JDK BUG
这篇文章,聊一下我最近才知道的一个关于 JDK 8 的 BUG 吧。
首先说一下我是怎么发现这个 BUG 的呢?
大家都知道我对 Dubbo 有一定的关注,前段时间 Dubbo 2.7.7 发布后我看了它的更新点,就是下面这个网址:
其中有 Bugfixex 这一部分:
每一个我都去简单的看了一下,其他的 Bugfixes 或多或少都和 Dubbo 框架有一定的关联性。但是上面红框框起来的部分完全就是 JDK 的 Bug 了。
所以可以单独拎出来说。
这个 Bug 我也是看到了这个地方才知道的,但是研究的过程中我发现,这个怎么说呢:我怀疑这根本就不是 Bug ,这就是 Doug Lea 老爷子在钓鱼执法。
为什么这样的说呢,大家看完本文就知道了。
Bug 稳定复现 点击 Dubbo 里面的链接,我们可以看到具体的描述就是一个链接:
打开这个链接:
要了解这个 Bug 是怎么回事,就必须先了解下面这个方法是干啥的:
从这个方法的第二个入参 mappingFunction 我们可以知道这是 JDK 8 之后提供的方法了。该方法的含义是:当前 Map 中 key 对应的值不存在时,会调用 mappingFunction 函数,并且将该函数的执行结果(不为 null)作为该 key 的 value 返回。比如下面这样的:
初始化一个 ConcurrentHashMap ,然后第一次去获取 key 为 why 的 value,没有获取到,直接返回 null。接着调用 computeIfAbsent 方法,获取到 null 后调用 getValue 方法,将该方法的返回值和当前的 key 关联起来。所以,第二次获取的时候拿到了 “why技术”。其实上面的代码的 17 行的返回值就是 “why技术”,只是我为了代码演示,再去调用了一次 map.get() 方法。知道这个方法干什么的,接下来就带大家看看 Bug 是什么。我们直接用这个问题里面给的测试用例,地址:
正常的情况下,我们希望方法正常结束,然后 map 里面是这样的:{AaAa=42,BBBB=42}但是你把这个代码拿到本地去跑(需要 JDK 8 环境),你会发现,这个方法永远不会结束。因为它在进行死循环。
这就是 Bug。
提问的艺术
知道 Bug 了,按理来说就应该开始分析源码,了解为啥出现了会出现这个 Bug。但是我想先插播一小节提问的艺术。因为这个 Bug 就是一个活生生的示例呀。 这个链接,我建议你打开看看,这里面还有 Doug Lea 老爷子的亲自解答:
通常情况下,被提问的人分为两类人:
- 遇到过并知道这个问题的人,可以看的明白你在说什么。
- 虽然没有碰见过这个问题,但感觉是自己熟悉的领域,可能知道答案,但是看了你的问题描述,也不知道你在说什么。
他开门见山的说:我注意这个 bug 很长时间了,然后我还有一个测试用例。可以说这个测试案例的出现,才是真正的转折点。然后他提出了自己的看法,这段描述简短有力的说出了问题的所在(后面我们会讲到),然后他还提出了自己的意见。不到一个小时,这个回到得到了 Doug Lea 的回复:
这次的回答可谓是峰回路转,他说:请忽略我之前的话。我们发现了一些可行的改进方法,这些改进可以处理更多的用户错误,包括本报告中所提供的测试用例,即解决在 computeIfAbsent 中提供的函数中进行递归映射更新导致死锁这样的问题。我们会在 JDK 9 里面解决这个问题。所以,回顾这个 Bug 被提出的过程。首先是 Martin 提出了这个问题,并进行了详细的描述。可惜的是他的描述很专业,是站在你已经了解了这个 Bug 的立场上去描述的,让人看的很懵逼。所以 Doug Lea 看到后也表示这啥呀,没搞懂。然后是 Pardeep 跟进这个问题,转折点在于他抛出的这个测试案例。而我相信,既然 Martin 能把这个问题描述的很清楚,他一定是有一个自己的测试案例的,但是他没有展现出来。所以,朋友们,测试案例的重要性不言而喻了。问问题的时候不要只是抛出异常,你至少给段对应的代码,或者日志,或者一次性描述清楚,写在文档里面发出来也行呀。
Bug 的原因
导致这个 Bug 的原因也是一句话就能说清楚,前面的 Pardeep 老哥也说了:问题在于我们在进行 computeIfAbsent 的时候,里面还有一个 computeIfAbsent。而这两个 computeIfAbsent 它们的 key 对应的 hashCode 是一样的。你说巧不巧。当它们的 hashCode 是一样的时候,说明它们要往同一个槽放东西。而当第二个元素进来的时候,发现坑位已经被前一个元素占领了,可能就是这样的画风:
第一步是计算 key 对应的 hashCode 应该放到哪个槽里面。然后是进入1649 行的这个 for 循环,而这个 for 循环是一个死循环,它在循环体内部判断各种情况,如果满足条件则 break 循环。首先,我们看一下 “AaAa” 和 “BBBB” 经过 spread 计算(右移 16 位高效计算)后的 h 值是什么:
当 key 为 “BBBB” 的时候,算出来的 h 值也是 2031775。它也会进入 1649 行的这个死循环。然后进行各种判断。接下来我要论证的是:****在本文的示例代码中,当运行到 key 为 “BBBB” 的时候,进入 1649 行这个死循环后,就退不出来了。程序一直在里面循环运行。
第 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 ,并没有初始完成,所以还没有放到该下标里面),进入下一次循环。然后它就在死循环里面出不来了!
先调用了 get 方法,如果返回为 null,则调用 putIfAbsent 方法,这样就能实现和之前一样的效果了。如果你在项目中也有使用 computeIfAbsent 的地方,建议也这样去修改。说到 ConcurrentHashMap get 方法返回 null,我就想起了之前讨论的一个面试题了:
Bug 的解决
其实彻底理解了这个 Bug 之后,我们再来看一下 JDK 9 里面的解决方案,看一下官方源码对比:
就加了两行代码,判断完是否是红黑树节点后,再判断一下是否是 ReservationNode 节点,因为这个节点就是个占位节点。如果是,则抛出异常。
另外,我看 JDK 9 修复的时候还不止修复了一个问题:
钓鱼执法
为什么我在文章的一开始就说了这是 Doug Lea 在钓鱼执法呢?因为在最开始提问的艺术那一部分,我相信,Doug Lea 跑完那个测试案例之后,心里也有点数了。大概知道问题在哪了,而且从他的回答和他写的文档中我也有理由相信,他写的这个方法的时候就知道可能会出问题。而且,Pardeep 的回复中提到了文档,那我们就去看看官方文档对于该方法的描述是怎样的:
文档中说函数方法应该简短,简单。而且不能在更新的映射的时候更新映射。就是说不能套娃。套娃,用程序说就是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 在这个地方钓鱼执法。CHM一定线程安全吗?
既然都说到 currentHashMap(CHM)了,那我说一个相关的注意点吧。首先 CHM 一定能保证线程安全吗?是的,CHM 本身一定是线程安全的。但是,如果你使用不当还是有可能会出现线程不安全的情况。给大家看一点 Spring 中的源码吧:
在这个类中,aliasMap 是 ConcurrentHashMap 类型的:这个是根据场景而定的,这个别名管理器,在这里加锁应该是为了避免多个线程操作 ConcurrentHashMap 。虽然 ConcurrentHashMap 是线程安全的,但是假设如果一个线程 put,一个线程 get,在这个代码的场景里面是不允许的。如果觉得不太好理解的话我举一个 redis 的例子。redis 的 get、set 方法都是线程安全的吧。但是你如果先 get 再 set,那么在多线程的情况下还是会有问题的。因为这两个操作不是原子性的。所以 incr 就应运而生了。我举这个例子的是想说线程安全与否不是绝对的,要看场景。给你一个线程安全的容器,你使用不当还是会有线程安全的问题。再比如,HashMap 一定是线程不安全的吗?说不能说的这么死吧。它是一个线程不安全的容器。但是如果我的使用场景是只读呢?在这个只读的场景下,它就是线程安全的。总之,看场景。道理,就是这么一个道理。