一、背景
之前写过一篇《ConCurrentHashMap 源码解析》。
一读者,加了我微信,前几天发信息说:
解析不太对吧,源码好像有问题!
addCount
方法中的错误如下:
起初我内心,是相当抗拒的,
JDK8 源码能写错?闹着玩么?
常用并发工具类啊,怎么可能有错!
硬着头皮,看了大半天,
好像是不对哦!问问度娘,查查官网,
我去!!!JDK8 的源码,确实写的不对!!!
二、结论
我查到了 Oracle 官网上,此 bug 的说明——“ConcurrentHashMap.addCount()”
为什么错了? 为什么?
if (sc < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
对于 sc == rs + 1
来说,等号左边是 负数,
而等号右边是 正数,这个判断永远是 false
;
稍微改下就对了: (sc >>> RESIZE_STAMP_SHIFT) == rs + 1
。
好了,对于面试来讲,你能扯出一个 JDK 的 bug,
很拽!很牛气!这就行了。
下面的源码解析,很枯燥,也很烧脑。
压根没看过源码的,还是别往下看了。
`
-------------- !!!劝退分割线!!!--------------
`
三、bug 的影响分析
这个 bug 对 ConCurrentHashMap
有啥大的影响么?
大的影响,没有!小的影响,也可以认为没有。
你想啊,这是 JDK8
常用并发工具类,真有问题,早就修复了。
这个问题,到 JDK12
才更正了,也能侧面看出来,它无足轻重!
如果非要说有啥影响,在极端高并发时,某些线程会多执行几行代码,
0.001 毫秒为一个单位,耗时会多几个单位吧。
为什么,这个后面说道源码再细说。
网上其实也有文章,说这个 bug 的,
像我这样,解释详细的文章,就几乎没有了。
(sc >>> RESIZE_STAMP_SHIFT) == rs + 1
这个为什么是对的,可能这是全网独一份的解释。
自夸了,哈哈!!
四、sc == rs + 1 判断无效,源码解析
《addCount 源码解析》,你先看下,这篇文章,
对相关源码有个详细的了解。
Node<K,V>[] tab, nt; int n, sc;
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n);
if (sc < 0) { // sc < 0 ,说明正在扩容
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
// 上面的条件满足,说明扩容结束了,跳出循环
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
// sizeCtl 加 1,执行扩容方法
transfer(tab, nt);
}
// 第一个扩容线程,修改 SIZECTL,启动扩容
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
源码中,我写了关注释,相关逻辑先清楚下哈。
-
sizeCtl
不同值代表的不同意义
- 在执行数组初始化时,会设置为 -1,
- 初始化完成时,会设置为扩容阈值,
- 扩容时,置为负数,记录有几个线程在扩容
- 扩容完成时,会置为下次扩容的阈值。
这个没有为什么,也别问为什么。
属于王八的屁股——龟腚!代码的规定。
下面咱们看下,扩容时, sizeCtl
是怎么设置的。
//第一条扩容线程设置的某个特定基数
U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)
//后续线程加入扩容大军时每次加 1
U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)
//线程扩容完毕退出扩容操作时每次减 1
U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)
第一行代码,那一大串,等会画图解释,
记住这三行代码,都是 CAS 操作,
每多一个线程加入扩容,sizeCtl 就加 1,
扩容线程每退出一个,sizeCtl 就减 1
-
sizeCtl
在扩容时,初始值是什么?
int rs = resizeStamp(n); // n 是数组长度,
static final int resizeStamp(int n) {
return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}
U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)
private static int RESIZE_STAMP_BITS = 16;
resizeStamp(n)
是计算 rs
的。
Integer.numberOfLeadingZeros(n)
这个方法指,简单说,就是这个二进制数前面几个 0。
比如 2 的二进制是 10,前面应该是 30 个0,那这个方法就返回 30。
4 的二进制是 100,前面应该是 29 个 0,那这个方法返回 29。
1 << (RESIZE_STAMP_BITS - 1)
这个相当于 1 左移 15 位。高16位是0,拼 1 个1,再拼 15 个0。
我以n = 16 为例,画了图,图好解释。
启动扩容时,sizeCtl
等于 rs << RESIZE_STAMP_SHIFT) + 2
,
可以很清楚的看到,在扩容时,sizeCtl
这个参数,一定是负数。
我又是画图,又在分析,就是想说清楚:
sc == rs + 1
永远是 false
,一个正数,一个负数,当然不相等了。
五、(sc >>> RESIZE_STAMP_SHIFT) == rs + 1 什么意思
JDK 12
改成了下面这样
if (sc < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) == rs + 1 ||
(sc >>> RESIZE_STAMP_SHIFT) == rs + MAX_RESIZERS ||
(nt = nextTable) == null || transferIndex <= 0)
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
与 JDK 8 相比 (sc >>> RESIZE_STAMP_SHIFT) != rs
这个条件去掉了。
如果这篇文章,你看懂了,就能回答这个问题,
没果回答不上来,(sc >>> RESIZE_STAMP_SHIFT) == rs + 1
,这句的意思就没懂。
好了,现在到了本文的难点。
(sc >>> RESIZE_STAMP_SHIFT) == rs + 1
意思是扩容结束了!!!
为什么?
// 第一个扩容线程,修改 SIZECTL,启动扩容
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
按上面的例子,n = 16,此时 sizeCtl
就是下面这个
扩容方法 transfer 我摘录了一部分。
当然,整个方法,我另外的文章《addCount 启动扩容》,也有细节的说明。
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) {
nextTable = null; // 2、 nextTable置空
table = nextTab; // 3、 table 指向扩容后的数组,大小是原数组的 2 倍
sizeCtl = (n << 1) - (n >>> 1); // 4、 sizeCtl 设置为原数组 1.75倍
return;
}
// 1、数据搬运完毕,sizeCtl 减小 1
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true;
i = n; // recheck before commit
}
}
代码注释中,我写了 1、2、3、4 ,算是标记性事件。
其中 步骤 2、3、4 是只有最后一个退出扩容的线程,才会执行。
所有执行 transfer 方法的线程,都会执行 步骤 1 。
为什么,不解释,上面给的文章链接,点开自己看。
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n);
if (sc < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) == rs + 1 ||(nt = nextTable) == null
|| transferIndex <= 0)
break;
}
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
删除一些代码,只看关键的。
假设 n 是 16, 还没开始扩容,sizeCtl 的值是 12 正数。
此时有两个线程来触发扩容,同时执行这行,
U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)
线程 1 执行成功,将 sizeCtl
设置为负值, 进入 transfer
方法。
假设它就是结束扩容的线程,会执行 步骤 1、2、3、4。
线程 2 执行失败,尝试再次进入 while
循环,
执行判断 s >= (long)(sc = sizeCtl) && (tab = table) != null
,
-
如果在 步骤 4 之后执行判断,会直接跳出循环。
原因:s
是 元素数量,sizeCtl
是下次扩容阈值,s < sizeCtl
-
如果是在 步骤 3、4 之间执行判断,进入循环后
break
原因:命中sc >>> RESIZE_STAMP_SHIFT) == rs + 1
-
如果是在 步骤 2、3 之间执行判断,进入循环后 会跳出
原因:命中(nt = nextTable) == null
-
如果是在 步骤 1、2 之间执行判断,进入循环后 会跳出
原因:命中transferIndex <= 0
-
如果是在 步骤 1之前,执行判断,进入循环后,可能会跳出
原因:可能命中transferIndex <= 0
步骤1到4 和 命中的条件,刚好是反着的。
为什么这样子,自己琢磨,不明白,可以留言哈。
细节十分到位!Doug Lea 真大神呀!
本篇详细解释下, 步骤 3、4 之间执行的判断。
上文例子中,table
长度是 16 ,
那扩容后 数组长度为 32,
即步骤3以后,table
变更结束,长度是 32.
16 转化为 二进制,前面是27个0,
32 转化为 二进制,前面是26个0
int rs = resizeStamp(32);
看图,两个黄框里的,是等号左右两边的值。
*sc >>> RESIZE_STAMP_SHIFT) == rs + 1
讲到这里,如果不明白,倒回去,再看一遍哈。
我尽力,已经尽力了,又是分析,又是画图的。
位运算是出了名的难懂,大脑不擅长这个,不懂正常哈。
这个等式,左边部分,近似理解为:原数组长度为 n,转二进制,前面几个 0
右边部分,rs 近似理解为:此刻数组长度 m,转二进制,前面几个 0。
扩容完成时,二者相差1,扩容没完成,那数组就是同一个。
Doug Lea 就是大神,并发控制,做的真是极好,
但是,但是,但是,代码可读性,太差啦!!
可能这就是大神的风格,动不动就位运算,把自己给绕进去了,
六、题外话, sc == rs + MAX_RESIZERS
本文主要篇幅,在讨论 sc == rs + 1
,这个错哪了,更正之后,为什么对了。
但是,这个错误其实没有任何影响。JDK8 里,sc == rs + 1
前面还有个条件
(sc >>> RESIZE_STAMP_SHIFT) != rs
这个条件是有效的。
而且这个条件,与更正后的相比较,
(sc >>> RESIZE_STAMP_SHIFT) == rs + 1
两者是 等价
的,这个不用解释吧。
JDK8
的 bug 其实是 sc == rs + MAX_RESIZERS
这个是控制扩容线程数量的,达到最大值后,别再加入了。
理论上 JDK8
这个条件是失效的,所谓理论上的 BUG。
为什么用了这么多年,也没出啥事儿呀!
因为这个最大值是 65535
,太大啦!!!。
六万多个线程,去协助扩容。实际上永远达不到。
咱们不抬杠哈,杠精止步》
六万对于线程数量来说,绝对天文数字。
人活一辈子,也就三万多天!
我四个小时能跑完一个马拉松,不足四万步!
一个 java虚拟机,能同时跑几百个线程,很拽了!
所以严谨的说:
JDK8 ConCurrentHashMap的 bug,是伪 bug.
就像,有很多文章说:
ThreadLocal 有内存溢出的风险 —— 纯粹虾扯蛋!
我写过一篇文章《ThreadLocal 》,专门聊过这事儿。
百亿年后,太阳也会爆炸
可我的人生,百年而已,
于我来说,太阳永恒!
那是绝对的永恒!
如果你说太阳会爆炸,
太阳不是永恒的!
随缘吧,爱咋咋地!!!
最后,感谢那位,网名“s686”的朋友,
是你提点了我,才有了这篇文章,
再次感谢!