6

这段时间阅读了 JDK 8 的 ConcurrentHashMap 源码,其中扩容的过程涉及技术点繁多,很有必要自己动手对扩容原理进行梳理总结。本文中,我对关键代码编写了单元测试,并且找来了图例,方便理解。
编写文章过程,我竟然发现了 JDK 8 版本扩容时对 sizeCtl 的判断有 BUG,具体在第 3.4 节中说明。

本文基于 jdk1.8.0_91

1. 数据结构

使用数组+链表+红黑树来实现,利用 CAS + synchronized 来保证并发更新的安全。

ConcurrentHashMap

1.1 对比 Java7

与 Java7 相比,Java8 中的 ConcurrentHashMap 舍弃了 Segment 结构,将分段锁改为粒度更小的“桶”锁。得益于 JVM 对锁的优化(锁膨胀、锁粗化、锁消除),ConcurrentHashMap 的锁技术从 ReentrantLock 改为内置锁 synchronized,并引入了 CAS 乐观锁。

1.2 对比 HashMap

红黑树结构略有不同。
HashMap 的红黑树中的节点叫做 TreeNode,TreeNode 不仅仅有属性,还维护着红黑树的结构,比如说查找,新增等等;
ConcurrentHashMap 中红黑树被拆分成两块,TreeNode 仅仅维护的属性和查找功能,新增了 TreeBin,来维护红黑树结构,并负责根节点的加锁和解锁;新增 ForwardingNode (转移)节点,扩容的时候会使用到,通过使用该节点,来保证扩容时的线程安全。

Node

Node 是 ConcurrentHashMap 存储结构的基本单元,用于存储数据。
与 HashMap 中的 Node 一样实现了 Map.Entry 接口,不同的是部分属性加了 volatile 修饰。

static class Node<K,V> implements Map.Entry<K,V> {
    //链表结构的属性定义
    final int hash;
    final K key;
    volatile V val;
    volatile Node<K,V> next;

    Node(int hash, K key, V val, Node<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.val = val;
        this.next = next;
    }

TreeNode

TreeNode 继承与 Node,它是红黑树的节点,同时保留了链表的特性。
当链表的节点数大于 8 时会转换成红黑树的结构,就是通过 TreeNode 作为存储结构代替 Node 来转换成黑红树的。

static final class TreeNode<K,V> extends Node<K,V> {
    //树形结构的属性定义
    TreeNode<K,V> parent;  // red-black tree links
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    TreeNode<K,V> prev;    // needed to unlink next upon deletion
    boolean red; //标志红黑树的红节点
    TreeNode(int hash, K key, V val, Node<K,V> next,
             TreeNode<K,V> parent) {
        super(hash, key, val, next);
        this.parent = parent;
    }
}

TreeBin

当链表转为红黑树后,数组中保存的引用为 TreeBin,TreeBin 内部不保存 key/value,他保存了 TreeNode 的 list 以及红黑树 root。
TreeBin 从字面含义中可以理解为存储树形结构的容器,而树形结构就是指 TreeNode,所以 TreeBin 就是封装 TreeNode 的容器,它提供转换黑红树的一些条件和锁的控制。

static final class TreeBin<K,V> extends Node<K,V> {
    //指向TreeNode列表和根节点
    TreeNode<K,V> root;
    volatile TreeNode<K,V> first;
    volatile Thread waiter;
    volatile int lockState;
    // 读写锁状态
    static final int WRITER = 1; // 获取写锁的状态
    static final int WAITER = 2; // 等待写锁的状态
    static final int READER = 4; // 增加数据时读锁的状态
}    

ForwardingNode

当进行扩容时,要把链表迁移到新的哈希表,在做这个操作时,会在把数组中的头节点替换为 ForwardingNode 对象。ForwardingNode 中不保存 key 和 value,只保存了扩容后哈希表(nextTable)的引用。此时查找相应 node 时,需要去 nextTable 中查找。

static final class ForwardingNode<K,V> extends Node<K,V> {
    final Node<K,V>[] nextTable;
    ForwardingNode(Node<K,V>[] tab) {
        super(MOVED, null, null, null);
        this.nextTable = tab;
    }

2. 索引计算

与 HashMap 不同的地方是,将 hash 值与 HASH_BITS 作按位与操作,保证 hash 值为正数。
负数在 ConcurrentHashMap 中有特殊的含义(代表 TreeBin、ForwardingNode 等)。

/**
 * 哈希桶数组索引位置的计算
 * 
 * @author Sumkor https://segmentfault.com/blog/sumkor
 * @since 2021/3/2
 */
@Test
public void hash() {
    int capacity = 16;// 默认值,见 java.util.concurrent.ConcurrentHashMap.DEFAULT_CAPACITY
    Object key = 17;

    int h = key.hashCode();// 第一步取hashCode值
    h = h ^ (h >>> 16);// 第二步高位参与运算
    h = h & 0x7fffffff;// 第三步与HASH_BITS相与,主要作用是使hash值为正数
    /**
     * @see ConcurrentHashMap#spread(int)
     *
     * 在 ConcurrentHashMap 之中,hash值为负数有特殊的含义:
     * -1 表示 ForwardingNode 节点 {@link ConcurrentHashMap#MOVED}
     * -2 表示 TreeBin 节点 {@link ConcurrentHashMap#TREEBIN}
     */

    h = h & (capacity - 1);// 第四步取模运算
    /**
     * @see ConcurrentHashMap#putVal(java.lang.Object, java.lang.Object, boolean)
     */

    System.out.println("h = " + h);
}

关于索引计算公式的推导见我写的文章 HashMap中的取模和扩容公式推导

3. 扩容

3.1 扩容的时机

  • put() 添加元素完毕后,通过 addCount() 检查元素总量 size 是否超过阈值 sizeCtrl。
  • putAll() 添加大量元素之前,通过 tryPresize() 检查是否需要扩容。
  • treeifyBin() 桶中元素由链表转成树结构之前,如果数组容量小于 64(MIN_TREEIFY_CAPACITY),放弃转换红黑树,通过 tryPresize() 检查是否需要扩容。
  • put()computeIfAbsent()computeIfPresent() 等方法操作 HashMap 元素时,发现元素节点类型为 ForwardingNode,则通过 helpTransfer() 检查当前线程是否加入扩容。

3.2 扩容控制 sizeCtl

sizeCtl 是 ConcurrentHashMap 中的一个重要的变量。

/**
 * Table initialization and resizing control.  When negative, the
 * table is being initialized or resized: -1 for initialization,
 * else -(1 + the number of active resizing threads).  Otherwise,
 * when table is null, holds the initial table size to use upon
 * creation, or 0 for default. After initialization, holds the
 * next element count value upon which to resize the table.
 */
private transient volatile int sizeCtl;

sizeCtl 用于数组初始化与扩容控制:

初始化前:
    = 0  //未指定初始容量
    > 0  //由指定的初始容量计算而来,再找最近的2的幂次方。比如传入6,计算公式为6+6/2+1=10,最近的2的幂次方为16,所以sizeCtl就为16。具体见 tableSizeFor 函数。
    
初始化中:
    = -1 //table正在初始化
    = -N //N是int类型,分为两部分,高15位是指定容量标识,低16位表示并行扩容线程数+1,具体见 resizeStamp 函数。
    
初始化后:
    = n - (n >>> 2) = table.length * 0.75  //扩容阈值,为table容量大小的0.75倍

3.3 扩容检查

addCount()tryPresize()helpTransfer() 都包含了相似的扩容检查逻辑。
这里以 addCount() 为例作分析。

3.3.1 addCount() 源码

扩容检查流程:

  • 计算元素总量 size,若 CAS 冲突严重则放弃扩容。
  • 若 size 计算成功,有新元素加入,且检测到元素总量大于阈值 size > sizeCtl。

    • 如果检查到当前已有线程在进行扩容。

      • 扩容已经接近完成或足够多的线程参与到扩容中了,当前线程直接返回。
      • 当前线程参与扩容。
    • 如果没有其他线程在进行扩容,则修改 sizeCtl 标识,进行扩容。
// 参数 x 表示键值对个数的变化值,如果为正,表示新增了元素,如果为负,表示删除了元素
private final void addCount(long x, int check) {
    CounterCell[] as; long b, s;
    // 如果 counterCells 为空,则直接尝试通过 CAS 将 x 累加到 baseCount 中
    if ((as = counterCells) != null ||
        !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
        // counterCells 非空
        // 或 counterCells 为空,但 CAS baseCount 失败都会来到这里
        CounterCell a; long v; int m;
        boolean uncontended = true;
        // 如果当前线程探针哈希到的数组元素非空,则尝试将 x 累加到对应数组元素
        if (as == null || (m = as.length - 1) < 0 ||
            (a = as[ThreadLocalRandom.getProbe() & m]) == null ||
            !(uncontended =
              U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
            // counterCells 为空,或其长度小于1
            // 或当前线程探针哈希到的数组元素为空
            // 或当前线程探针哈希到的数组元素非空,但 CAS 数组元素失败
            // 都会调用 fullAddCount 方法来完成 x 的写入  
            fullAddCount(x, uncontended);
            // 如果调用过 fullAddCount,则当前线程一定不会协助扩容
            return;
        }
        if (check <= 1)
            return;
        s = sumCount(); // s表示加入新元素后的size大小,即元素总量
    }
    if (check >= 0) { // check值为桶上节点数量,有新元素加入成功才检查是否要扩容
        Node<K,V>[] tab, nt; int n, sc;
        while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
               (n = tab.length) < MAXIMUM_CAPACITY) { // size大于sizeCtl阈值,及其他边界条件符合,则扩容
            int rs = resizeStamp(n); // 高16位置0,第16位为1,低15位存放当前容量n扩容标识,用于表示是对n的扩容。
            if (sc < 0) { // sizeCtl<0表示已经有线程在进行扩容工作
                if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                    sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                    transferIndex <= 0) // 条件1校验容量n扩容标识,条件2和3校验sc的边界(这里有BUG!),条件4和5校验扩容逻辑是否完成
                    break; // 跳出循环,表示当前线程无需参与扩容
                if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) // 当前线程参与扩容,sizeCtl加1
                    transfer(tab, nt);
            }
            else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                         (rs << RESIZE_STAMP_SHIFT) + 2)) // 没有其他线程在进行扩容,则修改sizeCtl的值,将其高15位存放容量n扩容标识,低16位存放并行扩容线程数+1
                transfer(tab, null);
            s = sumCount();
        }
    }
}

上述扩容检查流程,有两个关键的技术点:

  • ConcurrentHashMap 如何计算元素总量 size ?
  • resizeStamp 函数如何生成 sizeCtrl 以控制扩容过程?

此外,这里利用 sc == rs + 1 || sc == rs + MAX_RESIZERS 来校验 sizeCtl 的边界存在 BUG!

我们一个一个来看。

3.3.2 计算元素总量 size

ConcurrentHashMap 依靠 baseCount 和 counterCells 来计算元素总量 size,定义如下:

private transient volatile long baseCount;

private transient volatile CounterCell[] counterCells;

@sun.misc.Contended static final class CounterCell {
    volatile long value;
    CounterCell(long x) { value = x; }
}

addCount() 中分为以下三种情况来处理 size:

baseCount CAS 成功。

执行 U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x) 成功,
得到 size = baseCount + x。

baseCount CAS 失败,counterCells CAS 成功。

执行 U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x) 失败,
执行 U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x) 成功,
通过 size = sumCount() 来计算容量。

// 累加 baseCount 和与所有 counterCells 数组的非空元素的和
final long sumCount() {
    CounterCell[] as = counterCells; CounterCell a;
    long sum = baseCount;
    if (as != null) {
        for (int i = 0; i < as.length; ++i) {
            if ((a = as[i]) != null)
                sum += a.value;
        }
    }
    return sum;
}
baseCount CAS 失败,counterCells CAS 失败。

执行 U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x) 失败,
执行 U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x) 失败,
通过 fullAddCount() 将 x 的写入 baseCount 或 counterCells,不会计算 size。

3.3.3 扩容过程 sizeCtrl 的计算

在扩容过程中,sizeCtrl 值为负数,其高 15 位是指定容量标识,低 16 位表示并行扩容线程数 + 1。

这个数值有什么意义?

高 15 位是指定容量标识。即存储扩容之前数组的大小 table.length,用于标识是对该大小的扩容。
低 16 位表示并行扩容线程数 + 1。用于记录当前参与扩容的线程数量,用于控制参与扩容的线程数。

最大的可参与扩容的线程数:65535

/**
 * The maximum number of threads that can help resize.
 * Must fit in 32 - RESIZE_STAMP_BITS bits.
 */
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1; // 65535
为什么是并行扩容线程数 + 1?

sizeCtrl 的低 16 位记录并行扩容线程数,为什么还要 “+1” 呢?

我的理解是,在扩容过程中 sizeCtrl 为负数,而 -1 值具有特殊的含义,代表数组 table 正在初始化,是一个重要的标志位。
如 initTable() 方法中通过 U.compareAndSwapInt(this, SIZECTL, sc, -1) 判断 table 是否正在初始化。
为了让出 “-1” 这个标志位,因此在二进制符号位为负的情况下,低 16 位还需要再 + 1。

如何得到这个数值?

resizeStamp() 函数和 addCount()tryPresize()helpTransfer() 扩容检查逻辑都参与对 sizeCtrl 负数值的计算。

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

总结一下计算过程:

/**
 * 扩容时 sizeCtl = -N
 * N是int类型,分为两部分,高15位是扩容前的容量标识,低16位表示并行扩容线程数+1
 *
 * @author Sumkor https://segmentfault.com/blog/sumkor
 * @since 2021/3/2
 */
@Test
public void sizeCtl() {
    /**
     * 插入元素的时候,检查 sizeCtl 看是否需要扩容,此时
     * {@link ConcurrentHashMap#putVal} 操作调用了 {@link ConcurrentHashMap#addCount}
     * 若这里检查到 size > sizeCtl阈值,则进行扩容。
     */

    /**
     * 首先关注其中的 {@link ConcurrentHashMap#resizeStamp} 方法
     */
    int n = 8;
    /**
     * 设置容量为 8,二进制表示如下:
     * 0000 0000 0000 0000 0000 0000 0000 1000
     */

    n = Integer.numberOfLeadingZeros(n);
    /**
     * Integer.numberOfLeadingZeros(n) 用于计算 n 转换成二进制后前面有几个 0。
     * 已知 ConcurrentHashMap 的容量必定是 2 的幂次方,所以不同的容量 n 前面 0 的个数必然不同,
     * 这里相当于用 0 的个数来记录 n 的值。
     *
     * Integer.numberOfLeadingZeros(8)=28,二进制表示如下:
     * 0000 0000 0000 0000 0000 0000 0001 1100
     */

    int rs = n | (1 << (RESIZE_STAMP_BITS - 1));
    /**
     * (1 << (RESIZE_STAMP_BITS - 1)即是 1<<15,表示为二进制即是高 16 位为 0,低 16 位为 1:
     * 0000 0000 0000 0000 1000 0000 0000 0000
     *
     * 再与 n 作或运算,得到二进制如下:
     * 0000 0000 0000 0000 1000 0000 0001 1100
     */
    System.out.println("rs = " + Integer.toBinaryString(rs));
    /**
     * 结论:resizeStamp()的返回值:高16位置0,第16位为1,低15位存放当前容量n扩容标识,用于表示是对n的扩容。
     */

    int sizeCtl = (rs << RESIZE_STAMP_SHIFT) + 2;
    /**
     * rs << 16,左移 16 后最高位为 1,所以成了一个负数。计算得到 sizeCtl 二进制如下:
     * 1000 0000 0001 1100 0000 0000 0000 0010
     */
    System.out.println("sizeCtl = " + Integer.toBinaryString(sizeCtl));
    /**
     * 那么在扩容时 sizeCtl 值的意义便如下所示:
     * 高15位:容量n扩容标识
     * 低16位:并行扩容线程数+1
     */
}

3.4 扩容检查的 BUG

明白了 sizeCtl 的含义和计算过程之后,回过头来看扩容检查的代码,还是以 addCount() 为例:

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;
                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();
        }
    }
}

回顾一下,这里 rs = resizeStamp(n) 的高 16 位为 0,第 16 位为 1,低 15 位存放当前容量 n 扩容标识。
简单来说,rs 得到的值是正数,而扩容过程 sc < 0 是负数,那么 sc == rs + 1 || sc == rs + MAX_RESIZERS 是不可能成立的。这样导致的后果是无法控制执行扩容方法 transfer() 的线程数。不过影响并不严重, transfer() 方法本身是线程安全的,只是有可能会加剧该方法的资源竞争。

正确的写法应该是 sc == (rs << RESIZE_STAMP_SHIFT) + 1 || sc == (rs << RESIZE_STAMP_SHIFT) + MAX_RESIZERS
sc == (rs << RESIZE_STAMP_SHIFT) + 1 表示扩容已经结束。
sc == (rs << RESIZE_STAMP_SHIFT) + MAX_RESIZERS 表示参与扩容的线程已经达到最大值。

在 Oracle Java Bug Database 上可以看到在 2018 年有人提了这个 Bug :Bug ID:JDK-8214427 : probable bug in logic of ConcurrentHashMap.addCount()

Bug ID:JDK-8214427

可以看到该 BUG 已经在 JDK 12 版本中解决(也就是 JDK 8 中没有解决,说好的维护至 2030 年呢)。
话说回来,国内各种博客网站、公众号研究 ConcurrentHashMap 的文章多如牛毛,几乎没有看到人提及这个 BUG,也是奇怪。

看一下解决之后的写法,对 sc 边界值的判断跟我上述的想法是一致的。

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;
                if (U.compareAndSetInt(this, SIZECTL, sc, sc + 1))
                    transfer(tab, nt);
            } else if (U.compareAndSetInt(this, SIZECTL, sc, rs + 2))
                transfer(tab, null);
            s = sumCount();
        }
    }
}

3.5 扩容流程

上述流程只是扩容之前的准备,扩容的核心逻辑在 transfer() 方法中。
阅读过程中需要时刻关注 ConcurrentHashMap 是如何做到扩容的并发安全的。

3.5.1 transfer() 源码

看源码之前,提前梳理一下扩容过程:

  1. 创建 nextTable,新容量是旧容量的 2 倍。
  2. 将原 table 的所有桶逆序分配给多个线程,每个线程每次最小分配 16 个桶,防止资源竞争导致的效率下降。指定范围的桶可能分配给多个线程同时处理。
  3. 扩容时遇到空的桶,采用 CAS 设置为 ForwardingNode 节点,表示该桶扩容完成。
  4. 扩容时遇到 ForwardingNode 节点,表示该桶已扩容过了,直接跳过。
  5. 单个桶内元素的迁移是加锁的,将旧 table 的 i 位置上所有元素拆分成高低两部分,并迁移到 nextTable 上,低位索引是 i,高位索引是 i + n,其中 n 为扩容前的容量。
  6. 最后将旧 table 的 i 位置设置为 ForwardingNode 节点。
  7. 所有桶扩容完毕,将 table 指向 nextTable,设置 sizeCtl 为新容量 0.75 倍
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    int n = tab.length, stride;
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE) // 每核处理的桶的数目,最小为16
        stride = MIN_TRANSFER_STRIDE; // subdivide range
    if (nextTab == null) {            // initiating
        try {
            @SuppressWarnings("unchecked")
            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1]; // 构建nextTable,其容量为原来容量的两倍
            nextTab = nt;
        } catch (Throwable ex) {      // try to cope with OOME
            sizeCtl = Integer.MAX_VALUE;
            return;
        }
        nextTable = nextTab;
        transferIndex = n; // 迁移总进度,值范围为[0,n],表示从table的第n-1位开始处理直到第0位。
    }
    int nextn = nextTab.length;
    ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab); // 扩容时的特殊节点,hash固定为-1,标明此节点正在进行迁移。扩容期间的元素查找要调用其find方法在nextTable中查找元素
    boolean advance = true; // 当前线程是否需要继续寻找下一个可处理的节点
    boolean finishing = false; // to ensure sweep before committing nextTab // 所有桶是否都已迁移完成
    for (int i = 0, bound = 0;;) {
        Node<K,V> f; int fh;
        while (advance) { // 此循环的作用是 1.确定当前线程要迁移的桶的范围;2.通过更新i的值确定当前范围内下一个要处理的节点
            int nextIndex, nextBound;
            if (--i >= bound || finishing) // 每次循环都检查结束条件:i自减没有超过下界,finishing标识为true时,跳出while循环
                advance = false;
            else if ((nextIndex = transferIndex) <= 0) { // 迁移总进度<=0,表示所有桶都已迁移完成
                i = -1;
                advance = false;
            }
            else if (U.compareAndSwapInt
                     (this, TRANSFERINDEX, nextIndex,
                      nextBound = (nextIndex > stride ?
                                   nextIndex - stride : 0))) { // CAS执行transferIndex=transferIndex-stride,即transferIndex减去已分配出去的桶,得到边界,这里为下界
                bound = nextBound; // 当前线程需要处理的桶下标的下界
                i = nextIndex - 1; // 当前线程需要处理的桶下标
                advance = false;
            }
        }
        if (i < 0 || i >= n || i + n >= nextn) { // 当前线程自己的活已经做完或所有线程的活都已做完
            int sc;
            if (finishing) { // 已经完成所有节点复制了。所有线程已干完活,最后才走这里
                nextTable = null;
                table = nextTab; // table指向nextTable
                sizeCtl = (n << 1) - (n >>> 1); // 设置sizeCtl为新容量0.75倍
                return;
            }
            if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) { // 当前线程已结束扩容,sizeCtl-1表示参与扩容线程数-1
                if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT) // 相等时说明没有线程在参与扩容了,置finishing=advance=true,为保险让i=n再检查一次
                    return;
                finishing = advance = true;
                i = n; // recheck before commit
            }
        }
        else if ((f = tabAt(tab, i)) == null) // 遍历到i位置为null,则放入ForwardingNode节点,标志该桶扩容完成。
            advance = casTabAt(tab, i, null, fwd);
        else if ((fh = f.hash) == MOVED) // f.hash == -1 表示遍历到了ForwardingNode节点,意味着该节点已经处理过了
            advance = true; // already processed
        else {
            synchronized (f) { // 桶内元素迁移需要加锁
                if (tabAt(tab, i) == f) {
                    Node<K,V> ln, hn;
                    if (fh >= 0) { // 链表节点。非链表节点hash值小于0
                        int runBit = fh & n; // 根据 hash&n 的结果,将所有结点分为两部分
                        Node<K,V> lastRun = f;
                        for (Node<K,V> p = f.next; p != null; p = p.next) {
                            int b = p.hash & n; // 遍历链表的每个节点,依次计算 hash&n
                            if (b != runBit) {
                                runBit = b;
                                lastRun = p;
                            }
                        }
                        if (runBit == 0) {
                            ln = lastRun;
                            hn = null;
                        }
                        else {
                            hn = lastRun;
                            ln = null;
                        }
                        for (Node<K,V> p = f; p != lastRun; p = p.next) {
                            int ph = p.hash; K pk = p.key; V pv = p.val;
                            if ((ph & n) == 0)
                                ln = new Node<K,V>(ph, pk, pv, ln); // hash&n为0,索引位置不变,作低位链表
                            else
                                hn = new Node<K,V>(ph, pk, pv, hn); // hash&n不为0,索引变成“原索引+oldCap”,作高位链表
                        }
                        setTabAt(nextTab, i, ln); // 低位链表放在i处
                        setTabAt(nextTab, i + n, hn); // 高位链表放在i+n处
                        setTabAt(tab, i, fwd); // 在原table的i位置设置ForwardingNode节点,以提示该桶扩容完成
                        advance = true;
                    }
                    else if (f instanceof TreeBin) {
                        TreeBin<K,V> t = (TreeBin<K,V>)f;
                        TreeNode<K,V> lo = null, loTail = null;
                        TreeNode<K,V> hi = null, hiTail = null;
                        int lc = 0, hc = 0;
                        for (Node<K,V> e = t.first; e != null; e = e.next) {
                            int h = e.hash;
                            TreeNode<K,V> p = new TreeNode<K,V>
                                (h, e.key, e.val, null, null);
                            if ((h & n) == 0) {
                                if ((p.prev = loTail) == null)
                                    lo = p;
                                else
                                    loTail.next = p;
                                loTail = p;
                                ++lc;
                            }
                            else {
                                if ((p.prev = hiTail) == null)
                                    hi = p;
                                else
                                    hiTail.next = p;
                                hiTail = p;
                                ++hc;
                            }
                        }
                        ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
                            (hc != 0) ? new TreeBin<K,V>(lo) : t;
                        hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
                            (lc != 0) ? new TreeBin<K,V>(hi) : t;
                        setTabAt(nextTab, i, ln);
                        setTabAt(nextTab, i + n, hn);
                        setTabAt(tab, i, fwd);
                        advance = true;
                    }
                }
            }
        }
    }
}

总结:

  1. 每个线程想增/删元素时,如果访问的桶是 ForwardingNode 节点,则表明当前正处于扩容状态,协助一起扩容完成后再完成相应的数据更改操作。
  2. 一个旧桶内的数据迁移完成,但不是所有桶都迁移完成时,查询数据委托给 ForwardingNode 结点查询 nextTable 完成。
  3. 迁移过程中 sizeCtl 用于记录参与扩容线程的数量,全部迁移完成后 sizeCtl 更新为新 table 容量的 0.75 倍。

3.5.2 链表迁移图示

在 ConcurrentHashMap 中,对于数组的桶上的链表结构,扩容时需要拆分成两条新的链表。

普通链表迁移

迁移过程中,通过 ph & n,即 e.hash & oldCap 计算新数组的索引位置。这部分的思想与 HashMap 是一样的。

e.hash & oldCap 公式的推导见我的文章 《HashMap中的取模和扩容公式推导》

3.5.3 单元测试,理解 lastRun

不同的是,ConcurrentHashMap 采用 lastRun 节点来辅助拆分两条新链表,而 HashMap 采用首尾指针来拆分两条新链表,见我的文章 HashMap 扩容过程图解

计算 lastRun 节点图示:

lastRun 节点

计算 lastRun 节点和拆分链表的逻辑,单元测试:

/**
 * 扩容时,链表迁移算法
 *
 * @author Sumkor https://segmentfault.com/blog/sumkor
 * @since 2021/3/2
 */
@Test
public void transferLink() {
    int oldCap = 1;
    int newCap = 2;
    Node[] oldTable = new Node[oldCap];
    Node[] newTable = new Node[newCap];
    // A -> B -> C
    Node firstLinkNode03 = new Node(new Integer(3).hashCode(), 3, "C", null);
    Node firstLinkNode02 = new Node(new Integer(2).hashCode(), 2, "B", firstLinkNode03);
    Node firstLinkNode01 = new Node(new Integer(1).hashCode(), 1, "A", firstLinkNode02);
    oldTable[0] = firstLinkNode01;
    printTable(oldTable);

    // 赋值
    int i = 0;
    int n = oldCap;
    Node f = firstLinkNode01;
    int fh = firstLinkNode01.hash;

    /**
     * 单个桶元素扩容
     * @see ConcurrentHashMap#transfer(java.util.concurrent.ConcurrentHashMap.Node[], java.util.concurrent.ConcurrentHashMap.Node[])
     */
    Node ln, hn;
    if (fh >= 0) { // 链表节点。非链表节点hash值小于0
        int runBit = fh & n; // 根据 hash&n 的结果,将所有结点分为两部分
        Node lastRun = f;
        for (Node p = f.next; p != null; p = p.next) { // 遍历原链表得到lastRun,该节点作为新链表的起始节点(新链表采用头插法)
            int b = p.hash & n; // 遍历链表的每个节点,依次计算 hash&n
            if (b != runBit) {
                runBit = b;
                lastRun = p;
            }
        }
        if (runBit == 0) { // 判断lastRun节点是属于高位还是地位
            ln = lastRun;
            hn = null;
        } else {
            hn = lastRun;
            ln = null;
        }
        System.out.println("lastRun = " + lastRun.getValue());
        for (Node p = f; p != lastRun; p = p.next) {
            int ph = p.hash;
            Object pk = p.key;
            Object pv = p.val;
            if ((ph & n) == 0)
                ln = new Node(ph, pk, pv, ln); // hash&n为0,索引位置不变,作低位链表。这里采用头插法
            else
                hn = new Node(ph, pk, pv, hn); // hash&n不为0,索引变成“原索引+oldCap”,作高位链表
        }
        newTable[i] = ln;
        newTable[i + n] = hn;
        printTable(newTable);
    }
}

执行结果:

1=A -> 2=B -> 3=C ->
--------------------------
lastRun = C
2=B ->
1=A -> 3=C ->
--------------------------

使用更多的节点来测试,结果如下:

1=A -> 2=B -> 3=C -> 4=D -> 5=E -> 6=F -> 8=H -> 10=J ->
--------------------------
lastRun = F
4=D -> 2=B -> 6=F -> 8=H -> 10=J ->
5=E -> 3=C -> 1=A ->
--------------------------

关注链表迁移前后的顺序

ConcurrentHashMap 中的链表迁移之后,LastRun 节点及之后的节点的顺序与旧链表相同,其余节点都是倒序的。这是由于 ConcurrentHashMap 迁移桶上链表的时候,加了锁,因此迁移前后顺序不一致没有问题。

而 HashMap 中的链表迁移算法,使用了高低位的首尾指针,迁移前后节点的顺序都是一致的,可以避免在并发情况下链表出现环的问题。测试结果如下:

1=A -> 2=B -> 3=C -> 4=D -> 5=E -> 6=F -> 8=H -> 10=J ->
--------------------------
2=B -> 4=D -> 6=F -> 8=H -> 10=J ->
1=A -> 3=C -> 5=E ->
--------------------------

3.5.4 红黑树迁移图示

红黑树的迁移算法与 HashMap 中的是一样的,利用了 TreeNode 的链表特性,采用了高低位的首尾指针来拆分两条新链表。

红黑树迁移

4. 参考


作者:Sumkor
链接:https://segmentfault.com/a/11...


Sumkor
148 声望1.3k 粉丝

会写点代码