Loading

ConcurrentHashMap源码分析

CVTE的面试里第二个答的不好的地方就是ConcurrentHashMap

构造方法

默认构造方法

// 创建一个新的,具有默认表达小16的空Map
public ConcurrentHashMap() {
}

在默认构造方法中,我们可以看到ConcurrentHashMap实际上什么也没做,根据注释来看,使用该方法构造的ConcurrentHashMap会创建一个底层数组为16的空Map。相关变量如下:

// 底层数组的引用
transient volatile Node<K,V>[] table;
// 默认容量
private static final int DEFAULT_CAPACITY = 16;

至此,从默认构造方法中可以推断出:

  1. ConcurrentHashMap底层数组的初始化是惰性的,构造时并未初始化
  2. 底层数组中的元素类型是Node
  3. 使用默认构造方法时,底层数组的默认容量为16

用户定义初始容量

// 创建一个新的,具有一个可以容纳指定数量的元素的空Map,从而避免动态resize
public ConcurrentHashMap(int initialCapacity) {
    // 初始容量不能小于0
    if (initialCapacity < 0)
        throw new IllegalArgumentException();
    // 按照初始容量计算实际容量
    int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
                MAXIMUM_CAPACITY :
                tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
    // 设置sizeCtl = 计算后的容量
    this.sizeCtl = cap;
}

这里有两个我们迷惑的点,cap是怎么计算的,sizeCtl是啥。

关于cap的计算,我们不妨把上面的代码翻译成人话:

// MAXIMUM_CAPACITY = 2^30
int cap;
if (initialCapacity >= MAXIMUM_CAPACITY / 2) {
    cap = MAXIMUM_CAPACITY; 
} else {
    cap = tableSizeFor(initialCapacity * 1.5);
}

如果用户传入的容量大于等于允许的最大容量/2,那就将容量定为最大容量,最大容量是2^30。否则,将用户传入的初始大小的1.5倍传入tableSizeFor方法,tableSizeFor方法会返回你能够满足你传入的容量参数的一个最近的二的幂次。所以如果你调用构造方法时传入的initialCapacity=16,那么它的1.5倍就是24,最近的一个二的幂次是32,实际容量是32。这里应该是一个常见的面试题。

为什么最大容量是\(2^{30}\)呢?

因为Java中没有无符号类型,并且以补码数存储有符号整数,所以int类型能代表的最大正数就是\(2^{31}-1\),再加上ConcurrentHashMap限制底层数组的长度必须是2的幂次,所以就只能选\(2^{30}\)了。

这个构造方法里还差最后一行

this.sizeCtl = cap;

sizeCtl是个啥呢?看一下它上面的注释:

用于控制表初始化和resizing。

  • 当为负数时,表正在倍初始化或resizing
    • -1代表初始化
    • 其他情况代表 -(1 + 正在活动的resizing线程)
  • 否则,当tablenull时,代表table的初始化大小
    • 0代表默认大小
    • 其他情况代表具体的初始化大小
  • 否则,代表表resizing的阈值,若表中保存的键值对数量到达了这个阈值,触发resizing

所以sizeCtl加上table两个变量的状态组合出了ConcurrentHashMap的四种状态:

  1. 表在resizing (size < -1)
  2. 表在初始化过程中 (size == -1)
  3. 表尚未初始化 (table == null,此时sc代表表初始化时的大小)
  4. 表已经初始化了,等待到达阈值后进入resizing阶段 (table != null && sc > 0,此时sc就是阈值)

从该构造方法中可以看出:

  1. 用户传入的期待容量并不是初始化后的实际容量,实际容量是大于传入容量1.5倍的最近的一个2的幂次
  2. 支持resizing,并支持多线程协作完成resizing
  3. sizeCtl和table这两个变量的状态组合出了ConcurrentHashMap的四种状态,有时它记录正在resizing的线程数,有时它标识表正在初始化,有时它代表表的初始化大小,有时它代表下次扩容的阈值

实际上,该类还有很多构造方法,我们这里先分析到这。

插入

final V putVal(K key, V value, boolean onlyIfAbsent) {
    // key value不能为null
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode());
    int binCount = 0; // 暂时忽略
    // 循环取hash表,然后根据条件的对表不同做不同的事
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        if (some condition)
            ...
        else if (some condition)
            ...
        else if (some condition)
            ...
        else 
            ...
    }
    addCount(1L, binCount);
    return null;
}

putVal方法的代码很多,我们这里先看整体的结构。

首先,使用spread方法对待插入键原始的hashCode进行了一波计算,这波计算具体是干啥的咱先不研究,只需要知道一点——经过spread后,原hashCode的符号位被置0了,也就是变成了正数。ConcurrentHashMap会使用符号位来维护节点的一些状态。这些都是后话。

然后,它在一个死循环中不断获取表,这个表就是实际承载数据的底层数组,所以也能看出ConcurrentHashMap中的底层数组中每一个元素都是一个Node

在循环中,它判断了一些条件,根据条件去做一些事情。比如如果表还没初始化,可能就会去做初始化操作;比如表正在resizing,就会去做resizing时对应的操作;比如一切正常,那就实际的执行插入。稍后我们会逐个分析这些条件分支。

为什么要有一个死循环呢?这是开发并发程序时常见的套路,也是该类设计者Doug Lea的常见写法。举个例子,比如现在执行putVal时,发现表尚未初始化,table为空,那先要进行初始化,此时代码分支就执行初始化,执行之后进入下一次循环,在下一次循环里,表已经初始化了,代码会走到别的分支中。同时,在一些保证安全并发的CAS操作中,操作有可能会失败,失败时也要通过这个循环进行重试。

只有当实际插入真的成功时,这个循环才会跳出。

了解了putVal的整体结构后,我们开始逐个进入这些代码分支。

惰性初始化表

final V putVal(K key, V value, boolean onlyIfAbsent) {
    // ... 省略 ...
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
    }
    // ... 省略 ...
}

这里,if条件中判断了表是不是为空,或者表的长度为0,若为空,就调用initTable并将返回值赋给tab。Doug Lea很喜欢在iffor循环的括号里进行变量赋值,这里tabn被正确赋值。

注意,Node<K, V>[] tab = table这行代码,创建了一个对堆中底层数组的新引用tabtabtable现在引用着同一块内存,但如果你后续执行tab = xxxxx,那tab引用将不再指向原来的内存,它和table也不再代表一个东西。

下面是initTable的代码:

private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    // 再次判断
    while ((tab = table) == null || tab.length == 0) {
        // sizeCtl < 0,代表当前已经有线程正在进行resizing或初始化
        if ((sc = sizeCtl) < 0)
            Thread.yield(); // 输掉初始化竞争,自旋让步
        // 否则,使用CAS操作直接将sizeCtl设成 -1
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
            try {
                // 再次检测 如果表未初始化
                if ((tab = table) == null || tab.length == 0) {
                    // 计算底层数组大小
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    // 创建底层数组
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    // 让table和tab也都引用nt
                    table = tab = nt;
                    // 重新计算sc
                    sc = n - (n >>> 2);
                }
            } finally {
                // 重设sizeCtl
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

首先,因为这是个并发容器,存在多个线程都探测到表尚未初始化的情况,也存在争抢初始化失败的情况,所以,这里还是一个循环。

首先使用sc承载sizeCtl的原值,以便后面CAS操作探测出其它线程也在同时修改sizeCtl。如果sc < 0,那么代表其它线程正在进行初始化,或resizing,不管咋样,Thread.yield()会让当前线程主动让出执行权,在操作系统再次决定调度该线程运行时,它又会走到循环初始处去判断是否仍需要初始化。

如果一切正常,走到else if中,就使用CAS操作将sizeCtl的状态设置为-1,这个举动让所有线程都能发现当前表正在初始化,不要有进一步的动作。如果CAS失败,继续循环,否则进入else if

再次检测表是否尚未初始化,因为上面的条件只能判定进入else if之前,sc不小于0,这个条件并不代表表没有被其它线程初始化过。

else if中,如果sc是0,采用默认大小,否则采用sc指定的大小创建表,并设置tabletab都指向这个堆中的数组。

当表初始化完毕,按照约定,sizeCtl应该被设成扩容阈值,这里的式子实际就是设成数组容量的0.75倍。

从初始化过程中我们知道了:

  1. sizeCtl指定的扩容阈值是当前数组大小的0.75倍

若槽为空——无锁CAS插入

final V putVal(K key, V value, boolean onlyIfAbsent) {
    // ...省略...
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        if (尚未初始化) 
            初始化
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            if (casTabAt(tab, i, null,
                            new Node<K,V>(hash, key, value, null)))
                break;                   
        }
    }
    // ...省略...
}

这里引入了两个方法,tabAtcasTabAt,对于这俩方法,我们不用纠结过多,知道它是做什么的就行。

tabAt是在给定tab中,可见的获取指定位置的Node:

static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
    return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}

casTabAt则是原子的比较并交换给定tab指定位置上的Node:

static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
                                    Node<K,V> c, Node<K,V> v) {
    return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}

所以,上面的代码首先将f赋值成表中指定hash槽位的Node节点,如果这个位置还没有Node节点,就使用CAS操作新建一个。

插曲:bin的概念

Hash表中可能发生冲突,当冲突发生时,每一个槽上可能保存多个Node,它们或是使用链表,或是使用红黑树的形式。不管使用什么形式,这槽上的一系列Node就称为bin(箱子)

所以从上面的代码中可以看出,当bin为空时(槽上一个Node都没有),对该槽的CAS操作就能保证正确插入了,所以不用任何锁

在其它情况下,ConcurrentHashMap对Bin中的首节点加锁,这使得1.8中该类的锁粒度更加细致,我们稍后会看到。

若探测到该槽正在迁移——加入迁移大队

final V putVal(K key, V value, boolean onlyIfAbsent) {
    // ...省略...
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        if (尚未初始化) 
            初始化
        else if (指定槽位没有数据) 
            使用CAS无锁化插入
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
    }
    // ...省略...
}

现在,代码来到了第三个分支。在第三个分支中,f是本次插入该插入到的槽位上的Bin的首节点,如果你不知道为什么,就去看看第二个分支的代码。

若Bin首节点的hash值为MOVED,就启动helpTransfer方法,这是啥意思呢?

五种Node

我们得来先解析一下Node这个结构:

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保存了键值对,键的hash,以及下一个Node的指针。所以基础的Node可以构成一个链表结构。在ConcurrentHashMap内部,还维护了其它四种Node:

img

  • TreeNode:当使用红黑树时,树的节点
  • TreeBin:当使用红黑树时,树的头。它并不保存实际的KV,是一个虚拟节点。只是为了让代码能探测到当前Bin是树结构。
  • ForwardingNode:当一个bin中的节点开始迁移前,该节点会被插入到该bin的头,它的hash属性为MOVED,没有实际键值,只是为了让代码能探测到当前Bin正在迁移。
  • ReservationNode:在使用computeIfAbsentcompute操作时,作为一个占位符节点

这里,我们最关心的是ForwardingNode,以及什么是迁移。

TreeBinForwardingNodeReservationNode都不是实际存储数据的Node,它们的hash属性都是特殊的,都小于0,在该类的代码中会用Node的hash属性作为判断判断依据

static final int MOVED     = -1; // hash for forwarding nodes
static final int TREEBIN   = -2; // hash for roots of trees
static final int RESERVED  = -3; // hash for transient reservations

什么是迁移

所谓迁移,就是resizing带来的。resizing是在Hash表中的元素数量已经达到阈值时做的一个扩大底层数组的操作,为的是不让每一个Bin中有太多元素,拉低Map的处理效率。

扩容需要把每一个Bin中的每一个Node以新的hash函数重新映射到新的底层数组中,这个过程就叫Bin的迁移。而ConcurrentHashMap的并发锁粒度以Bin为单位,当一个Bin正在执行迁移时,其它线程不可能能继续向Bin中插入数据,或修改Bin中的数据。

所以,上面的代码就是在线程发现Bin的头节点是ForwardingNode时,帮助进行迁移。

至于迁移的代码,超出了本小节的范围,本小节只说插入,后面会说迁移。

插入!!!

final V putVal(K key, V value, boolean onlyIfAbsent) {
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        if (尚未初始化) 
            初始化
        else if (指定槽位没有数据) 
            使用CAS无锁化插入
        else if (如果该Bin正在迁移)
            帮助迁移
        else {
            V oldVal = null;
            // 锁定bin头
            synchronized (f) {
                // 如果f还是bin头的话
                if (tabAt(tab, i) == f) {
                    // 如果fh并不是任何虚拟节点
                    if (fh >= 0) {
                        binCount = 1;

                        // 沿f向下比对,若找到key相等的节点,并且不是`onlyIfAbsent`(只有key不存在时才put)
                        // 就更新该节点的值,并退出
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                            if (e.hash == hash &&
                                ((ek = e.key) == key ||
                                    (ek != null && key.equals(ek)))) {
                                oldVal = e.val;
                                if (!onlyIfAbsent)
                                    e.val = value;
                                break;
                            }
                            // 如果下一个节点已经为空了,插入一个新节点
                            Node<K,V> pred = e;
                            if ((e = e.next) == null) {
                                pred.next = new Node<K,V>(hash, key,
                                                            value, null);
                                break;
                            }
                        }
                    }
                    // 如果Bin头是TreeBin,操作红黑树来添加Node或修改Node
                    else if (f instanceof TreeBin) {
                        Node<K,V> p;
                        binCount = 2;
                        if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                        value)) != null) {
                            oldVal = p.val;
                            if (!onlyIfAbsent)
                                p.val = value;
                        }
                    }
                }
            }
            if (binCount != 0) {
                // 如果binCount大于等于树化的阈值了,就对该bin进行树化,转化成红黑树
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    addCount(1L, binCount);
    return null;
}

这里的代码很简单,但仍有些需要注意的地方。首先,if (fh >= 0)代码块中,由该Bin头引领的Bin一定是一个链表结构,因为如果是红黑树结构,f会是一个TreeBinfh就是负数了。所以,可以在这个分支中大胆的以链表形式遍历。

在以链表形式遍历时,binCount就会一直自增,如果此次put是一次插入,binCount就代表该链表当前的长度。在后面会对binCount做一个判断,如果它已经到了要树化该Bin的阈值时,就对该Bin进行树化,转换成红黑树。

实际上,在负载因子和扩容的加持下,很难有一个Bin会达到树化阈值。达到树化阈值代表Key的hash函数真的不均匀,导致hash表中数据的分布倾斜,某些槽承载了大量的数据。此时若还用链表做Bin的话,Map的查找复杂度将退化成线性时间。

所以,红黑树实际上是一个对于垃圾Hash函数的保底策略,保证Map至少拥有树的对数时间复杂度。对于少量数据来说,红黑树花在维护平衡性上的消耗可能不如简单的链表来的实在。

ConcurrentHashMap会尽量避免树化操作,实际上在treeifyBin方法中,还需要判断当前table的容量是不是小于MIN_TREEIFY_CAPACITY,该值是64。如果小于这个值,则对table进行扩容,也就是resizing。

插入总结

  1. ConcurrentHashMap会在插入时检测表是否还没创建,若是,则创建。创建过程采用对sizeCtl的CAS自旋操作保证安全性
  2. ConcurrentHashMap在Bin中还没有任何元素时,直接在上面放置元素,无需任何锁定,并采用CAS来避免多个线程共同放置
  3. 在Bin中有元素时,它会使用synchronizd锁定Bin头进行插入,当插入后发现链表已经过长时,树化该Bin
  4. 当Bin头是ForwardingNode时,代表该Bin正在迁移,此时调用线程暂停插入,而是helpTransfer
  5. 当Bin头是TreeBin时,代表该Bin是红黑树结构

获取

目测获取操作比较简单

public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    int h = spread(key.hashCode());
    // 如果表已经初始化
    if ((tab = table) != null && (n = tab.length) > 0 &&
        // 并且该hash值映射到的bin不为null
        (e = tabAt(tab, (n - 1) & h)) != null) {
        // 如果bin头Node的key就是传入的key,直接命中
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        // 如果bin头的hash < 0,它可能是TreeBin,尝试获取
        // 除了TreeBin,其它的虚拟节点的find都返回null
        else if (eh < 0)
            return (p = e.find(h, key)) != null ? p.val : null;
        // 否则,遍历链表进行获取
        while ((e = e.next) != null) {
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}

相比于put操作,get很简单,并且get操作是无锁的。为什么get不用加锁呢?

我们考虑这里可能发生的并发问题:

  1. Bin链表末尾插入了新节点
  2. Bin中已有节点的value被修改

这俩问题其实都是可见性问题,由于get操作不涉及到修改,所以它实际上不需要和别人互斥执行以保证数据不被弄错乱,它只需要保证其它线程的修改对自己可见即可。

而Node的nextvalue变量都是volatile的,能保证若其它线程修改了该变量,则对任何后续的读取都可见

static class Node<K,V> implements Map.Entry<K,V> {
    volatile V val;
    volatile Node<K,V> next;
}

那红黑树的情况呢?我不想分析红黑树的代码,太复杂了,不过可以看到TreeBin内部实际使用了一些CAS操作以及LockSupport.park来实现了一个读写锁。

img

网上的大佬解析了这个类,得到了以下结论:

  1. 通过TreeBin查找某个节点时,如果当前写锁被占用或者有等待获取写锁的线程,表示红黑树处于正在调整的过程中,则遍历链表查找;
  2. 如果写锁没有被占用且没有等待的线程,则抢占读锁,遍历红黑树的节点来查找;
  3. 读锁释放时会判断是否所有的读锁都释放了,如果都释放了且当前有等待获取写锁的线程,则唤醒正在等待中的线程。

以上内容引用自【JUC并发编程】3 ConcurrentHashMap的get()方法真的不需要加锁吗?

所以,我的结论是即使TreeBin中有读写锁,也可以说get操作是无锁的,因为即使在读时遇到写锁已经加了的情况,也可以通过退化成链表来进行get

但你要问get需不需要加锁,只能说有时候需要,在TreeBin的时候需要加读写锁。

统计大小

上面我们的插入操作实际上没有完全讲完,因为插入操作肯定还要做两件事,即维护ConcurrentHashMapsize,还有检测插入后是否到达扩容阈值。我们上面讲的是插入,所以刻意屏蔽了相关的内容。

实际上,putVal的最后有这么一行:

addCount(1L, binCount);

该函数有两个参数:

  1. x:要将Map的大小加多少
  2. check:是否进行resizing检查的依据。若 < 0,不检查,若 <= 1,只在没有竞争的情况下检查。

所以,上面的调用实际上是在发生插入后,将Map的大小加1,并且根据传入的插入的bin的长度,决定是否进行resizing检查。这一小节里,我们只专注于统计大小,但是关于这两个参数的作用,先交代在这了。

分布式计数原理

/**
 * 基本计数器值,主要在没有竞争时使用,但是也会在table初始化竞争
 * 期间作为一个后备方案。通过CAS操作更新。
 */
private transient volatile long baseCount;

baseCount维护了ConcurrentHashMap中的元素数量,它的更新是通过CAS操作保证原子性,通过volatile保证并发可见性。但该类的计数操作作为一个十分流行的面试题,并不只是一个变量那么简单。

CounterCell是一个计数单元,一系列计数单元组成了一个长度为2的幂次的表,用于实现分布式计数:

@sun.misc.Contended static final class CounterCell {
    volatile long value;
    CounterCell(long x) { value = x; }
}
// counter-cell表,当非空时,其大小是2的幂次
private transient volatile CounterCell[] counterCells;

CounterCell中的value是volatile的,保证了并发编程中的多线程修改的可见性。同时,counterCells变量也是volatile的,这保证了并发编程中修改该引用实际指向的计数单元表时也可以做到多线程修改可见。

说了这么多,实际上ConcurrentHashMap在维护元素数时,先通过CAS尝试直接加到baseCount上,若失败,则随机加到一个CounterCell上。所以,所有CounterCell中的值以及baseCount的和才等于ConcurrentHashMap的元素数量。之所以这么做,我的理解就是为了减少竞争,在一个变量上CAS和在一堆变量上CAS肯定后者竞争更小啊。

下面是ConcurrentHashMapsize方法调用的,用于返回的方法,实际上就是将baseCount和计数单元表中的所有计数单元值相加。

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

代码分析

addCount

private final void addCount(long x, int check) {
    CounterCell[] as; long b, s;
    // 如果计数单元表不为空,或者CAS修改baseCount失败
    if ((as = counterCells) != null ||
        !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
        // 操作计数单元表
    }
    // ...
}

可以看到addCount的逻辑就是在有计数单元表时直接走计数单元表,否则尝试CAS一下用baseCount,失败再走计数单元表。

所以,上面代码中的if分支,在以下两个条件下会进入:

  1. 计数单元表已经初始化
  2. 计数单元表尚未初始化,并且通过CAS尝试修改baseCount时遇到冲突而失败

往下看:

private final void addCount(long x, int check) {
    CounterCell[] as; long b, s;
    if ((as = counterCells) != null ||
        !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
        // 应用计数单元表进行计数
        CounterCell a; long v; int m;
        boolean uncontended = true;
        // 如果计数单元表为空,或者计数单元表长度为0
        // 或者随机负载到的计数单元表中的单元为null
        // 或者CAS增加计数单元失败(遇到冲突)
        //   => 执行fullAddCount
        if (as == null || (m = as.length - 1) < 0 ||
            (a = as[ThreadLocalRandom.getProbe() & m]) == null ||
            !(uncontended =
                U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
            fullAddCount(x, uncontended);
            return;
        }
        if (check <= 1)
            return;
        // 执行sumCount
        s = sumCount();
    }
    // 省略resizing相关代码
}

上面的代码,我知道你看着很懵逼,我也懵逼。咱先不考虑sumCount()fullAddCount()这些东西,它们太高端,暂时还看不懂。不过中间那一串挺多||连起来的if条件貌似隐藏着一些呼之欲出的秘密,只不过这么写太难看懂了,我们得转换一下:

// 更新CounterCell时是否遇到冲突
boolean uncontended = false;
// 如果计数单元表为空,或者计数单元表长度为0,进行fullAddCount并返回
if (as == null || (m = as.length - 1) < 0) {
    fullAddCount(x, uncontended);
    return;
}
// 随机获取计数单元表中的一个单元
CounterCell a =  as[ThreadLocalRandom.getProbe() & m];
// 如果恰好这个单元为null,fullAddCount并返回
if (a == null) {
    fullAddCount(x, uncontended);
    return;
}
// CAS操作计数单元,进行计数累加,x就是用户传入的要加的值
uncontended = U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x));
// 若CAS增加计数单元失败,代表遇到冲突,进行fullAddCount
if (!(uncontended =
    U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
    fullAddCount(x, uncontended);
    return;
}

所以,这里实际上就是尝试将用户传入的x通过CAS操作加到一个计数单元里。不过有三种情况会走fullAddCount

  1. 计数器单元表尚未初始化
  2. 随机获取的计数器单元尚未初始化
  3. 在计数器单元上计数仍然遇到冲突

在这里,向fullAddCount传递的两个参数分别是要加的数以及是否是遇到了冲突导致的此次调用。

fullAddCount

private final void fullAddCount(long x, boolean wasUncontended) {
    int h;
    // 从线程本地随机数中获取一个素数,若为0
    // 则代表尚未初始化,初始化这个素数再重新获取
    if ((h = ThreadLocalRandom.getProbe()) == 0) {
        ThreadLocalRandom.localInit();      // force initialization
        h = ThreadLocalRandom.getProbe();
        // 如果走了这个if,就不认为曾经发生过冲突
        // 目前不明白为啥
        wasUncontended = true;
    }
    // 如果计数器单元槽非空,则为true
    boolean collide = false;
    for (;;) {
        // ...
    }
    // ...
}

这个for循环里干了啥呢?

for (;;) {
    CounterCell[] as; CounterCell a; int n; long v;
    // 如果计数单元表不是null,并且也不为空数组
    if ((as = counterCells) != null && (n = as.length) > 0) {
        if (some condition)  doSomething();
        else if (some condition) doSomething();
        else if (some condition) doSomething();
        else if (some condition) doSomething();
        else if (some condition) doSomething();
        else if (some condition) doSomething();
        h = ThreadLocalRandom.advanceProbe(h);
    }
    else if (some condition) {
        // 初始化计数单元表
    }
    // 继续尝试使用baseCount添加
    else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x))
        break;    
}

这里,我特意把一些分支折叠了,没有展示它们的代码,要不然这个方法很长很长。不过这个结构我们已经很熟悉了,就是一个循环套一堆分支,每个分支有自己的边界条件,满足这个条件就进入,然后做该分支该做的工作,做完继续循环。

首先,如果计数单元表已经初始化了,就进入那个第一个if分支,里面还有六个小分支。这里,我们先假设计数单元表不存在,看看该方法是如何初始化计数单元表的。

for (;;) {
    CounterCell[] as; CounterCell a; int n; long v;
    // 如果计数单元表不是null,并且也不为空数组
    if ((as = counterCells) != null && (n = as.length) > 0) {
        // 五个小分支...
        h = ThreadLocalRandom.advanceProbe(h);
    }
    // 如果单元表不忙,并且计数单元表还等于as(计数单元表的引用没有被其它线程切换过)
    // 并且通过CAS进行单元表忙状态切换成功
    else if (cellsBusy == 0 && counterCells == as &&
                U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
        boolean init = false;
        try {
            // 创建表
            // 如果counterCell等于as
            if (counterCells == as) {
                // 初始化大小为2的计数单元表
                CounterCell[] rs = new CounterCell[2];
                // 将用户的x填入表中的一个位置
                rs[h & 1] = new CounterCell(x);
                // 更新this.counterCells
                counterCells = rs;
                init = true;
            }
        } finally {
            // 对cellsBusy变量解锁
            cellsBusy = 0;
        }
        if (init)
            break;
    }
    // 继续尝试使用baseCount添加
    else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x))
        break;    
}

这里主要是通过cellsBusy这个自旋锁以及if块内部对counterCells == as的再次确认来保证不会有一个线程初始化后被另一个线程再次初始化。

对于cellsBusy变量,当进行resizing时貌似也会锁它,当它被锁了,最下面的else-if分支会采用对baseCount的CAS操作进行保底。

下面,我们就看当计数单元表已经被初始化完成后的那五个分支,这里,我们先不看分支里的代码,只看分支条件:

for (;;) {
    CounterCell[] as; CounterCell a; int n; long v;
    if ((as = counterCells) != null && (n = as.length) > 0) {
        // 如果选中的计数单元尚未初始化
        if ((a = as[(n - 1) & h]) == null) {
            // 初始化计数单元
        }
        // 如果已知是外部传入的,对指定计数单元进行CAS时遇到冲突
        else if (!wasUncontended)       
        // 如果尝试对计数单元的CAS更新失败
        else if (U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))
        // 如果as变量已经过时,`counterCells`引用被修改了
        // 或者计数单元表已经大于等于最大限制了(就是CPU数量)
        else if (counterCells != as || n >= NCPU)
        // 如果没有发生碰撞
        else if (!collide)
        // 如果单元格不忙,并且锁定cellsBusy成功
        else if (cellsBusy == 0 &&
                    U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
            // 扩展计数单元表,并使用扩展后的表重试
        }
        h = ThreadLocalRandom.advanceProbe(h);
    }
    else if (cellsBusy == 0 && counterCells == as &&
                U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
        // 初始化技术单元表
    }
    else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x))
        break;
}

光是看这些分支已经有点懵逼了。

先看下第一个,初始化计数单元的分支:

// 如果选中的计数单元是空的
if ((a = as[(n - 1) & h]) == null) {
    if (cellsBusy == 0) {            // 如果cellsBusy未锁定
        CounterCell r = new CounterCell(x); // 创建新CounterCell并将用户的x值放进去
        // 锁定cellsBusy
        if (cellsBusy == 0 &&
            U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
            boolean created = false;
            try {               // Recheck under lock
                CounterCell[] rs; int m, j;
                // 在锁下再次进行各种判断,确定没有其它线程修改了操作的前提条件
                // 注意,因为这里已经在锁下面了,所以可以放心的直接使用
                // if (condition1 && condition2 && ...)这种方式判断
                // 因为这些条件已经不会被修改了
                if ((rs = counterCells) != null &&
                    (m = rs.length) > 0 &&
                    rs[j = (m - 1) & h] == null) {
                    // 将新创建的单元格放进表里
                    rs[j] = r;
                    created = true;
                }
            } finally {
                // 释放锁
                cellsBusy = 0;
            }
            // 结束
            if (created)
                break;
            continue;           // 槽现在已经不是空了
        }
    }
    // 走到这里,说明要么cellsBusy没锁上,要么又发生了其它冲突
    // 设置collide标志
    collide = false;
}

这段代码就是锁定计数单元表,创建单元并插入,同时已经把用户需要的值加到新创建的单元中了,直接跳出循环。

这里面有一个collide标志,它应该是碰撞的意思,按理来说它为true应该代表发生了冲撞,应该以某种方式重试,但是这里该变量的取值貌似和其名字的含义相反,若它为false,则代表在该方法内部检测出了冲突。

collide类似的,还有一个wasUncontended,当它为false时,代表在调用该方法之前,在方法外部(比如addCount中)就发生了对单元格插入的冲突。

下面一个分支:

// 如果已知是外部传入的,对指定计数单元进行CAS时遇到冲突
else if (!wasUncontended)   
    wasUncontended = true;
// 省略几个else-if分支
h = ThreadLocalRandom.advanceProbe(h);

这里直接把这个冲突状态取消了,然后后面几个else-if都会被跳过,就直接走到这个advanceProbe(h)操作。我这里不去看这个方法的内容了,反正就是推进下线程本地的素数嘛,推进素数后就可以达到换个计数单元的效果。设计者管这个推进素数切换计数单元的操作称为rehash,我们后面也称它为rehash,但请不要和整个ConcurrentHashMapresizing搞混。

切换素数之后,继续进入自旋。

第三个分支:

else if (U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))
    break;

很好理解,如果这次对计数单元进行CAS累加成功,则直接跳出循环,过程结束。

第四个分支:

else if (counterCells != as || n >= NCPU)
    collide = false;    
// ...省略其它分支
h = ThreadLocalRandom.advanceProbe(h);

如果counterCells的引用关系被改变了,或者计数单元表的长度已经达到当前CPU个数个了,置collide = false。只有发生扩容时,counterCells的引用关系才会被改变,所以,这也可以理解为当发生扩容,或者counterCells的大小已经达到当前CPU个数个(最大限制)时,就设置collide = false,表示发生冲突。并且,该分支也没有continue或者break,会走到最后的rehash操作。

第五个分支:

else if (!collide)
    collide = true;
// ...省略其它分支
h = ThreadLocalRandom.advanceProbe(h);

若有冲突,重置冲突,走rehash。

最后一个分支:

// 锁cellsBusy
else if (cellsBusy == 0 &&
            U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
    try {
        if (counterCells == as) {
            // 二倍扩展计数单元数量
            CounterCell[] rs = new CounterCell[n << 1];
            // 移动老数据到新的表中
            for (int i = 0; i < n; ++i)
                rs[i] = as[i];
            // 切换单元表
            counterCells = rs;
        }
    } finally {
        // 解锁
        cellsBusy = 0;
    }
    // 设置冲突状态
    collide = false;
    continue; // 使用新扩容的计数单元重试
}

其实到此为止,计数方面的东西应该已经说完了,来个总结吧。

  1. ConcurrentHashMap采用baseCount加上一个为二的幂次的计数单元表来计数
  2. 无论是baseCount还是计数单元,都采用CAS来更新,提供多个计数单元是为了减少在一个变量上的冲突
  3. 对于一次计数,首先尝试更新baseCount,若发生冲突,则尝试使用计数单元表
  4. 计数单元表以及内部的计数单元也都是惰性初始化的,用到时才初始化
  5. 计数单元表可以动态扩容,当它的大小大于等于CPU个数时,停止扩容,因为表的本质是为了避免冲突,提供多于CPU个数个表实际上是没有意义的
  6. 对于计数单元表、计数单元的创建,以及扩容,都会通过对一个cellsBusy变量的CAS操作来上自旋锁
  7. 一次插入使用线程本地随机素数来映射到CountCell表上的一个单元,当发生冲突,该随机素数会推进,以选择另一个单元进行插入,以动态减少冲突。这个操作称作rehash。
  8. 当对计数单元表进行操作时,若遇到必须锁定计数单元表但锁定失败的情况,会使用baseCount进行一次后备尝试。

resizing

扩容检查的条件

addCount方法由putVal调用,传入两个参数addCount(1L, binCount)

在添加的bin为链表时,binCount有两种可能:

  1. 添加的key在链表中已经存在,本次put是一次更新操作,binCount是遍历链表节点的个数
  2. 添加的key在链表中不存在,本次put是一次插入操作,binCount是链表节点个数

addCount将这个参数命名为check,用于控制是否进行resizing检查。注释中的规则如下:

  1. 如果check < 0,不进行resizing检查
  2. 如果在计数时有冲突发生,并且check <= 1,不进行resizing检查
  3. 其它情况,都进行resizing检查

根据addCount的代码来查看,貌似和预想的不太一样:

private final void addCount(long x, int check) {
    CounterCell[] as; long b, s;
    // 如果计数表还未初始化,baseCount中的值就是当前Map总数量
    if ((as = counterCells) != null ||
        !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
        // 如果 计数表初始化了,优先使用计数表
        // 或者当baseCount冲突时使用计数表
        CounterCell a; long v; int m;
        // 未发生冲突
        boolean uncontended = true;
        // 如果需要初始化计数表或计数单元,或者对计数单元的写入冲突了
        // 执行fullAddCount后直接退出,不进行resizing检查
        if (as == null || (m = as.length - 1) < 0 ||
            (a = as[ThreadLocalRandom.getProbe() & m]) == null ||
            !(uncontended =
                U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
            fullAddCount(x, uncontended);
            return;
        }

        // 走到这里,说明对计数单元的CAS写入并未发生冲突,如果此时check <= 1,退出,不进行resizing检查
        if (check <= 1)
            return;
        s = sumCount();
    }
    // 如果check >= 0,进行resizing检查
    if (check >= 0) {
        // resizing检查
    }
}

所以,根据代码,我们可以分析出,当使用putVal调用addCount时,扩容检查会在以下情况发生:

  1. 根本没使用计数表,并且baseCount的CAS写入成功时
  2. 使用计数表了,并且对计数单元的CAS成功,而且在putVal里满足以下两个条件
    1. bin是链表,并且对链表元素的遍历次数大于1
    2. bin中是树结构(树结构的binCount为常数值2)

我们可以看出resizing检查并不是每次添加都执行的,只有CAS成功,并且在使用了计数表的情况下还得满足check > 1时才检测。猜测这样的目的是为了在并发情况下尽量减少resizing检测的次数,尽量只让一个线程(CAS成功的线程)来执行resizing检查。

并且当出现树形结构时,设计者通过将binCount设置为常数2,表示总是希望进行扩容检查的。可能是因为出现树可能代表当前元素数量已经太多了吧。

当你删除元素的时候,addCountcheck会被设置成-1,所以移除时只会维护计数,并不会检测resizing。

进行扩容检查

下面把目光聚焦到扩容检查的代码上

private final void addCount(long x, int check) {
    // ...省略计数代码...
    if (check >= 0) {
        Node<K,V>[] tab, nt; int n, sc;
        // 如果s大于sizeCtl,并且table已经初始化,并且table大小没有超过最大容量
        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();
        }
    }
}

这段代码极其复杂,位运算很多,我们一点一点来看。

首先,外层的循环很好理解,s就是当前Map中的元素数量,若没有使用计数表,就是baseCountCAS累加后直接得到的结果,如果使用计数表,就是后通过sumCount统计的,反正走到这里,它已经是Map中元素数量了。

使用ssizeCtl相比较,若数组已经初始化了,并且不处于扩容期间,sizeCtl就是再次扩容的阈值。如果数组处于扩容期间,sizeCtl是负数,这个条件也不可能成立。

所以这里while中的条件可以看作,如果数组已经初始化并在正常工作,并且当前Map元素数达到扩容阈值时。

其内部调用了resizeStamp(n),计算出了一个rs

static final int resizeStamp(int n) {
    // n的前导0个数 | 1 << 15
    return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}

我们假设n = 16,也就是当前table的容量是16,那么resizeStamp的结果就是

00000000 00000000 00000000 00010000     =  16
前导零 = 27

00000000 00000000 00000000 00011011
00000000 00000000 10000000 00000000     或运算
=
00000000 00000000 10000000 00011011     = 32795

实际上,resizeStamp的结果的十进制表示并不重要,我们主要关注它的二进制表示。

无论怎么计算,n的前导零最多是32个,所以实际上,这个方法返回的值在二进制表示上,一定是后八位上是n的前导零个数,并且第16位一定是1。高16位肯定都是0。对这样一个数左移16位的话,它一定是一个负数。

然后:

while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
        (n = tab.length) < MAXIMUM_CAPACITY) {
    int rs = resizeStamp(n);
    if (sc < 0) {
        // 如果sc < 0,已经有其它线程开启resizing过程
    }
    else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                    (rs << RESIZE_STAMP_SHIFT) + 2))
        transfer(tab, null);
    s = sumCount();
}

两个分支,第一个是sc < 0,这应该是其它线程正在resizing的情况,先不看。

else if分支中,使用CAS操作将sizeCtlsc变成了(rs << 16) + 2,还是拿刚刚n = 16的例子,相当于是这样的:

10000000 00011011 00000000 00000010

从整体上来说,sc变成了一个相当大的负数,从高低两部分来说,其低16位为2,高16位为原来的rs。然后,使用transfer方法开启迁移,并重新统计数量。

transfer 扩容

transfer里面的代码很多很多,我们得一点一点看,先看前面一点

private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    int n = tab.length, stride;
    // stride代表当前扩容负责的子区域大小
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
        stride = MIN_TRANSFER_STRIDE; // subdivide range
    
    // 如果nextTab == null,是第一次扩容,扩容的目标表还没有建立
    if (nextTab == null) {            // initiating
        try {
            // 建立目标表,新大小是当前大小的二倍
            @SuppressWarnings("unchecked")
            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
            nextTab = nt;
        } catch (Throwable ex) {      // try to cope with OOME
            // 如果数组容量已经溢出,那么就让`sizeCtl=Integer.MAX_VALUE`后直接退出,扩容失败
            sizeCtl = Integer.MAX_VALUE;
            return;
        }
        nextTable = nextTab;
        // transferIndex = 表长度
        transferIndex = n;
    }
    // ...省略大部分代码...
}

在这段代码中,建立了迁移的子区域大小stride。如果你的CPU核心数大于1,就使用n/8/NCPU,作为子区域大小,否则就使用n,让一个线程负责整个表的迁移。同时,stride的最小值是16。

假设当前表大小是1024,你是8核心CPU,那么一次迁移的子区域大小就是1024/8/8=16

并且,这段代码检测了第一次迁移nextTab还没建立的情况,若还没建立,先建立nextTab,并且transferIndex = 原表大小

现在,我们大致知道了ConcurrentHashMap是支持多线程并发迁移的,并且每一个线程的一次迁移中负责stride个bin。如果继续往下看,你会觉得这代码很长很长,无从下手。我下楼买薯片时突然想到,应该先理清在多线程迁移的整个流程,然后按流程去匹配代码里的段落。

首先,刚进入扩容任务的线程肯定要根据stride划分一下它负责处理的bin范围,在stride固定的情况下,这需要最少一个指针来界定。其次,扩容任务需要对它负责的范围的每一个数组槽位进行扫描,这需要一个整数记录。对于每一个扫描到的槽位,都要先在上面加上ForwardingNode节点,这样其它代码就能识别到该Bin正在进行迁移。当Bin进行迁移时,需要对Bin头进行加锁,以免在迁移过程中有节点插入到该Bin。

现在,继续往下看:

// 新表的大小
int nextn = nextTab.length;
// 公用的fwd节点
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
// 某种标记位,看起来应该是是否推进到下一个槽位
boolean advance = true;
boolean finishing = false; 
// i应该代表当前迁移的槽位 bound应该代表当前迁移任务的界限
for (int i = 0, bound = 0;;) {
    Node<K,V> f; int fh;
    // 如果向下一个槽位推进的话
    while (advance) {
        int nextIndex, nextBound;
        // --i 相当于向下一个槽位推进,可以看出迁移操作是从后往前的
        // 如果推进后仍大于界限,或者整个扩容任务已经结束
        if (--i >= bound || finishing)
            advance = false; // 不向下推进
        // 否则让nextIndex=transferIndex,并且如果它 <= 0的话
        else if ((nextIndex = transferIndex) <= 0) {
            // 应该是到数组头了吧,不向下推进
            i = -1;
            advance = false;
        }
        // 否则,只能是推进后已经不大于界限了,并且nextIndex也不<=0
        // 此时将transferIndex CAS替换成其原有值-stride
        // 相当于重新初始化一个范围,新范围是原来范围的前面stride个
        else if (U.compareAndSwapInt
                    (this, TRANSFERINDEX, nextIndex,
                    nextBound = (nextIndex > stride ?
                                nextIndex - stride : 0))) {
            // 貌似只有这里对bound进行了初始化
            bound = nextBound;
            i = nextIndex - 1;
            advance = false; // 不向下推进,因为需要把当前槽位中的东西都迁移走才能推进到下一个槽位
        }
    }
    // ...省略
}

上面的代码真的很难理解,不过根据我的猜想,可以画个图:

img

不要纠结于边界上差1个这种问题,我们主要学的是思路。

上图中每一个颜色的块是一个stride,这里假设只有4个,但stride的最小值是16。stride代表一个线程正在执行的迁移任务,它需要维护i从该stride的最右侧开始,遍历每一个槽位上的bin,并将bin中的全部数据迁移。当遍历完一个槽位,它需要做一个advance操作,将i向前推进,直到到达bound

至此,transferIndex的作用也明了了,它代表当前没扩容和扩容位置的分界线。这也是为什么最开始transfer方法将它设成了表大小n,因为此时整个旧表都是没被扩容的。

所以我们此时再看这段代码:

for (int i = 0, bound = 0;;) {
    Node<K,V> f; int fh;
    while (advance) {
        int nextIndex, nextBound;
        if (--i >= bound || finishing)
            advance = false;
        else if ((nextIndex = transferIndex) <= 0) {
            i = -1;
            advance = false;
        }
        else if (U.compareAndSwapInt
                    (this, TRANSFERINDEX, nextIndex,
                    nextBound = (nextIndex > stride ?
                                nextIndex - stride : 0))) {
            bound = nextBound;
            i = nextIndex - 1;
            advance = false;
        }
    }
    // ...省略一些其它操作...
}

最开始,ibound都是0,它们也是惰性初始化的。在第一次循环中,第一个分支走不了,第二个分支走不了,但在第二个分支中nextIndex被设置成了当前transferIndex边界。nextIndex就是下一次i的位置(+1)。在第三个分支中,CAS操作修改transferIndex到下一个边界,分配一个stride范围的数组元素给当前操作线程,并设置好boundi

也就相当于这个图片:

img

现在,i就从上图中的位置一直往前推进,直到bound。并且与此同时,其它线程也可以通过CAS操作transferIndex,为自己分配stride任务。

并且,现在其它线程仍然可以继续在没有被迁移的(bin头不是fwd)的槽位上put,并且依然可以自由的get

下面继续往下看

for (int i = 0, bound = 0;;) {
    Node<K,V> f; int fh;
    while (advance) {
        // 省略推进相关代码...
    }
    // 代码到达这里,已经选中了一个槽位并对它进行迁移,i就是这个槽位
    // 如果 i 的范围已经不合法
    if (i < 0 || i >= n || i + n >= nextn) {
        int sc;
        // 如果扩容已经完全结束了
        if (finishing) {
            // 加速GC
            nextTable = null;
            // table引用指向新的表
            table = nextTab;
            // sizeCtl = n * 2 - n / 2 = nextn - nextn / 4 = nextn * 0.75
            // sizeCtl设成新大小的0.75倍,作为新的扩容阈值
            sizeCtl = (n << 1) - (n >>> 1);
            return;
        }
        // sc的低16位代表的是当前执行扩容的线程数 + 2
        // CAS操作sc-1,代表该线程的任务已经完成
        if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
            // 如果sc-2不等于刚刚开始扩容时,sc的那个数,直接返回
            if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
                return;
            // 否则,就说明当前线程是最后一个完成扩容的
            // 设置finishing = true
            finishing = advance = true;
            i = n; // recheck before commit
        }
    }
    // 没进入上面的分支,代表i的位置是合法位置
    // 如果该bin的位置为`null`,直接cas将fwd加入到bin头即可
    // 保证其它线程不会往`fwd`节点中插东西,并且看到`fwd`节点后
    // 会来帮助扩容
    else if ((f = tabAt(tab, i)) == null) advance = casTabAt(tab, i, null, fwd);
    // 如果该位置已经是fwd节点了,代表已经处理过了,推进
    else if ((fh = f.hash) == MOVED)
        advance = true; // already processed
    // 否则
    else {
        // 这里就应该是迁移该bin中的所有元素到新的hash表的代码了
        // synchronized锁bin头,防止迁移过程中插入
        // 当迁移完后,也会往bin头上加一个fwd
        synchronized(f) {
            // ...省略...
        }
    }
}

至于迁移bin中所有元素,这里的代码我不想分析了。

总结一下,迁移操作通过transferIndexstride来让多个线程并发完成迁移。一个新的迁移,应该是会取当前transferIndex往前stride个槽位作为它的迁移任务,然后更新transferIndex。对transferIndex的更新是CAS的。

然后,对于一个正在迁移的线程,当它发现自己正在处理的槽位已经不合法了,就停止,如果检测到自己是最后一个迁移线程,就宣告迁移结束。迁移结束后,会将table引用设置到新表上,并恢复sc为正数,大小为新表的0.75倍。

迁移线程在迁移一个槽位时,会将bin头上synchronized锁,以免在迁移过程中插入引发数据丢失。并且,迁移结束后会在bin头上放一个fwd节点,其它线程在put到这个bin时看到fwd节点就会来帮助迁移。

回到addCount

可能都已经有点忘了,我们的transfer是从addCount中进来的,当时有两个分支:

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

我们是从else if分支进来的,这个分支将sc设成初始的负数,低16位标识正在迁移的线程数 + 2。

现在我们要看sc < 0这个分支

// 当前正在resize
if (sc < 0) {
    // 如果 sc 被改动 || sc 等于 rs + 1 || sc == rs + 最大RESIZER数
    // || nextTable == null || transferIndex <= 0
    // 都直接跳出,不帮助做resize
    if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
        sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
        transferIndex <= 0)
        break;
    // 否则,将sizeCtl CAS的加1,代表有新线程加入
    if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
        transfer(tab, nt);
}

这个没啥可说的,就是在符合一些可以帮助迁移的条件时尝试修改sizeCtl,帮助迁移。

helpTransfer

putVal中,如果发现待插入的槽位上bin头是fwd节点,那么就会调用helpTransfer,帮助迁移。

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);
        while (nextTab == nextTable && table == tab &&
                (sc = sizeCtl) < 0) {
            if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                sc == rs + MAX_RESIZERS || transferIndex <= 0)
                break;
            if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
                transfer(tab, nextTab);
                break;
            }
        }
        return nextTab;
    }
    return table;
}

这个代码实际上和addCount中的没有区别,就是少了一个首次transfer的分支。

关于迁移相关的内容,就分析到这里,后面的面试题部分可能会有相关的面试题,就当作对迁移过程的总结了。我的分析可能较为片面,因为这个代码真的是太庞大了,请谅解。如有错误还欢迎提出。

树化&解树化

putVal中有这么一个条件分支:

// 如果遍历链表的个数 >= 树化阈值(8)
if (binCount >= TREEIFY_THRESHOLD)
    treeifyBin(tab, i);

如果遍历链表的个数大于等于8时,调用treeifyBin。然而,并不是调用treeifyBin就立即对该Bin进行树化,其代码中是这样写的:

private final void treeifyBin(Node<K,V>[] tab, int index) {
    Node<K,V> b; int n, sc;
    if (tab != null) {
        // 如果表大小 < 最小树化容量(64)
        if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
            // 尝试扩容两倍
            tryPresize(n << 1);
        // 否则,如果b是正常节点
        else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
            // 锁b,也就是bin头
            synchronized (b) {
                // 进行树化
                if (tabAt(tab, index) == b) {
                    TreeNode<K,V> hd = null, tl = null;
                    for (Node<K,V> e = b; e != null; e = e.next) {
                        TreeNode<K,V> p =
                            new TreeNode<K,V>(e.hash, e.key, e.val,
                                                null, null);
                        if ((p.prev = tl) == null)
                            hd = p;
                        else
                            tl.next = p;
                        tl = p;
                    }
                    setTabAt(tab, index, new TreeBin<K,V>(hd));
                }
            }
        }
    }
}

所以,实际上,即使一个链表过长,达到树化阈值了,在table长度没有大于最小树化容量,也就是64时,也不会树化,而是进行二倍扩容。

这是因为在少量数据时,维护红黑树的平衡特性画出的开销跟使用链表相比可能并不划算。只有在数据量实在比较大(表容量大于64),而且数据倾斜比较严重(链表长度大于等于8)的情况下,才会使用红黑树。

在很多移除节点的地方,都会调用untreeify进行解树化。调用它的条件都如下:

else if (t.removeTreeNode(p))
    setTabAt(tab, i, untreeify(t.first));

这貌似和树化时不一样,树化时最起码和链表长度有点关系,解树化好像和红黑树内部元素数量完全无关。

removeTreeNode的内部,有这样一行代码:

if ((r = root) == null || r.right == null || // too small
    (rl = r.left) == null || rl.left == null)
    return true;

如果树根为null、树根的右子树为null、左子树为null,或者左子树的左子树为null,都会返回true,进行解树化。

而红黑树是平衡二叉树,如果无视颜色节点,那么它是一棵稍微向左倾斜的平衡二叉树,如果把红色节点抻平,那么它就是一颗平衡二叉树。参考《算法 第四版》中的红黑树实现。

对于一个平衡二叉树,如果根节点的右子节点为null,那它的左子树最多只有1个节点,对于左子树为null也是一样,而考虑到颜色节点,所以要判断下left.left。所以,这里的代码大概可以理解成红黑树中节点最多有两个的时候就解树化。

这是作者的个人推断,如有错误烦请指点

而在上面我们没接触到的transfer中的实际迁移一个Bin的代码分支中,有这样几行代码:

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;

这个不太好解释,好像是无论是链表还是红黑树,都会从bin中分出两条链表,一个是lo,一个是hi,然后这两条链表,一个会映射到新表中的原槽中,也就是i,一个会映射到新表的n+i槽中。

如果拆解后,某个链表中元素的数量到不了UNTREEIFY_THRESHOLD,就解树化,以链表形式存储到新表中的新槽位,若能到,则还是以树的形式存储。

这个我后面有时间可能单独分析迁移的代码,然后把这里的坑补上。

面试题

未完......

参考

posted @ 2023-02-18 20:22  yudoge  阅读(40)  评论(0编辑  收藏  举报