ConcurrentHashMap扩容时像蚂蚁一样团结

651 阅读8分钟

在Map的家族中,ConcurrentHashMap被专门设计用来解决并发问题,它的实现逻辑相对于其他一些Map的实现来说比较复杂。想直接通过阅读代码来理解它,比较困难。我也遇到了这个困难,但我有一些惊奇的发现,今天就只说其中的一个,那就是ConcurrentHashMap的扩容,相当的团结!

跟它的家族其他成员一样,ConcurrentHashMap的构造函数也只是对几个成员变量进行了初始化,并没有开辟相应的存储空间。其相应的存储空间是在放入第一个元素的时候检查并执行初始化的。我们来看一下它put方法的逻辑框架:

public V put(K key, V value) {
    return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            //cas操作放入头节点
        }
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        else {
           //和正常放入value有关的操作
        }
    }
    addCount(1L, binCount);
    return null;
}

我很善意的去掉了好多代码,只把跟逻辑分支有关的留了下来。我们可以看到,代码大体分为四个分支:

  • 如果table为空的话,调用initTable()进行初始化
  • 如果table不为空,但是第i个元素值为null的话,使用cas操作放置头节点
  • 如果头节点不为空且hash值为MOVED(MOVED为常量,值为-1),调用了一个名字叫helpTransfer的方法
  • 最后,就常规操作,加锁放入一个新的值

我要详细说的,就是这个helpTransfer方法。这家伙啥意思,help = 帮忙,帮啥忙?为啥要帮忙?怎么帮忙? 这个问题,要先从那个叫MOVED的hash值说起。为了很好的控制并发,ConcurrentHashMap新增了多个具有特殊意义的标识,在源码中,MOVED的注释如下:

static final int MOVED     = -1; // hash for forwarding nodes

ForwardingNode 是 Node的一个子类,它是特殊的子类,只在扩容进行过程中才会出现。如果任意一个操作取到这种类型的节点,那么执行该操作的线程立马就可以知道,当前的Map正在扩容中。此时,它会停止自己的操作,调用helpTransfer方法,帮助扩容。ConcurrentHashMap就靠此方法,让并发操作的多个线程,变得团结起来,一起完成扩容操作。当我看懂此处的时候,感受到了浓浓的基情,眼前又浮现出大家纷纷停止手头的工作,一起守在同一个屏幕前面找bug的场景。

然鹅,想帮忙就能帮吗,现实不是这个样子。我们一步步分析,先来看一下,ConcurrentHashMap是怎么记录参与扩容的线程数与扩容时的数组容量的,这里涉及到一个短小但重要的方法,叫做resizeStamp,理解它是理解怎么判定能否帮助扩容的关键。我们看这个代码的逻辑:

static final int resizeStamp(int n) {
    return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}

传入的参数n,是当前数组的长度,该方法简单的进行了几个操作。或操作符前半部分,它获取到n的二进制表示形式最高位的1之前的0的个数,因为n是二的幂值,所以其二进制表示中只含有一个1。后半部分中,讲1左移RESIZE_STAMP_BITS-1位。RESIZE_STAMP_BITS取值为16,是一个常数。将1左移15位,最后得到的是一个以1开头,后边跟着15个0的二进制数。当它与前半部分的值做或运算之后,其实就得到了前半部分数值的负数形式。举个例子,假设当前n为16。

16 的二进制表现形式为(注意此处为32位存储)
00000000000000000000000000010000
Integer.numberOfLeadingZeros方法获取到1前面的0的个数,数一下,有27个。把27表示成二进制方式再与后边的值取或。
 00000000000000000000000000011011
 00000000000000001000000000000000
|--------------------------------
000000000000000001000000000011011

先把得到的这个值记住,我们再来看一句源码,这句源码出现在addCount之中:

private final void addCount(long x, int check) {
    ......
        int rs = resizeStamp(n);
        //当前线程是发起扩容的第一个线程时,用CAS操作修改sizeCtl的值
        else if (U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2))
           transfer(tab, null);
    ......
}

也是善意的省略了一大堆代码,只留下关键的两句。首先也是调用了resizeStamp方法,求出了一个值,然后再对这个值进行位移并且+2,其结果如下所示:

RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS。
可以简单计算一下,在resizeStamp方法中,已经将1向左移动了RESIZE_STAMP_BITS位,
此处又再移动RESIZE_STAMP_SHIFT位。最终结果就是将resizeStamp方法的结果,向左移动到最高位:
          000000000000000001000000000011011
左移之后   100000000001101100000000000000000
再加上2后  100000000001101100000000000000010

那么,现在看到的这个值,可以分为两部分,高16位存储着与扩容前的数组容量密切相关的一个值。低16位则存储着当前参与扩容的线程数+1。用这个方法计算完毕后,使用CAS操作将结果赋给成员变量sizeCtl。

你可能在好多地方看到过,高16位存储着数组容量的信息,这个信息就是数组容量二进制表示的高位0的个数,为什么要这么存?其实原因不难想,因为这么存能保证16位一定可以表示数组的容量。我们知道,数组容量最大值可以是int型的最大值,这个值是无法存储到16个bit当中的,所以聪明的源码作者想到了这个办法,直接把高位0的个数存进去,这个值即能与数组容量一一对应,又绝对保证可以用16个bit存储。实际上,0最多的时候,也只有32个。至于整个结果开头为什么是1,在源码注释中有解释,是为了让此值为负,以标识数组正在扩容。有了以上的铺垫,我们就能很容易的理解帮助扩容前要经历的层层考察了。(注:以下代码取自JDK12,之前的版本从1.8起都有bug, 原因点我)

private transient volatile int sizeCtl;
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
    Node<K,V>[] nextTab; int sc;
    if (tab != null && (f instanceof ForwardingNode) &&
        (nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
        int rs = resizeStamp(tab.length) << RESIZE_STAMP_SHIFT;
        while (nextTab == nextTable && table == tab &&
               (sc = sizeCtl) < 0) {
            if (sc == rs + MAX_RESIZERS || sc == rs + 1 ||
                transferIndex <= 0)
                break;
            if (U.compareAndSetInt(this, SIZECTL, sc, sc + 1)) {
                transfer(tab, nextTab);
                break;
            }
        }
        return nextTab;
    }
    return table;
}

我们直接看while循环中的第一个if的三个判断条件,其中sc的值为sizeCtl:

  • sc == rs + 1。sc的低位存储着参与扩容的线程数+1,当有线程加入的时候就给sc+1,当有线程退出的时候就给sc-1。如果该式成立的话,说明当前所有扩容的线程已经完成扩容全部退出了。就没有参与扩容的必要了。
  • sc == rs + MAX_RESIZERS。上文中也提到过,低位用来存储可以参与扩容的线程数,MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1,可以得出,低位能表示的最大值为MAX_RESIZERS。若此式成立,表示当前扩容的线程已经到了最大值,不需要再增加扩容线程了。
  • transferIndex <= 0。这个判断与扩容线程分配到的桶有关,此式表明旧数组的所有桶已经被参与扩容的线程分配完毕,不需要再加入新的线程扩容。

这三个判断告诉想要帮忙的线程,你的好意组织心领了,但现在确实不需要更多的帮手了,你回去办自己的事儿去吧。 如果都通过了的话,当前线程就会尝试用CAS操作给sc值+1,表示多增加我一个线程帮忙扩容。

至此为止,一个线程加入扩容操作的逻辑就结束了,大体总结一下就是:感知到扩容->尝试加入->辅助扩容。

ConcurrentHashMap的扩容操作也比较复杂,本文中的多处逻辑都与它有紧密联系,比如三个判断条件中的transferIndex值的维护就在扩容方法中进行。本文只将精力聚焦在帮助扩容的逻辑上,一是理解sizeCtl这个变量在扩容过程中的变身及其作用,二是体会积极活泼的线程主动参与集体活动的团结性,高能预警,此句扣题点睛,它们的积极活泼团结性,就像蚂蚁一样

我截取的代码,都进行过删减,有兴趣的读者可以找到源码,从此文的基础上,详细阅读和理解源码,去收获更多的知识点。