什么?JDK8的ConcurrentHashMap 有 bug

1,588 阅读4分钟

前言

大家好,我是 Skow

大家看到标题是不是第一反应,怎么可能 JDK8 的 ConcurrentHsahMap 会有 bug,肯定是个标题党,我在工作中都使用的好好的

且慢,的确我们 JDK8 中存在这个 Bug,但是这个 Bug 不影响使用,并且在 JDK12 中对其进行了修复

在这个JDK 的 Bug 收集论坛上,我们可以看到这个报告指出的是在 ConcurrentHsahMap 进行 addCount() 的时候会有问题存在,我们先看一下具体在 JDK8 和 JDK12中该方法的体现

JDK8

private final void addCount(long x, int check) {
        //...
        if (check >= 0) {
            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) {
                    if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                        sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                        transferIndex <= 0)
                        break;
                    //...
                }
                //...
            }
        }
    }

JDK12

private final void addCount(long x, int check) {
    //...
    if (check >= 0) {
        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) << RESIZE_STAMP_SHIFT;
            if (sc < 0) {
                if (sc == rs + MAX_RESIZERS || sc == rs + 1 ||
                        (nt = nextTable) == null || transferIndex <= 0)
                    break;
                //...
            } 
            //...
        }
    }
}

我们仔细对比一下代码,就可以发现在 JDK12 中去除了 (sc >>> RESIZE_STAMP_SHIFT) != rs

这个条件判断,那问题就来,这个条件出现了什么问题?

Doug Lea 这么细的人当初为什么没考虑到,欢迎走入今天的文章,揭秘一下这个 BUG 的前世今生,对 ConcurrentHashMap 扩容和计数逻辑熟悉的同学,可直接跳转第三部份看问题分析即可

基本定义

默认小伙伴对 ConcurrentHashMap 有初步的了解,以及清楚 CAS 算法等基础知识,此文不做展开说明

想要知道 ConcurrentHashMap 中的扩容和计数逻辑,我们需要先明白一些定义,不然直接开始读源码,我们会有点吃力

接下来会罗列一下一会我们 addCount 方法中会用到的一些基本定义,我们先混个眼熟

  • sizeCtl

    sizeCtl 的定义有点复杂,但是也有点重要

  • sizeCtl < -1表示当前有 N-1 个线程正在执行扩容操作,如 -2 就表示有 2-1 个线程正在扩容

  • sizeCtl = -1 表示当前正在初始化数组

  • sizeCtl = 0 默认状态,表示数组还没有被初始化

  • sizeCtl > 0 记录下一次需要扩容的大小。

  • counterCells

    计数单元,非空的时候,大小通常为2次幂

  • RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS

    用于生成记录扩容线程个数的戳记的移位数

  • MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1

    辅助扩容的最大线程数

  • transferIndex

    扩容任务分配的进度标示。初始为n,逆序扩容,每次减一个步长的位置,最终减至<= 0,表示整个扩容任务分配结束

计数逻辑

假设现在让你设计一个并发下的 int 类型的数值相加,你会怎么考虑?

第一反应是不是使用 volatile 或者 synchroieed 来保证数据相加的准确性,或许会有同学想到用volatile修饰变量然后利用 CAS进行相加,性能会不会较优

但是在并发激烈的情况下,CAS也可能会导致部份线程一直处于自旋的情况

为了避免这种极端的并发激烈情况,Doug Lea 采取了一种分而治之的思想,来尽可能避免这种竞争的发生

那就是利用 baseCount + CounterCell

baseCount 顾名思义就是一个基础的计数变量,counterCells 上面我们有简短说明,它是一个计数单元,非空的时候,大小通常为2次幂,在未发生竞争的时候,cas 修改会直接去修改 baseCount,假设 CAS 修改失败也不会进行自旋,而是以哈希映射的方式在 counterCells 中获取到一个数组下标的位置来进行计数,若依然没法找到对应合适的位置会继续重复这个操作,多次失败后就会针对 counterCells 进行扩容,继续孜孜不倦的寻找合适的格子来计数

了解了 addCount 的大体设计思想,我们来看下源码

/**
 * Adds to count, and if table is too small and not already
 * resizing, initiates transfer. If already resizing, helps
 * perform transfer if work is available.  Rechecks occupancy
 * after a transfer to see if another resize is already needed
 * because resizings are lagging additions.
 *
 * @param x the count to add
 * @param check if <0, don't check resize, if <= 1 only check if uncontended
 */
private final void addCount(long x, int check) {
    CounterCell[] aslong b, s;
    // 这边 compareAndSwapxxx 都是 CAS操作
    // 此处的意思就是如果 counterCells 不为 null,就直接往 counterCells 里操作,反之则直接操作 baseCount
    if ((as = counterCells) != null ||
        !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
        CounterCell a; long v; int m;
        // 默认线程安全,没有竞争
        boolean uncontended = true;
        if (as == null || (m = as.length - 1) < 0 ||
            // ThreadLocalRandom.getProbe() 取当前线程的 hash 值
            // 此处的 compareAndSwapLong 做的事情是找到不为 null 的格子,进行 value+x,CAS 失败就进入 fullAddCount
            (a = as[ThreadLocalRandom.getProbe() & m]) == null ||
            !(uncontended =
              U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
            fullAddCount(x, uncontended);
            return;
        }
        if (check <= 1)
            return;
        s = sumCount();
    }
    // 扩容代码暂时省略
}


简单的逻辑大致在代码里都已经注释了,摘出来两点和大家在分享一下

fullAddCount 这个方法,其实就是尽可能去保证计数成功,里面的逻辑不展开说明,是一堆 if lese,感兴趣的小伙伴可以去进一步阅读

第二点,此处 check 方法的判断

check 的值,我们可以理解为链表的长度,会根据不同的调用,入参进行变化

如果我们是在 put 方法中调用的 addCount 那么 check 一定大于等于 2,如果是在 computeIfAbsent 方法中调用的,那么 check 为 可能为1

当 check <= 1 就不会进行扩容检查操作,也是为了保障性能,所以直接 return

扩容逻辑

话外音:咋还不说 bug,都看了这么久了

小伙伴们别着急,只有明白了这个计数和扩容逻辑,我们才能理解到这个 bug 的精髓所在

ConcurrentHashMap 的扩容逻辑,我认为是 ConcurrentHashMap 较为核心的逻辑之一,我们来细细品鉴一下

private final void addCount(long x, int check) {
    // 只有当外部传进来的 check 大于等于0的时候,才会开始检查扩容
    if (check >= 0) {
        Node<K,V>[] tab, nt; int n, sc;
        // sizeCtl 会被赋值为下一次扩容的大小,,此处比较 s 和 下一次扩容的大小
        // tab 未发生改变并且数组长度未达到阈值
        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 || sc == rs + 1 ||
                    sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                    transferIndex <= 0)
                    break;
                if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                    transfer(tab, nt);
            }
            else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                         (rs << RESIZE_STAMP_SHIFT) + 2))
                transfer(tab, null);
            s = sumCount();
        }
    }
}

进入 while 循环后,我们可以看到 resizeStamp 这个方法,虽然和我们的 bug 理解无关,但是这个方法很有意思,作为拓展我们展开说下,不感兴趣的小伙伴可以往下扒拉扒拉

/**
 * Returns the stamp bits for resizing a table of size n.
 * 返回值作为正在扩容的数组的长度n的一个标志位
 * Must be negative when shifted left by RESIZE_STAMP_SHIFT.
 * 通过 RESIZE_STAMP_SHIFT 左移 16 位时一定是一个负数
 */
static final int resizeStamp(int n) {
    return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}

numberOfLeadingZeros 这个方法主要的目的就是为了得到 n 转成二进制后的最高非 0 位前的 0 的个数。

小张:啥玩意,你这说的,听不懂

上例子

假设传入的 n 为 16,numberOfLeadingZeros 得到结果即如图所示

然后在当前 JDK8 版本中 RESIZE_STAMP_BITS 默认为 16

1 << (RESIZE_STAMP_BITS - 1) 等于 1 << 15 等于 32678

27 | 32678 = 32795 二进制就是:1000 0000 0001 1011

利用 rs 即可反推出,当前需要扩容的数组长度!切记,切记,切记。后文理解 bug 需要用到

理解了 resizeStamp 的意图之后我们继续往下看,当 sc 小于 0 的时候,表示 ConcurrentHashMap 已经开始扩容,会去判断是否扩容完成,是否需要线程去辅助继续帮助扩容

反之则说明还没有开始扩容,则触发第一个扩容线程,进行 cas 修改,这边是 rs << RESIZE_STAMP_SHIFT) + 2,为什么是 + 2,而不是 +1

我们又要回到我们刚才算出来的 rs 的作用是什么

  • 高 16 位代表当前扩容的标记

  • 低 16 位代表了扩容的线程数

知道 rs 作用我们就比较好理解为什么需要 +2,而不是+1了

因为我们的 rs 最终都是要赋值给 sizeCtl 的,而 sizeCtl 负数才代表扩容,而将 rs 左移 16 位就刚好使得最高位为 1,此时低 16 位全部是 0,而因为低 16 位要记录扩容线程数,按常理 +1 即可,但是  sizeCtl 中 -1 这个数值已经被使用了,用来代替当前有线程准备扩容,所以只好 +2

问题分析

前面铺垫完,我们进入正题

我们来看下扩容的五个条件判断

if (check >= 0) {
            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) {
                    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);
                }
                else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                             (rs << RESIZE_STAMP_SHIFT) + 2))
                    transfer(tab, null);
                s = sumCount();
            }
        }

  • sc == rs + 1

    最后一个线程正在处理扩容工作,扩容工作即将结束

  • sc == rs + MAX_RESIZERS

    当前扩容线程数已满,无需辅助帮助扩容

  • (nt = nextTable) == null

    扩容完成之后把 nextTable 置为 null

  • transferIndex <= 0

    当前可供扩容的下标已经全部分配完毕,也代表了当前线程扩容结束

(sc >>> RESIZE_STAMP_SHIFT) != rs

这个条件也是我们的 bug 所在,我们理解这个条件为 将 sc 右移 16 位 判断是否不等于我们的 rs

sc = sizeCtl,即扩容完成后 sc 会被赋值为 扩容后的数组的大小

rs 即刚才我们分析的扩容时间戳

那么当进入这个 if 判断,其实一定意味着 我们的 tab 被发生的改变

那么一定有另一个线程正在使 ConcurrentHashMap 处于扩容中,或者处于扩容结束

  1. 当处于扩容中  (nt = nextTable) == null  可能为 true
 private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
 ...
 
     if (finishing) {
         nextTable = null;
         // table changed after "nextTable = null"。
         table = nextTab;
         sizeCtl = (n << 1) - (n >>> 1);
         return;
     }
 ...
 }

  1. 当扩容结束,这个 sizeCtl 已被改变,则和当前线程拿到的 sizeCtl 不符,即 (sc >>> RESIZE_STAMP_SHIFT) != rs  这个条件判断永远为 false, (sc == rs + 1 || sc == rs + MAX_RESIZERS ) 永远不会为 true,扩容线程数永远不受限制

那么,这个 bug 是否会影响到我们的使用呢?

当这个条件判断为 false 的时候,进入第二个 if又是一个 cas 判断,这个 cas 判断将会失败

并且当前线程在下一个迭代中将会 break 出 while 循环 或者继续尝试去帮助扩容

虽然设计的思路略有偏差,但是好在不影响实际使用

小结

至此,我们的分析已经结束,这个 bug 比较隐蔽的,但是我们理解 ConcurrentHashMap 的扩容逻辑后,也还是可以去 get 到 Bug 的产生

我们简单归纳下,就是判断扩容的时候的一个条件判断写错,导致扩容的线程数不受控制,但是不影响实际使用,因为在真正的扩容逻辑 transfer 方法中,参与扩容的线程也会发现其实扩容已经结束,不参与扩容即可

本文没有分析 transfer 的真正逻辑,感兴趣的同学可以去看下