这段时间阅读了 JDK 8 的 ConcurrentHashMap 源码,其中扩容的过程涉及技术点繁多,很有必要自己动手对扩容原理进行梳理总结。本文中,我对关键代码编写了单元测试,并且找来了图例,方便理解。
编写文章过程,我竟然发现了 JDK 8 版本扩容时对 sizeCtl 的判断有 BUG,具体在第 3.4 节中说明。
1. 数据结构
使用数组+链表+红黑树来实现,利用 CAS + synchronized 来保证并发更新的安全。
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 implements Map.Entry {
//链表结构的属性定义
final int hash;
final K key;
volatile V val;
volatile Node next;
Node(int hash, K key, V val, Node next) {
this.hash = hash;
this.key = key;
this.val = val;
this.next = next;
}
TreeNode
TreeNode 继承与 Node,它是红黑树的节点,同时保留了链表的特性。
当链表的节点数大于 8 时会转换成红黑树的结构,就是通过 TreeNode 作为存储结构代替 Node 来转换成黑红树的。
static final class TreeNode extends Node {
//树形结构的属性定义
TreeNode parent; // red-black tree links
TreeNode left;
TreeNode right;
TreeNode prev; // needed to unlink next upon deletion
boolean red; //标志红黑树的红节点
TreeNode(int hash, K key, V val, Node next,
TreeNode 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 extends Node {
//指向TreeNode列表和根节点
TreeNode root;
volatile TreeNode 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 extends Node {
final Node[] nextTable;
ForwardingNode(Node[] 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[] 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[] 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 已经在 JDK 12 版本中解决(也就是 JDK 8 中没有解决,说好的维护至 2030 年呢)。
话说回来,国内各种博客网站、公众号研究 ConcurrentHashMap 的文章多如牛毛,几乎没有看到人提及这个 BUG,也是奇怪。
看一下解决之后的写法,对 sc 边界值的判断跟我上述的想法是一致的。
private final void addCount(long x, int check) {
//...
if (check >= 0) {
Node[] 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() 源码
看源码之前,提前梳理一下扩容过程:
- 创建 nextTable,新容量是旧容量的 2 倍。
- 将原 table 的所有桶逆序分配给多个线程,每个线程每次最小分配 16 个桶,防止资源竞争导致的效率下降。指定范围的桶可能分配给多个线程同时处理。
- 扩容时遇到空的桶,采用 CAS 设置为 ForwardingNode 节点,表示该桶扩容完成。
- 扩容时遇到 ForwardingNode 节点,表示该桶已扩容过了,直接跳过。
- 单个桶内元素的迁移是加锁的,将旧 table 的 i 位置上所有元素拆分成高低两部分,并迁移到 nextTable 上,低位索引是 i,高位索引是 i + n,其中 n 为扩容前的容量。
- 最后将旧 table 的 i 位置设置为 ForwardingNode 节点。
- 所有桶扩容完毕,将 table 指向 nextTable,设置 sizeCtl 为新容量 0.75 倍
private final void transfer(Node[] tab, Node[] 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[] nt = (Node[])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 fwd = new ForwardingNode(nextTab); // 扩容时的特殊节点,hash固定为-1,标明此节点正在进行迁移。扩容期间的元素查找要调用其find方法在nextTable中查找元素
boolean advance = true; // 当前线程是否需要继续寻找下一个可处理的节点
boolean finishing = false; // to ensure sweep before committing nextTab // 所有桶是否都已迁移完成
for (int i = 0, bound = 0;;) {
Node 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 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) {
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 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(ph, pk, pv, ln); // hash&n为0,索引位置不变,作低位链表
else
hn = new Node(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 t = (TreeBin)f;
TreeNode lo = null, loTail = null;
TreeNode hi = null, hiTail = null;
int lc = 0, hc = 0;
for (Node e = t.first; e != null; e = e.next) {
int h = e.hash;
TreeNode p = new TreeNode
(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(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc != 0) ? new TreeBin(hi) : t;
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
}
}
}
}
}
总结:
- 每个线程想增/删元素时,如果访问的桶是 ForwardingNode 节点,则表明当前正处于扩容状态,协助一起扩容完成后再完成相应的数据更改操作。
- 一个旧桶内的数据迁移完成,但不是所有桶都迁移完成时,查询数据委托给 ForwardingNode 结点查询 nextTable 完成。
- 迁移过程中 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 节点和拆分链表的逻辑,单元测试:
/**
* 扩容时,链表迁移算法
*
* @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. 参考
- 关于jdk1.8中ConcurrentHashMap的方方面面
- ConcurrentHashMap原理分析(1.7与1.8)
- ConcurrentHashMap底层详解(图解扩容)(JDK1.8)
- ConcurrentHashMap 1.8 计算 size 的方式