这篇文章,聊一下我最近才知道的一个关于 JDK 8 的 BUG 吧。java
首先说一下我是怎么发现这个 BUG 的呢?node
你们都知道我对 Dubbo 有必定的关注,前段时间 Dubbo 2.7.7 发布后我看了它的更新点,就是下面这个网址: https://github.com/apache/dubbo/releases/tag/dubbo-2.7.7
git
其中有 Bugfixes 这一部分:github
每个我都去简单的看了一下,其余的 Bugfixes 或多或少都和 Dubbo 框架有必定的关联性。可是上面红框框起来的部分彻底就是 JDK 的 Bug 了。面试
因此能够单独拎出来讲。redis
这个 Bug 我也是看到了这个地方才知道的,可是研究的过程当中我发现,这个怎么说呢:我怀疑这根本就不是 Bug ,这就是 Doug Lea 老爷子在钓鱼执法。spring
为何这样的说呢,你们看完本文就知道了。apache
点击 Dubbo 里面的连接,咱们能够看到具体的描述就是一个连接:api
打开这个连接:数组
https://bugs.openjdk.java.net/browse/JDK-8062841
咱们能够看到:这个 Bug 是位于大名鼎鼎的 concurrent 包里面的 computeIfAbsent 方法。
这个 Bug 在 JDK 9 里面被修复了,修复人是 Doug Lea。
而咱们知道 ConcurrentHashMap 就是 Doug Lea 的大做,能够说是“谁污染谁治理”。
要了解这个 Bug 是怎么回事,就必须先了解下面这个方法是干啥的:
java.util.concurrent.ConcurrentHashMap#computeIfAbsent
从这个方法的第二个入参 mappingFunction 咱们能够知道这是 JDK 8 以后提供的方法了。
该方法的含义是:当前 Map 中 key 对应的值不存在时,会调用 mappingFunction 函数,而且将该函数的执行结果(不为 null)做为该 key 的 value 返回。
好比下面这样的:
初始化一个 ConcurrentHashMap ,而后第一次去获取 key 为 why 的 value,没有获取到,直接返回 null。
接着调用 computeIfAbsent 方法,获取到 null 后调用 getValue 方法,将该方法的返回值和当前的 key 关联起来。
因此,第二次获取的时候拿到了 “why技术”。
其实上面的代码的 17 行的返回值就是 “why技术”,只是我为了代码演示,再去调用了一次 map.get() 方法。
知道这个方法干什么的,接下来就带你们看看 Bug 是什么。
咱们直接用这个问题里面给的测试用例,地址:
https://bugs.openjdk.java.net/secure/attachment/23985/Main.java
我只是在第 11 行和第 21 行加入了输出语句:
正常的状况下,咱们但愿方法正常结束,而后 map 里面是这样的:{AaAa=42,BBBB=42}
可是你把这个代码拿到本地去跑(须要 JDK 8 环境),你会发现,这个方法永远不会结束。由于它在进行死循环。
这就是 Bug。
知道 Bug 了,按理来讲就应该开始分析源码,了解为啥出现了会出现这个 Bug。
可是我想先插播一小节提问的艺术。由于这个 Bug 就是一个活生生的示例呀。
这个连接,我建议你打开看看,这里面还有 Doug Lea 老爷子的亲自解答:
https://bugs.openjdk.java.net/browse/JDK-8062841
首先咱们看提出问题的这我的对于问题的描述(能够先不用细看,反正看着也是懵逼的):
一般状况下,被提问的人分为两类人:
1.遇到过并知道这个问题的人,能够看的明白你在说什么。
2.虽然没有遇见过这个问题,但感受是本身熟悉的领域,可能知道答案,可是看了你的问题描述,也不知道你在说什么。
这个描述很长,我第一次看的时候很懵逼,很难理解他在说什么。我就是属于第二类人。
并且在大多数的问题中,第二类人比第一类人多不少。
可是当我了解到这个 Bug 的前因后果的时候,再看这个描述,其实写的很清楚了,也很好理解。我就变成第一类人了。
可是变成第一类人是有前提的,前提就是我已经了解到了这个地方 Bug 了。惋惜,如今是提问,而被提问的人,还对这个 Bug 不是特别了解。
即便,这个被提问的人是 Doug Lea。
能够看到,2014 年 11 月 04 日 Martin 提出这个问题后, Doug Lea 在不到一个小时内就进行了回复,我给你们翻译一下,老爷子回复的啥:
首先,你说你发现了 ConcurrentHashMap 的问题,可是我没有看到的测试用例。那么我就猜想一下是否是有其余线程在计算值的时候被卡住了,可是从你的描述中我也看不到相应的点。
简单来讲就是:Talk is cheap. Show me the code.(屁话少说,放码过来。)
因而另外一个哥们 Pardeep 在一个月后提交了一个测试案例,就是咱们前面看到的测试案例:
Pardeep 给 Martin 回复到下面这段话:
他开门见山的说:我注意这个 bug 很长时间了,而后我还有一个测试用例。
能够说这个测试案例的出现,才是真正的转折点。
而后他提出了本身的见解,这段描述简短有力的说出了问题的所在(后面咱们会讲到),而后他还提出了本身的意见。
不到一个小时,这个回到获得了 Doug Lea 的回复:
他说:小伙子的建议仍是不错的,可是如今还不是咱们解决这个问题的时候。咱们也许会经过代码改进死锁检查机制,以帮助用户 debug 他们的程序。可是目前而言,这种机制就算作出来,工做效率也是很是低下的,好比在当前的这个案例下。可是如今咱们至少清楚的知道,是否要实现这种机制是不能肯定的。
总之一句话:问题我知道了,可是目前我还没想到好的解决方法。
可是,在 19 天之后,老爷子又回来处理这个问题了:
此次的回答可谓是峰回路转,他说:请忽略我以前的话。咱们发现了一些可行的改进方法,这些改进能够处理更多的用户错误,包括本报告中所提供的测试用例,即解决在 computeIfAbsent 中提供的函数中进行递归映射更新致使死锁这样的问题。咱们会在 JDK 9 里面解决这个问题。
因此,回顾这个 Bug 被提出的过程。
首先是 Martin 提出了这个问题,并进行了详细的描述。惋惜的是他的描述很专业,是站在你已经了解了这个 Bug 的立场上去描述的,让人看的很懵逼。
因此 Doug Lea 看到后也表示这啥呀,没搞懂。
而后是 Pardeep 跟进这个问题,转折点在于他抛出的这个测试案例。而我相信,既然 Martin 能把这个问题描述的很清楚,他必定是有一个本身的测试案例的,可是他没有展示出来。
因此,朋友们,测试案例的重要性不言而喻了。问问题的时候不要只是抛出异常,你至少给段对应的代码,或者日志,或者一次性描述清楚,写在文档里面发出来也行呀。
致使这个 Bug 的缘由也是一句话就能说清楚,前面的 Pardeep 老哥也说了:
问题在于咱们在进行 computeIfAbsent 的时候,里面还有一个 computeIfAbsent。而这两个 computeIfAbsent 它们的 key 对应的 hashCode 是同样的。
你说巧不巧。
当它们的 hashCode 是同样的时候,说明它们要往同一个槽放东西。
而当第二个元素进来的时候,发现坑位已经被前一个元素占领了,可能就是这样的画风:
接下来咱们就解析一下 computeIfAbsent 方法的工做流程:
第一步是计算 key 对应的 hashCode 应该放到哪一个槽里面。
而后是进入1649 行的这个 for 循环,而这个 for 循环是一个死循环,它在循环体内部判断各类状况,若是知足条件则 break 循环。
首先,咱们看一下 “AaAa” 和 “BBBB” 通过 spread 计算(右移 16 位高效计算)后的 h 值是什么:
哇塞,好巧啊,从框起来的这两部分能够看到,都是 2031775 呢。
说明他们要在同一个槽里面搞事情。
先是 “AaAa” 进入 computeIfAbsent 方法:
在第一次循环的时候 initTable,没啥说的。
第二次循环先是在 1653 行计算出数组的下标,并取出该下标的 node。发现这个 node 是空的。因而进入分支判断:
在标号为 ① 的地方进行 cas 操做,先用 r(即 ReservationNode)进行一个占位的操做。
在标号为 ② 的地方进行 mappingFunction.apply 的操做,计算 value 值。若是计算出来不为 null,则把 value 组装成最终的 node。
在标号为 ③ 的东西把以前占位的 ReservationNode 替换成标号为 ② 的地方组装成的node 。
问题就出现标号为 ② 的地方。能够看到这里去进行了 mappingFunction.apply 的操做,而这个操做在咱们的案例下,会触发另外一次 computeIfAbsent 操做。
如今 “AaAa” 就等着这个 computeIfAbsent 操做的返回值,而后进行下一步操做,也就是进行标号为 ③ 的操做了。
接着 “BBBB” 就来了。
经过前面咱们知道了 “BBBB” 的 hashCode 通过计算后也是和 “AaAa” 同样。因此它也要想要去那个槽里面搞事情。
惋惜它来晚了一步。
带你们看一下对应的代码:
当 key 为 “BBBB” 的时候,算出来的 h 值也是 2031775。
它也会进入 1649 行的这个死循环。而后进行各类判断。
接下来我要论证的是:
在本文的示例代码中,当运行到 key 为 “BBBB” 的时候,进入 1649 行这个死循环后,就退不出来了。程序一直在里面循环运行。
在标号为 ① 的地方,因为这个时候 tab 已经不为 null 了,因此不会进入这个分支。
在标号为 ② 的地方,因为以前 “AaAa” 已经扔了一个 ReservationNode 进去占位置了,因此不等于 null。因此,也就不会进入这个分支。
怕你懵逼,给你配个图,真是暖男做者石锤了:
接下来到标号为 ③ 的地方,里面有一个 MOVED,这个 MOVED 是干啥的呢?
表示当前的 ConcurrentHashMap 是不是在进行扩容。
很明显,如今尚未到该扩容的时候:
第 1678 行的 f 就是以前 “AaAa” 扔进去的 ReservationNode ,这个 Node 的 hash 是 -3,不等于MOVED(-1)。
因此,不会进入这个分支判断。
接下来,能进的只有标号为 ④ 的地方了,因此咱们只须要把这个地方攻破,就完全了解这个 Bug 了。
走起:
经过前面的分析咱们知道了,当前案例状况下,只会进入 1672 行这个分支。
而这个分支里面,还有四个判断。咱们一个个的攻破:
标号为 ⑤ 的地方,tabAt 方法取出来的对象,就是以前 “AaAa” 放进去的占位的 ReservationNode ,也就是这个 f 。因此能够进入这个分支判断。
标号为 ⑥ 的地方,fh >=0 。而 fh 是当前 node 的 hash 值,大于 0 说明当前是按照链表存储的数据。以前咱们分析过了,当前的 hash 值是 -3。因此,不会进入这个分支。
标号为 ⑦ 的地方,判断 f 节点是不是红黑树存储。固然不是的。因此,不会进入这个分支。
标号为 ⑧ 的地方,binCount 表明的是该下标里面,有几个 node 节点。很明显,如今一个都没有。因此当前的 binCount 仍是 0 。因此,不会进入这个分支。
完了。分析完了。
Bug 也就出来了,一次 for 循环结束后,没有 break。苦就苦在这个 for 循环仍是个死循环。
再来一个上帝视角,看看当 key 为 “BBBB” 的时候发生了什么事情:
进入无限循环内:
①.通过 “AaAa” 以后,tab 就不为 null 了。
②.当前的槽中已经被 “AaAa” 先放了一个 ReservationNode 进行占位了,因此不为 null。
③.当前的 map 并无进行扩容操做。
④.包含⑤、⑥、⑦、⑧。
⑤.tabAt 方法取出来的对象,就是以前 “AaAa” 放进去的占位的 ReservationNode,因此知足条件进入分支。
⑥.判断当前是不是链表存储,不知足条件,跳过。
⑦.判断当前是不是红黑树存储,不知足条件,跳过。
⑧.判断当前下标里面是否放了 node,不知足条件(“AaAa” 只有个占位的Node ,并无初始完成,因此尚未放到该下标里面),进入下一次循环。
而后它就在死循环里面出不来了!
我相信如今你们对于这个 Bug 的来路了解清楚了。
若是你是在 idea 里面跑这个测试用例,也能够这样直观的看一眼:
点击这个照相机图标:
从线程快照里面其实也是能够看到端倪的,你们能够去分析分析。
有的观点说的是因为线程安全的致使的死循环,通过分析我以为这个观点是不对的。
它存在死循环,不是因为线程安全致使的,纯粹是本身进入了死循环。
或者说,这是一个“彩蛋”?
或者......自信点,就说这事 Bug ,能稳定复现的那种。
那么咱们若是是使用 JDK 8 怎么避免踩到这个“彩蛋”呢?
看看 Dubbo 里面是怎么解决的:
先调用了 get 方法,若是返回为 null,则调用 putIfAbsent 方法,这样就能实现和以前同样的效果了。
若是你在项目中也有使用 computeIfAbsent 的地方,建议也这样去修改。
说到 ConcurrentHashMap get 方法返回 null,我就想起了以前讨论的一个面试题了:
答案都写在这个文章里面了,有兴趣的能够了解一下《这道面试题我真不知道面试官想要的回答是什么》
Bug 的解决 其实完全理解了这个 Bug 以后,咱们再来看一下 JDK 9 里面的解决方案,看一下官方源码对比:
http://gee.cs.oswego.edu/cgi-bin/viewcvs.cgi/jsr166/src/main/java/util/concurrent/ConcurrentHashMap.java?r1=1.258&r2=1.259&sortby=date&diff_format=f
就加了两行代码,判断完是不是红黑树节点后,再判断一下是不是 ReservationNode 节点,由于这个节点就是个占位节点。若是是,则抛出异常。
就这么简单。没有什么神秘的。
因此,若是你在 JDK 9 里面执行文本的测试用例,就会抛出 IllegalStateException。
这就是 Doug Lea 以前提到的解决方案:
了解了这个 Bug 的前因后果后,特别是看到解决方案后,咱们就能轻描淡写的说一句:
害,就这?没据说过!
另外,我看 JDK 9 修复的时候还不止修复了一个问题:
http://hg.openjdk.java.net/jdk9/jdk9/jdk/file/6dd59c01f011/src/java.base/share/classes/java/util/concurrent/ConcurrentHashMap.java
你去翻一翻。发现,啊,全是知识点啊,学不动了。
为何我在文章的一开始就说了这是 Doug Lea 在钓鱼执法呢?
由于在最开始提问的艺术那一部分,我相信,Doug Lea 跑完那个测试案例以后,内心也有点数了。
大概知道问题在哪了,并且从他的回答和他写的文档中我也有理由相信,他写的这个方法的时候就知道可能会出问题。
并且,Pardeep 的回复中提到了文档,那咱们就去看看官方文档对于该方法的描述是怎样的:
https://docs.oracle.com/javase/8/docs/api/
文档中说函数方法应该简短,简单。并且不能在更新的映射的时候更新映射。就是说不能套娃。
套娃,用程序说就是recursive(递归),按照文档说若是存在递归,则会抛出 IllegalStateException 。
而提到递归,你想到了什么?
我首先就想到了斐波拉契函数。咱们用 computeIfAbsent 实现一个斐波拉契函数以下:
public class Test { static Map<Integer, Integer> cache = new ConcurrentHashMap<>(); public static void main(String[] args) { System.out.println("f(" + 14 + ") =" + fibonacci(14)); } static int fibonacci(int i) { if (i == 0) return i; if (i == 1) return 1; return cache.computeIfAbsent(i, (key) -> { System.out.println("Slow calculation of " + key); return fibonacci(i - 2) + fibonacci(i - 1); }); } }
这就是递归调用,我用 JDK 1.8 跑的时候并无抛出 IllegalStateException,只是程序假死了,缘由和咱们前面分析的是同样同样的。我理解这个地方是和文档不符的。
因此,我怀疑是 Doug Lea 在这个地方钓鱼执法。
既然都说到 currentHashMap(CHM)了,那我说一个相关的注意点吧。
首先 CHM 必定能保证线程安全吗?
是的,CHM 自己必定是线程安全的。可是,若是你使用不当仍是有可能会出现线程不安全的状况。
给你们看一点 Spring 中的源码吧:
org.springframework.core.SimpleAliasRegistry
在这个类中,aliasMap 是 ConcurrentHashMap 类型的:
在 registerAlias 和 getAliases 方法中,都有对 aliasMap 进行操做的代码,可是在操做以前都是用 synchronized 把 aliasMap 锁住了。
为何?为何咱们操做 ConcurrentHashMap 的时候还要加锁呢?
这个是根据场景而定的,这个别名管理器,在这里加锁应该是为了不多个线程操做 ConcurrentHashMap 。
虽然 ConcurrentHashMap 是线程安全的,可是假设若是一个线程 put,一个线程 get,在这个代码的场景里面是不容许的。
若是以为不太好理解的话我举一个 redis 的例子。
redis 的 get、set 方法都是线程安全的吧。可是你若是先 get 再 set,那么在多线程的状况下仍是会有问题的。
由于这两个操做不是原子性的。因此 incr 就应运而生了。
我举这个例子的是想说线程安全与否不是绝对的,要看场景。给你一个线程安全的容器,你使用不当仍是会有线程安全的问题。
再好比,HashMap 必定是线程不安全的吗?
说不能说的这么死吧。它是一个线程不安全的容器。可是若是个人使用场景是只读呢?
在这个只读的场景下,它就是线程安全的。
总之,看场景。道理,就是这么一个道理。
因此点个“赞”吧,周更很累的,不要白嫖我,须要一点正反馈。
才疏学浅,不免会有纰漏,若是你发现了错误的地方,还请你留言指出来,我对其加以修改。
感谢您的阅读,我坚持原创,十分欢迎并感谢您的关注。
我是 why,一个被代码耽误的文学创做者,不是大佬,可是喜欢分享,是一个又暖又有料的四川好男人。
欢迎关注个人微信公众号:why技术。在这里我会分享一些java技术相关的知识,用匠心敲代码,对每一行代码负责。偶尔也会荒腔走板的聊一聊生活,写一写书评、影评。感谢你的关注,愿你我共同进步。