BUG探究 ConCurrentHashMap 源码中的 bug

一、背景

之前写过一篇《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. 在执行数组初始化时,会设置为 -1
  2. 初始化完成时,会设置为扩容阈值
  3. 扩容时,置为负数,记录有几个线程在扩容
  4. 扩容完成时,会置为下次扩容的阈值。

这个没有为什么,也别问为什么。

属于王八的屁股——龟腚!代码的规定。

下面咱们看下,扩容时, 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”的朋友,

是你提点了我,才有了这篇文章,

再次感谢!

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值