目录
1.2. treeifyBin() -- 数组小于 64,先扩容
2.1. transfer() -- 计算每个线程迁移的长度
1. 扩容触发的条件
- 插入导致容量超过阈值:当执行插入操作时,如果插入新元素后,表中的元素数量超过了当前容量与负载因子乘积所确定的阈值,就会触发扩容。
- treeifyBin() 方法中的扩容:当某个桶中的链表长度超过阈值(默认为 8)时,会尝试将链表转换为红黑树。然而,如果当前数组的容量小于 64,treeifyBin() 方法会选择进行扩容而不是树化,这会触发扩容。
- putAll() 方法中的扩容:当需要在短时间内插入大量元素时,tryPresize 方法可能会被调用以提前调整容量。此方法会计算所需的新容量,并通过调用 transfer() 方法来进行扩容。
1.1. addCount() -- 累加计数器和检查扩容
在存储完元素后,会更新计数器以及检查扩容
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 存储操作...
addCount(1L, binCount);
return null;
}
- 更新计数:
- 首先尝试通过 CAS 操作直接更新 baseCount。如果 counterCells 数组不为空(说明已经有竞争)或 CAS 操作失败,则进入更复杂的路径来更新计数
- 处理竞争:
- 如果 counterCells 存在且当前线程的计数单元(CounterCell)可以更新,则直接更新。
- 如果更新失败(可能由于竞争),则调用 fullAddCount() 方法。这是一个更复杂的方法,用于处理高竞争情况下的计数更新。
- 检查并触发扩容:
- 如果 check 大于或等于 0,则可能需要检查是否需要扩容。
- 如果当前元素数量 s 超过了 sizeCtl,并且表的容量小于最大容量,则可能需要进行扩容。
- 扩容通过 transfer 方法完成。在扩容过程中,多个线程可以协作完成数据的迁移。
- 使用 CAS 操作来控制并发扩容过程,确保线程安全
int check
- 当 check 大于等于 0 时,表示需要在更新计数后进行检查,以确定是否需要扩容。
- 当 check 小于 0 时,表示不需要进行扩容检查。这种情况下,通常只更新计数,而不考虑是否需要扩容。
在putVal()方法中,check 是传入 binCount(某个桶的操作次数)
CounterCell[] as
作用:
- ConcurrentHashMap 通过维护一个 CounterCell 数组,使每个线程可以在不同的 CounterCell 上进行计数更新,从而减少竞争。
- 当多个线程同时更新计数时,ConcurrentHashMap 会尝试让每个线程更新不同的 CounterCell。最终,所有 CounterCell 的值会被汇总以得到总的计数值。
工作机制:
- 初始状态:在竞争不严重的情况下,ConcurrentHashMap 会直接更新一个共享的计数器 baseCount。
- 竞争加剧:如果检测到竞争(例如,更新 baseCount 的 CAS 操作失败),则会初始化 counterCells 数组,并将计数分散到不同的 CounterCell 中。
- 线程映射:每个线程通过哈希计算找到对应的 CounterCell。如果哈希冲突导致多个线程访问同一个 CounterCell,ConcurrentHashMap 会尝试重新分配或扩展 counterCells 数组。
- 计数汇总:当需要获取总的元素数量时,会将 baseCount 和所有 CounterCell 的值相加。
private final void addCount(long x, int check) {
CounterCell[] as;
long b, s;
// 无竞争状态下counterCells == null 且 CAS baseCount(累加1)成功,不会进入下面代码块
// //有竞争进入则进入以下代码块
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
|| (a = as[ThreadLocalRandom.getProbe() & m]) == null
|| !(uncontended =U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
// 高竞争环境下更新计数器
fullAddCount(x, uncontended);
return;
}
// 如果不需要检查(check <= 1),直接返回
if (check <= 1){
return;
}
// 重新计算总数
s = sumCount();
}
// 如果需要检查并可能触发扩容
if (check >= 0) {
Node<K,V>[] tab, nt;
int n, sc;
// 如果元素个数大于等于sizeCtl,需要扩容
while (s >= (long)(sc = sizeCtl)
&& (tab = table) != null
&& (n = tab.length) < MAXIMUM_CAPACITY) {
// 计算扩容标记
int rs = resizeStamp(n) << RESIZE_STAMP_SHIFT;
// 第一次尝试扩容是不会进入下面这个if的
if (sc < 0) {
// 如果 sc == 扩容戳 + 最大扩容线程,表示扩容的线程数已经到最大值了
// sc == rs + 1,表示扩容已经结束了,
// nextTable 表示扩容用的新table,如果它为空表示没有在进行扩容或者已经扩容结束
// transferIndex 表示扩容索引,如果小于等于0表示没有在进行扩容操作或扩容结束
if (sc == rs + MAX_RESIZERS
|| sc == rs + 1
||(nt = nextTable) == null
|| transferIndex <= 0){
break;
}
// 增加扩容线程的数量并执行 transfer
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)){
transfer(tab, nt);
}
}else if (U.compareAndSwapInt(this, SIZECTL, sc, rs + 2)){
// 初始化扩容
transfer(tab, null);
}
// 重新计算总数
s = sumCount();
}
}
}
1.2. treeifyBin() -- 数组小于 64,先扩容
在链表长度大于等于 8 时,尝试将链表转为红黑树
private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b;
int n, sc;
// 数组不能为空
if (tab != null) {
// 数组的长度n,是否小于64
if ((n = tab.length) < MIN_TREEIFY_CAPACITY){
// 如果数组长度小于64,不能将链表转为红黑树,先尝试扩容操作
// 这里的扩容传入的数据是当前数据的2倍
tryPresize(n << 1);
}
// ...
}
}
1.3. tryPresize() -- 预调整哈希表容量
目的是在预计将要插入大量元素时,提前调整 ConcurrentHashMap
的容量,以减少后续扩容的次数和开销
private final void tryPresize(int size) {
// 计算预期的容量 c 通常是 size 的 1.5 倍加 1
// 如果达到最大容量的一半,则直接设置为最大容量
int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY : tableSizeFor(size + (size >>> 1) + 1);
int sc;
// 循环,直到成功调整容量或确定不需要调整
while ((sc = sizeCtl) >= 0) {
Node<K,V>[] tab = table;
int n;
// 如果当前哈希表未初始化或长度为0,则进行初始化
if (tab == null || (n = tab.length) == 0) {
n = (sc > c) ? sc : c;
// 通过 CAS 设置 sizeCtl 为 -1,表示正在初始化
if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if (table == tab) {
@SuppressWarnings("unchecked")
// 更新表为新表
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = nt;
// 设置 sizeCtl 为新容量的 0.75
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
}
// 如果目标容量 c 小于等于当前 sizeCtl 或者当前容量达到最大值,则无需扩容
}else if (c <= sc || n >= MAXIMUM_CAPACITY){
break;
}else if (tab == table) {
// 扩容戳,生成与当前数组长度相关的标记
int rs = resizeStamp(n);
// 如果 sizeCtl 是负数,说明有线程正在进行扩容,当前线程可以协助扩容
if (sc < 0) {
Node<K,V>[] nt;
// 验证扩容戳是否匹配
if ((sc >>> RESIZE_STAMP_SHIFT) != rs
// 是否还有线程参与扩容
|| sc == rs + 1
// 判断当前扩容的线程是否达到了最大限度
|| sc == rs + MAX_RESIZERS
// 扩容已经结束了
|| (nt = nextTable) == null
// 记录迁移的索引位置,从高位往低位迁移,也代表扩容即将结束
|| transferIndex <= 0){
break;
}
// 如果需要协助扩容,CAS 增加 sizeCtl,表示增加一个线程来协助扩容
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)){
transfer(tab, nt);
}
}else if (U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)){
// 调用transfer方法,并且将第二个参数设置为null,就代表是第一次来扩容
transfer(tab, null);
}
}
}
}
2. 开始扩容
代码是连续的,为了更好理解,根据每段代码的作用分段解释
这里假设 NCPU = 16,旧数组长度为 32
下文的变量都是处理完的数值
2.1. transfer() -- 计算每个线程迁移的长度
NCPU = 16:系统当前可用的处理器数量
n = 32:旧数组长度
stride = 16:每次迁移的范围
// 表示系统当前可用的处理器数量
static final int NCPU = Runtime.getRuntime().availableProcessors();
// nextTab 表示扩容用的新 table,第一次扩容 nextTab 是 null
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length;
// 每个线程一次性迁移多少数据到新数组
int stride;
// 这里的步长可以理解为每次迁移的范围
// 最小步长 MIN_TRANSFER_STRIDE = 16
// 如果线程数只有 1 的话,直接就是原数组长度
// 如果算出来每个线程的长度小于 16 的话,直接使用最小步长 16
// 大于 16 则使用 (n >>> 3) / NCPU
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE){
stride = MIN_TRANSFER_STRIDE;
}
// ...
2.2. transfer() -- 构建新数组
transferIndex = 32:初始值设置为旧表的长度 n(32)
,表示整个表尚未被处理
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
// ...
// 第一个进来扩容的线程需要把新数组构建出来
if (nextTab == null) {
try {
// 新 table 的长度是原 table 长度 * 2
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
nextTab = nt;
} catch (Throwable ex) {
sizeCtl = Integer.MAX_VALUE;
return;
}
// 这两个成员变量都是被volatile修饰的,保证不同线程操作扩容时的可见性
nextTable = nextTab;
transferIndex = n;
}
// ...
}
2.3. transfer() -- 线程领取迁移任务
nextn = 64:新数组的长度
transferIndex = 16:尚未被处理的旧表索引的最高位置
advance = false:表示当前线程是否需要继续处理任务
finishing = false:用于标识迁移是否结束,在迁移完成后设置为 true,以停止扩容过程
i = 31:表示当前处理桶的位置
bound = 16:用于限制当前线程可以处理的桶的范围,在任务分配时,bound 被设置为当前线程可以处理的最高桶索引
// 新数组长度
int nextn = nextTab.length;
//当扩容过程中遇到需要移动的节点时,可以将这些节点替换为 ForwardingNode,从而标识该位置正在进行扩容操作
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
// advance:true,代表当前线程需要接收任务,然后再执行迁移
// 如果为false,代表已经接收完任务
boolean advance = true;
// 标识迁移是否结束
boolean finishing = false;
for (int i = 0, bound = 0;;) {
Node<K,V> f;
int fh;
while (advance) {
int nextIndex, nextBound;
// 第一次不会进入这个if块
if (--i >= bound || finishing){
advance = false;
}
// 第一次也不会进入这里
// 判断 transferIndex 是否小于等于0,代表没有任务可领取,结束了
// 在线程领取任务会,会对 transferIndex 进行修改,修改为 transferIndex - stride
// 在任务都领取完之后,transferIndex 肯定是小于等于 0 的,代表没有迁移数据的任务可以领取
else if ((nextIndex = transferIndex) <= 0) {
// 扩容结束
i = -1;
advance = false;
}
// nextIndex = 32, stride = 16
// nextIndex > stride ? nextIndex - stride : 0 当前的nextIndex是否大于每个线程切割的
// 将 TRANSFERINDEX 从 nextIndex 变成16
else if (U.compareAndSwapInt(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ? nextIndex - stride : 0))) {
// 对 bound 赋值(16)
bound = nextBound;
// 对 i 赋值(31)
i = nextIndex - 1;
// 赋值,代表当前领取任务结束
// 该线程当前领取的任务是(16~31)
advance = false;
}
}
}
2.4. transfer() -- 判断扩容是否已经结束
i = 31:当前处理桶的索引位置
n = 32:旧数组长度
// 情况1:i < 0,分为以下两种情况:
// 1. 迁移已经结束:初始时i = 0,在第一个if块中自减为-1,但未进入最后一个if块分配迁移区间,此时i仍为-1
// 2. 当前线程没有获得任务
// 情况2:i >= n:表示迁移的索引位置不可能大于或等于数组长度,因此不会成立
// 情况3:i + n >= nextn:由于i的最大值是数组索引的最大值,因此此条件也不会成立
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
// 第一次到这里:必定是 false
// 如果再次到这里,并且最后一个扩容的线程也完成了扫描
if (finishing) {
nextTable = null;
table = nextTab;
// 更新 sizeCtl 为新数组长度的0.75倍(扩容阈值)
sizeCtl = (n << 1) - (n >>> 1);
return;
}
// 到这里意味着当前线程已不再需要执行扩容任务
// 因此,需要将并发扩容的线程计数减一
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
// 如果当前线程是最后一个扩容线程
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT){
return;
}
// 如果是最后一个线程,它需要重新扫描旧数据,确保所有数据都已迁移
finishing = advance = true;
// 将当前的i重新变成原数组的长度
// 重新进行一轮校验
i = n;
}
}
2.5. transfer() -- 迁移数据(链表)
Node<K,V> f:当前索引位置 i 处的节点,用于检查和处理当前桶的内容
ForwardingNode<K,V> fwd:标记当前桶位置已经被处理,ForwardingNode 的哈希值为 MOVED,用于标识该位置正在进行或已完成迁移
fh:当前节点的哈希值
ln:低位链表的头节点,存储哈希值与 n 的按位与结果为 0 的节点
hn:高位链表的头节点,存储哈希值与 n 的按位与结果为 n 的节点
runBit:用于确定节点在新数组中的位置,runBit 计算方式为fh & n
,结果为 0 或 n
lastRun:用于追踪最后一个相同 runBit 的节点,节点 lastRun 及其后续节点将直接迁移到新数组中
这里迁移链表的操作和 HashMap 差不多,都是将节点通过哈希值分为两条链表,不过这里利用了 LastRun 机制,通过记录最后一个 runBit 相同的节点,减少了链表节点的冗余计算和复制
// 如果当前索引 i 处没有数据,则不需要迁移
else if ((f = tabAt(tab, i)) == null){
// 使用 CAS 操作将 i 位置标记为fwd,fwd 的哈希值为 MOVED,表示该位置已处理
advance = casTabAt(tab, i, null, fwd);
}
// 如果当前位置的哈希值为 MOVED,说明数据已经迁移完成
else if ((fh = f.hash) == MOVED){
// 直接标记为已处理,主要用于最后一个扩容线程的检查
advance = true;
}else {
// 锁定当前节点以确保线程安全
synchronized (f) {
// 再次检查当前节点是否未改变
if (tabAt(tab, i) == f) {
// 低位链表节点
Node<K,V> ln = null;
// 高位链表节点
Node<K,V> hn = null;
// 如果当前节点的哈希值为正常值
if (fh >= 0) {
// 计算当前节点的 runBit(0或n)
int runBit = fh & n;
// 用于追踪最后一个相同 runBit 的节点
Node<K,V> lastRun = f;
// 遍历链表以确定最后一个 runBit 相同的节点
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
// 如果 b 值不同于 runBit,更新 runBit 和 lastRun
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
// 根据最后的runBit决定ln和hn的指向
if (runBit == 0) {
ln = lastRun;
} else {
hn = lastRun;
}
// 从链表头遍历到lastRun,将节点分配到ln或hn
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);
} else {
hn = new Node<K,V>(ph, pk, pv, hn);
}
}
// 将新数组的i位置设置为ln链表
setTabAt(nextTab, i, ln);
// 将新数组的i+n位置设置为hn链表
setTabAt(nextTab, i + n, hn);
// 将原数组的i位置标记为fwd,表示迁移完成
setTabAt(tab, i, fwd);
// 设置advance为true,以便继续处理下一个节点
advance = true;
}
}
}
}
2.6. helpTransfer() -- 协助扩容
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
Node<K,V>[] nextTab;
int sc;
// 1. 老数组不为 null
// 2. f 是 ForwardingNode 类型,用于指示正在进行的扩容。
// 3. 新数组不为 null(获取新数组并赋给 nextTab)。
if (tab != null && (f instanceof ForwardingNode) &&
(nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
// 获取扩容标识戳
int rs = resizeStamp(tab.length);
// 进入循环以协助扩容
// 1. nextTab仍是当前正在使用的新数组。
// 2. table仍是当前的老数组。
// 3. sizeCtl为负数,表示正在扩容。
while (nextTab == nextTable
&& table == tab
&& (sc = sizeCtl) < 0) {
// 检查扩容状态:
// 1. 扩容标识戳是否一致。
// 2. transferIndex是否小于0,表示所有扩容任务已被领取。
if ((sc >>> RESIZE_STAMP_SHIFT) != rs
|| sc == rs + 1
|| sc == rs + MAX_RESIZERS
|| transferIndex <= 0){
break;
}
// 如果还有未领取的任务,尝试通过CAS操作增加sizeCtl,表示协助扩容的线程数加一。
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
// 调用transfer方法实际执行扩容任务
transfer(tab, nextTab);
break;
}
}
// 返回新数组
return nextTab;
}
// 如果条件不满足,返回当前表
return table;
}
3. 细节讲解
在前面部分其实就是对源码进行解读,但阅读过程中也有一些问题需要深入研究的
3.1. 扩容戳
扩容戳的作用:
- 唯一标识:扩容戳为每次扩容操作生成一个唯一的标识符。它确保当前的扩容操作与其他可能的扩容操作区分开来
- 协助判断:在多线程环境下,当一个线程发现扩容正在进行时,它可以通过扩容戳判断是否需要协助当前的扩容操作
- 标识扩容状态:sizeCtl 的高位部分包含扩容戳,而低位部分用于表示当前参与扩容的线程数量
在实际使用中,扩容戳会左移 16 位,然后赋值给 sizeCtl
。这样,计算出的扩容戳实际上位于 sizeCtl
的高 16 位。高 16 位用于标识当前扩容操作的唯一性,而低 16 位用于记录参与扩容的线程数。
确保第 16 位为 1 的目的是为了保证 sizeCtl
变量为负数。由于扩容戳在使用时会左移 16 位,这确保了最高位始终为 1,从而使 sizeCtl
保持负数状态。
生成扩容戳代码:
// 扩容戳的计算通常是通过 resizeStamp 方法,该方法根据旧数组的长度计算一个标识符
// 这确保了在不同数组长度下,扩容戳是不同的
int rs = resizeStamp(n);
private static int RESIZE_STAMP_BITS = 16;
static final int resizeStamp(int n) {
return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}
// 获取当前数据转成二进制后的最高位的 1 前的 0 的个数
public static int numberOfLeadingZeros(int i) {
if (i == 0)
return 32;
int n = 1;
if (i >>> 16 == 0) { n += 16; i <<= 16; }
if (i >>> 24 == 0) { n += 8; i <<= 8; }
if (i >>> 28 == 0) { n += 4; i <<= 4; }
if (i >>> 30 == 0) { n += 2; i <<= 2; }
n -= i >>> 31;
return n;
}
例子
假如 n = 32
Integer.numberOfLeadingZeros(n)
- n = 32 => 10 0000
- 最高位的 "1" 位位于 第 6 位
- 因为 int 是32 位,前面有 26 个 "0"
- return 26
1 << (RESIZE_STAMP_BITS - 1)
- RESIZE_STAMP_BITS = 16
- 1 << (16 - 1) 即 1 << 15,结果是二进制的 1000 0000 0000 0000(即 32768)
位或运算 |
- 将 26(即二进制的 0000 0000 0001 1010)与 32768(即二进制的 1000 0000 0000 0000)进行位或操作
- 结果是 1000 0000 0001 1010,即十进制的 32794
3.2. 首次扩容为什么计数是 +2 而不是 +1
else if (U.compareAndSwapInt(this, SIZECTL, sc, rs + 2)){
// 初始化扩容
transfer(tab, null);
}
前面已经说过扩容戳的作用
- 高 16 位用于标识当前扩容操作。
- 低 16 位用于记录参与扩容的线程数。
了解这两个条件后,更容易理解扩容戳的处理过程。扩容戳最终会被赋值给 sizeCtl
。在 sizeCtl
中,负数表示正在进行扩容。通过将扩容戳左移 16 位,确保最高位为 1,此时低 16 位全部为 0。
低 16 位需要记录扩容线程数,因此通常需要加 1。不过,这里加的是 2,这是因为 sizeCtl
中的 -1 已经被使用,用于标识当前有线程准备进行扩容。如果仅加 1,可能会与标志位发生冲突。为了避免这种冲突,初始化记录扩容线程数时,需要加 2