ConcurrentHashMap
好长的名字,好难理解的map。
学习这个map,中间也遇到了很多问题,不过现在有了DeepSeek的帮助,我可以随意理解,所有不会的问题,都可以刨根问底,虽然确实不太必要,而且浪费时间。
没关系,整理一下,之后就是背八股了。
简单说一下我对于1.7之前的ConcurrentHashMap的理解,
一个map持有一个Segment数组,每个Segment元素持有一个node数组。进入的时候,先哈希运算,进入相应的Segment的元素,写操作的时候加锁,就这样。
重点说明1.8之后的ConcurrentHashMap,这里不再存在Segment元素,相反,整个map的锁的颗粒度已经达到了每一个node理解。
简单看一下,字段的解释放在后面。
构造函数
先是完整的构造函数:
arduino
Creates a new, empty map with an initial table size based on the given number of elements (initialCapacity), initial table density (loadFactor), and number of concurrently updating threads (concurrencyLevel).
形参:
initialCapacity -- the initial capacity. The implementation performs internal sizing to accommodate this many elements, given the specified load factor.
loadFactor -- the load factor (table density) for establishing the initial table size
concurrencyLevel -- the estimated number of concurrently updating threads. The implementation may use this value as a sizing hint.
抛出:
IllegalArgumentException -- if the initial capacity is negative or the load factor or concurrencyLevel are nonpositive
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (initialCapacity < concurrencyLevel) // Use at least as many bins
initialCapacity = concurrencyLevel; // as estimated threads
long size = (long)(1.0 + (long)initialCapacity / loadFactor);
int cap = (size >= (long)MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int)size);
this.sizeCtl = cap;
}
这里其实就是判断参数的合法性。虽然有这么多参数,其实就是为了控制sizeCtl,它是一个很重要的参数。它是大于等于cap
的最小2的n次幂。
arduino
tableSizeFor((int)size
/**
* Returns a power of two table size for the given desired capacity.
* See Hackers Delight, sec 3.2
*/
private static final int tableSizeFor(int c) {
int n = -1 >>> Integer.numberOfLeadingZeros(c - 1);
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
这个函数还有另外一种实现方式,但是我感觉没有这种实现方式优雅。实在是太优雅了。
cap * loadFactor也就得到了initialCapacity
之后进行数组操作的时候,会反复调用CAS改变它的值。如果它的值是一个正数,那么它就表示这个map的容量,如果它的值是一个负数,就表示,这个数组正在扩容。而且它的绝对值越大,扩容的线程就越多。后面会重点展示它的计算,它的计算充分展现了位运算的强大。
put方法
我们直接从put方法进入。
typescript
public V put(K key, V value) {
return putVal(key, value, false);
}
它委托给putVal来实现:
ini
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
//添加值不能为null,因为null无法进行hash运算。感觉加上null,要处理的麻烦比较多。
if (key == null || value == null) throw new NullPointerException();
//参见后面位运算的部分,这里使用spread,把hash值充分压缩到16位
int hash = spread(key.hashCode());
//binCount是链表的数量,如果是红黑树,这个值固定为2,普通链表则会在后面动态+1,如果太大,就会转变成红黑树
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh; K fk; V fv;
//初始化table,走完这里的逻辑,就可以直接回到开头,重新循环了
if (tab == null || (n = tab.length) == 0)
tab = initTable();
//空槽,直接设值,成功就break,失败,就重新循环。
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
break; // no lock when adding to empty bin
}
//扩容,
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
//逻辑可以走到这里,说明这个节点肯定存在了
//这里先提供看一下是不是onlyIfAbsent,是的话,就存在一种可能,不需要设锁,直接拿到值就跑路。
else if (onlyIfAbsent // check first node without acquiring lock
&& fh == hash
&& ((fk = f.key) == key || (fk != null && key.equals(fk)))
&& (fv = f.val) != null)
return fv;
//上锁,走你
else {
V oldVal = null;
//对象锁,这里因为ConcurrentHashMap的锁的颗粒度非常细,所以这样锁也没有什么问题。
//因为冲突的可能性不大,所以是轻量锁。
synchronized (f) {
//再判断一波,因为可能被锁阻塞了,那么这里f可能就已经被remove了。
//另外,能够进入这个逻辑,说明,要么是潘通节点,要么是树节点,
if (tabAt(tab, i) == f) {
if (fh >= 0) {
//能进来,至少是有一个bin的,就为1,另外,因为已经加锁了,所以后面的操作都是线程安全的
binCount = 1;
//++binCount,也就是对于bin计数
for (Node<K,V> e = f;; ++binCount) {
//找节点
K ek;
//先是hash判断,之后引用判断,之后是内容判断,和HashMap一样。
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);
break;
}
}
}
//树节点的fh好像是-2,为了保证和MOVE与普通的node区分。
else if (f instanceof TreeBin) {
Node<K,V> p;
//固定binCount为2,后面就不会触发treeifyBin
binCount = 2;
//红黑树查找,等我学好数据结构再回来理,如果有这样一天的话。
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
else if (f instanceof ReservationNode)
throw new IllegalStateException("Recursive update");
}
}
//这里就是前面的逻辑,但凡if (fh >= 0) 这里通过了,下面的逻辑就要走一波,不然没有break,就再循环一遍。
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
//oldVal 不等于null,就说明,不需要之后的逻辑了,addCount就不用走了,直接返回值就可以。
//因为如果存在oldVal,就说明只是一次无关紧要的修改,表的容量没有变大
if (oldVal != null)
return oldVal;
break;
}
}
}
//这里会根据binCount进行判断,逻辑很复杂。
addCount(1L, binCount);
return null;
}
上面基本上就是全部了。
addCount
简单进入这个函数的逻辑:
scss
/**
* Adds to count, and if table is too small and not already
* resizing, initiates transfer.
* If already resizing, helps
* perform transfer if work is available. Rechecks occupancy
* after a transfer to see if another resize is already needed
* because resizings are lagging additions.
*
* @param x the count to add
* @param check if <0, don't check resize, if <= 1 only check if uncontended
*/
private final void addCount(long x, int check) {
CounterCell[] cs; long b, s;
//counterCells用来分段计数,如果还没有这个东西,
if ((cs = counterCells) != null ||
//就看能不能直接CAS给baseCount赋值,结束这段逻辑
//能够进入这段逻辑,要么存在cs。存在cs,就说明已经有着比较激烈的竞争了,所以就不需要CAS直接赋值baseCount了
//要么就是赋值baseCount失败
!U.compareAndSetLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell c; long v; int m;
//uncontended表示没有竞争
boolean uncontended = true;
//为什么需要在这里加上一个判断cs == null呢?为了避免后面出现空指针异常,因为很可能还没有cs,
if (cs == null || (m = cs.length - 1) < 0 ||
//如果c槽为空。c是一个线程相关的随机数,&m表示映射到m这段区间中。
(c = cs[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
//这里表示直接赋值失败,也就是存在竞争。
U.compareAndSetLong(c, CELLVALUE, v = c.value, v + x))) {
fullAddCount(x, uncontended);
return;
}
//一个简单的问题是,为什么这里check <= 1就直接return了,
//下面check >= 0还要继续走。
//因为,如果直接进入下面的逻辑,说明上面来得太轻松了,竞争不激烈,估计整个map也不大,那么对于扩容的判断就要多一些。
//反过来,如果进入上面的逻辑,说明已经存在竞争了,但是既然check<=1,说明map还可以用,多一事不如少一事。
if (check <= 1)
return;
//直接通过cs进行求和,走的是LongAddr的逻辑,为了下面的循环。
s = sumCount();
}
//check>=0,就说明存在需要扩表的可能。这里存在check=0的的情况,但是必要是前面的if没有进入,说明表很小,无冲突。
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
//while循环,因为这里存在并发问题,有可能你走到这里的时候,其他线程添加了节点导致需要扩表了。
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
//这一步的结束后面有。
int rs = resizeStamp(n) << RESIZE_STAMP_SHIFT;
if (sc < 0) {
//rs是一个标记值,它非常优雅,但是不好理解。它是一个最高位为1的数,换言之,一个负数。
//但是,这里我认为,我们应该超越正负的观念,只是认为这个数最高位标记为1,表示它和寻常的状态不一样罢了。
//transfer结束之后,sc--,所以一开始就直接rs+2,说明rs+1就是结束transfer的时刻。
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();
}
}
}
以上只是简单走完了构造一个ConcurrentHashMap和往里面put一个值的过程,但是,我们还有很多问题没有解决。
transfer方式究竟是如何运作的?
为什么会出现一个helptransfer的函数。
binCount是什么?又为什么存在?
check是什么,为什么存在多个判断条件?
uncontended是什么?
rs是什么?为什么选择这样一个数作为标记?
ConcurrentHashMap里面使用的位运算有多么巧妙?
ConcurrentHashMap.addCount() 方法详细解析
一开始询问了DeepSeek,它的回答如下。我稍微删减一下。
这是 ConcurrentHashMap 中最核心的方法之一,负责元素计数和扩容触发。让我完整解释这个方法的每个部分:
📊 方法签名和变量声明
arduino
private final void addCount(long x, int check) {
CounterCell[] cs; long b, s;
- x:要增加的计数值(通常为 1 或 -1)也就表示添加或者删减的节点的数量,在putVal中,为1,表示新增一个节点。
- check:扩容检查阈值,在putVal中,也就是binCount。
- cs:计数单元数组引用,这里就是一个LongAddr,以空间换时间,避免多线程冲突。
- b, s:临时存储 baseCount 和总和
🔢 第一部分:计数更新机制
1.1 快速路径尝试
ini
if ((cs = counterCells) != null ||
!U.compareAndSetLong(this, BASECOUNT, b = baseCount, s = b + x)) {
逻辑:
- 如果已经存在 CounterCell 数组,或者
- CAS 更新 baseCount 失败(说明有竞争)
1.2 慢速路径 - 分段计数
ini
CounterCell c; long v; int m;
boolean uncontended = true;
if (cs == null || (m = cs.length - 1) < 0 ||
(c = cs[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended = U.compareAndSetLong(c, CELLVALUE, v = c.value, v + x))) {
fullAddCount(x, uncontended);
return;
}
分段计数失败条件(任一满足即进入 fullAddCount):
cs == null
:CounterCell 数组未初始化(m = cs.length - 1) < 0
:数组长度为0(c = cs[ThreadLocalRandom.getProbe() & m]) == null
:当前线程对应的槽位为空!U.compareAndSetLong(c, CELLVALUE, v = c.value, v + x)
:CAS 更新对应槽位失败
反过来说,以上任何一个成功了,后面就不会进行判断,有了cs,就不会尝试CAS,这就是短路或的优雅。,
1.3 计数完成后的检查
ini
if (check <= 1)
return;
s = sumCount(); // 计算当前总元素数
check 参数的含义:
check <= 1
:不需要检查扩容(如替换现有值)check >= 0
:需要检查扩容(如新增元素)
🚀 第二部分:扩容检查与触发
2.1 扩容条件判断
ini
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
扩容触发条件:
s >= (long)(sc = sizeCtl)
:元素总数 ≥ 扩容阈值(tab = table) != null
:哈希表已初始化(n = tab.length) < MAXIMUM_CAPACITY
:未达到最大容量
2.2 扩容状态标识计算
ini
int rs = resizeStamp(n) << RESIZE_STAMP_SHIFT;
resizeStamp(n)
:生成与当前容量相关的唯一标识- 左移
RESIZE_STAMP_SHIFT
(16位)得到基础状态值
这里是位运算,提高效率,只需要理解resizeStamp(n)
是一个唯一标识就足够了。左移16位,为的就是空余来低16位用于添加线程。
2.3 扩容中状态处理(已有扩容在进行)
ini
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);
}
不能协助扩容的条件:
sc == rs + MAX_RESIZERS
:协助线程数已达上限sc == rs + 1
:扩容即将完成(nt = nextTable) == null
:新表未初始化transferIndex <= 0
:所有迁移任务已完成
协助扩容 :通过 CAS 将 sizeCtl + 1
,然后调用 transfer
参与迁移
2.4 发起新扩容
kotlin
else if (U.compareAndSetInt(this, SIZECTL, sc, rs + 2))
transfer(tab, null);
第一个发起扩容的线程:
- 通过 CAS 将
sizeCtl
设置为rs + 2
- 调用
transfer(tab, null)
开始迁移,第二个参数为 null 表示需要初始化新表
2.5 更新计数并重新检查
ini
s = sumCount();
在循环末尾重新计算总元素数,因为迁移过程中可能有其他线程在操作
🎯 关键设计思想详解
1. 分段计数(CounterCell)机制
问题:高并发下频繁更新 baseCount 会成为性能瓶颈
解决方案:
scss
// 类似于 LongAdder 的分段计数
+----------+ +------------+------------+------------+
| baseCount| | CounterCell| CounterCell| CounterCell|
| (基础值) | | (线程1) | (线程2) | (线程3) |
+----------+ +------------+------------+------------+
最终计数 = baseCount + ∑CounterCell[i].value
2. sizeCtl 状态机
sizeCtl
的不同值表示不同状态:
值范围 | 含义 |
---|---|
> 0 |
扩容阈值(容量 × 负载因子) |
-1 |
表正在初始化 |
<-1 |
高16位:扩容标识,低16位:参与扩容线程数+1 |
举例:
- 容量=16,阈值=12:
sizeCtl = 12
- 扩容中(容量16→32,3个线程):
sizeCtl = rs + 3
3. 扩容戳(resizeStamp)机制
arduino
static final int resizeStamp(int n) {
return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}
作用:为每次扩容生成唯一标识,防止不同容量的扩容相互干扰
🔄 完整工作流程示例
场景:插入新元素触发扩容
- 线程A 插入元素,调用
addCount(1, binCount)
- 尝试快速计数:CAS 更新 baseCount 失败(有竞争)
- 分段计数:找到对应 CounterCell,CAS 更新成功
- 检查扩容:计算总元素数 s=13,阈值=12
- 发起扩容 :线程A成功 CAS 设置
sizeCtl = rs + 2
- 开始迁移 :调用
transfer(tab, null)
开始数据迁移 - 线程B 插入时发现扩容中,协助迁移
- 完成扩容:所有线程协作完成,更新 table 引用和 sizeCtl
💡 性能优化亮点
1. 无锁计数
- 通过 CAS 和分段计数避免锁竞争
- 读多写少时性能接近 volatile 变量
2. 协作式扩容
- 多个线程并行迁移,加速扩容过程
- 动态调整参与线程数
3. 精确状态控制
- 通过 sizeCtl 精确控制扩容状态
- 防止过度扩容或协助线程过多
4. 渐进式检查
- 只在真正需要时检查扩容
- 减少不必要的计算开销
这个方法是 ConcurrentHashMap 高并发设计的精华体现,通过巧妙的分段计数和协作式扩容,在保证线程安全的同时实现了极高的并发性能!
Uncontende与check解析
🔍 uncontended
的作用
基本含义
uncontended
表示 "无竞争" ,用于标记在 CounterCell 更新过程中是否遇到了竞争。
在代码中的使用
ini
boolean uncontended = true; // 初始假设无竞争
if (cs == null || (m = cs.length - 1) < 0 ||
(c = cs[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended = U.compareAndSetLong(c, CELLVALUE, v = c.value, v + x))) {
// 如果进入这里,说明有竞争或初始化问题
fullAddCount(x, uncontended);
return;
}
uncontended
的三种状态
-
true
:真正无竞争(理想情况)- CounterCell 数组存在且长度正常
- 当前线程对应的槽位存在
- CAS 更新 CounterCell 成功
-
false
:有竞争- CounterCell 数组和槽位都正常
- 但 CAS 更新 CounterCell 失败(其他线程在同时更新)
-
初始化问题:
- CounterCell 数组为 null 或长度为0
- 当前线程对应的槽位为 null
uncontended
在 fullAddCount
中的作用
scss
fullAddCount(x, uncontended);
uncontended
参数告诉 fullAddCount
:
true
:可能是初始化问题,需要初始化 CounterCell 数组或槽位false
:明确有竞争,需要重试或扩容 CounterCell 数组
🎯 check
参数的巧妙设计
check
参数的含义
check
参数控制扩容检查的粒度,在不同操作中传递不同的值:
操作类型 | check 值 | 含义 |
---|---|---|
空桶插入 | 0 | 最低优先级的扩容检查 |
链表插入 | 1-7 | 中等优先级的扩容检查 |
树节点插入 | 2 | 标记为树节点 |
替换现有值 | < 0 | 不检查扩容 |
看似"矛盾"的逻辑解析
kotlin
// 第一处:CounterCell 更新成功后
if (check <= 1)
return;
// 第二处:后续逻辑
if (check >= 0) {
// 扩容检查逻辑
}
这实际上是一个精心设计的"过滤网" :
过滤流程分析
sql
check 值分布
←--- 负值 --- 0 --- 1 --- 2+ ---→
不检查 ↓ ↓ ↓ 检查
│ │ │
第一处过滤: │ │ × (check<=1 时返回)
│ │
第二处过滤: × (check<0 跳过) ✓ (进入扩容检查)
实际场景分析
场景1:check < 0
(如 replace 操作)
arduino
// 第一处:check <= 1 ? 是,但不会执行到这里
// 因为 replace 操作通常直接返回,不调用 addCount
// 第二处:check >= 0 ? 否,跳过扩容检查
结果:不检查扩容
场景2:check = 0
(空桶插入)
kotlin
// 第一处:check <= 1 ? 是,但注意逻辑!
if (check <= 1)
return; // 这里会返回吗?不一定!
关键 :这个 return
只在 CounterCell 路径 中生效!
kotlin
if ((cs = counterCells) != null || !U.compareAndSetLong(...)) {
// CounterCell 路径
if (check <= 1)
return; // 在这里,check=0 会返回
}
// 如果是 baseCount CAS 成功,会继续执行
场景3:check = 1
(短链表插入)
kotlin
// 第一处:check <= 1 ? 是
if (check <= 1)
return; // 在 CounterCell 路径中返回
设计思想:短链表的插入竞争较小,不急于扩容检查
场景4:check >= 2
(长链表或树节点)
arduino
// 第一处:check <= 1 ? 否,继续执行
// 第二处:check >= 0 ? 是,进行扩容检查
设计思想:冲突较多的桶需要及时检查扩容
🎪 现实世界比喻
医院急诊分诊系统
把 ConcurrentHashMap 比作医院 ,check
参数就像病人病情的紧急程度:
分诊规则:
check < 0
:健康体检(不需要急诊处理)check = 0
:轻微擦伤(低优先级)check = 1
:普通感冒(中等优先级)check ≥ 2
:重伤急诊(高优先级)
处理流程:
scss
病人到达 → 分诊台评估紧急程度(check)
↓
[第一关:CounterCell 通道]
↓
轻微病人(check≤1) → 简单处理后就回家(return)
↓
重伤病人(check≥2) → 进入急诊室(扩容检查)
↓
[第二关:baseCount 通道]
↓
所有病人(check≥0) → 都进急诊室检查
uncontended
就像医疗资源竞争
true
:医院空闲,直接就诊false
:医院繁忙,需要排队或转院
💡 设计哲学深度解析
性能与及时性的平衡
为什么 check <= 1
在 CounterCell 路径中返回?
kotlin
// 高并发场景下的优化
if (cs = counterCells) != null || !U.compareAndSetLong(...)) {
// 进入这里说明:要么已有 CounterCell,要么 baseCount CAS 失败
// 这两种情况都表明系统处于高并发状态
if (check <= 1) // 对于低冲突的桶
return; // 不进行扩容检查,避免在高并发时增加负担
}
为什么 check >= 0
在 baseCount 路径中检查?
scss
// 低并发场景下的保证
// 如果能直接 CAS 更新 baseCount 成功,说明并发度低
if (check >= 0) {
// 即使是最低优先级的检查也执行,因为系统有充足资源
}
智能的扩容触发策略
这种设计实现了 "自适应扩容检查" :
- 高并发时:只对高冲突的桶进行扩容检查
- 低并发时:对所有插入都进行扩容检查
- 无竞争时:最全面的扩容保障
实际效果
- 避免过度扩容:在高并发时减少不必要的检查
- 保证及时扩容:在真正需要时及时触发
- 资源优化:根据系统负载动态调整策略
🔧 代码执行路径总结
路径1:baseCount CAS 成功(低并发)
scss
addCount(1L, binCount)
↓
baseCount CAS 成功
↓
if (check >= 0) → 总是检查扩容
↓
可能触发扩容
路径2:CounterCell 更新成功(高并发)
scss
addCount(1L, binCount)
↓
baseCount CAS 失败 → 进入 CounterCell 路径
↓
CounterCell CAS 成功
↓
if (check <= 1) → 低冲突桶直接返回
↓
if (check >= 2) → 高冲突桶检查扩容
路径3:CounterCell 初始化/竞争(高并发激烈)
scss
addCount(1L, binCount)
↓
baseCount CAS 失败 → 进入 CounterCell 路径
↓
CounterCell CAS 失败或需要初始化
↓
fullAddCount(x, uncontended) → 复杂处理
↓
直接返回(不检查扩容,先解决计数问题)
这种精妙的设计让 ConcurrentHashMap 能够在各种并发场景下都保持优异的性能!
rs是什么?
rs
(resize stamp)是扩容戳,用于唯一标识一次扩容操作:
ini
int rs = resizeStamp(n) << RESIZE_STAMP_SHIFT;
resizeStamp 计算方法
arduino
static final int resizeStamp(int n) {
return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}
rs 的组成:
- 高16位:扩容标识(基于容量n的前导零个数)
- 低16位:用于记录扩容线程数
举例(假设 n=16):
Integer.numberOfLeadingZeros(16)
= 27(32-5,因为16=2⁴)1 << (16 - 1)
= 0x8000resizeStamp(16)
= 27 | 0x8000 = 0x8000 | 0x001B = 0x801B- 左移16位:
rs = 0x801B0000
🎯 为什么需要这些判断条件
当 sc < 0
时,表示扩容正在进行中,当前线程想要协助扩容。但这些判断确保我们只在"合适的时候"协助:
判断条件逐行解析:
ini
if (sc == rs + MAX_RESIZERS || sc == rs + 1 ||
(nt = nextTable) == null || transferIndex <= 0)
break;
🔒 四个退出条件的深层原因
1. sc == rs + MAX_RESIZERS
- 线程数已达上限
ini
if (sc == rs + MAX_RESIZERS)
含义:扩容协助线程数已经达到最大值
数学原理:
rs
= 扩容基础戳(如 0x801B0000)MAX_RESIZERS
= 最大协助线程数(通常是 2¹⁶-1)sc == rs + MAX_RESIZERS
表示:(低16位) = MAX_RESIZERS
为什么需要:
- 防止过多线程参与扩容,造成资源浪费
- 避免线程间过度竞争,反而降低性能
举例:
ini
rs = 0x801B0000
MAX_RESIZERS = 0x0000FFFF
rs + MAX_RESIZERS = 0x801BFFFF
如果 sc == 0x801BFFFF,说明已经有65535个线程在协助了
2. sc == rs + 1
- 扩容即将完成
ini
if (sc == rs + 1)
含义:扩容工作马上结束,不需要更多协助
数学原理:
- 初始扩容状态:
sc = rs + 2
(1个主导线程 + 1) - 每个协助线程:
sc = sc + 1
- 当
sc == rs + 1
时,表示所有线程都快退出了
为什么需要:
- 避免在扩容收尾阶段加入,造成混乱
- 防止新线程刚开始工作,扩容就结束了
3. (nt = nextTable) == null
- 新表未准备好
ini
if ((nt = nextTable) == null)
含义:新的哈希表还没有创建完成
为什么需要:
- 如果
nextTable
为 null,说明扩容还没真正开始 - 或者扩容出现了异常情况
- 此时协助没有意义,应该退出等待
4. transferIndex <= 0
- 任务已分配完
scss
if (transferIndex <= 0)
含义:所有迁移任务都已经被分配完毕
背景知识:
transferIndex
:从后往前分配迁移任务的范围指针- 初始值 = 旧表长度 n
- 每个线程领取一段任务:
[transferIndex-stride, transferIndex)
为什么需要:
- 如果
transferIndex <= 0
,说明所有桶都已经分配了迁移任务 - 没有剩余工作需要协助,应该退出
✅ 通过检查后的协助逻辑
scss
if (U.compareAndSetInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
逻辑:
- CAS 增加协助线程计数 :
sc → sc + 1
- 参与实际迁移工作 :调用
transfer(tab, nt)
CAS 的重要性:确保线程安全地增加计数器,防止并发问题
🎪 形象比喻:建筑工地协作
把扩容比作建筑工地搬迁:
场景设定
- 旧工地 = 当前哈希表
- 新工地 = nextTable
- 工头 = 主导扩容的线程
- 工人 = 协助扩容的线程
- 任务板 = transferIndex
- 工号牌 = sizeCtl 的低16位
四个退出条件的现实对应
-
sc == rs + MAX_RESIZERS
👥- "工地已经招了65535个工人,人满为患,别再来了!"
-
sc == rs + 1
🏁- "搬迁工作已经完成99%,现在来帮忙反而添乱"
-
(nt = nextTable) == null
🏗️- "新工地还没建好呢,来了也没活干"
-
transferIndex <= 0
📋- "任务板上已经没有待分配的工作了"
合格的协助流程
yaml
工人A来到工地 → 查看情况:
✅ 工地还在搬迁中(sc < 0)
✅ 工人数量没满(不是 rs + MAX_RESIZERS)
✅ 不是收尾阶段(不是 rs + 1)
✅ 新工地已建好(nextTable != null)
✅ 还有未分配的任务(transferIndex > 0)
→ 去管理处登记领工号(CAS: sc → sc+1)
→ 开始实际搬迁工作(transfer)
💡 设计哲学总结
"恰到好处"的协作
- 不早不晚:在需要的时候协助,不需要的时候退出
- 不多不少:控制协助线程数量,避免过度竞争
- 不慌不忙:有序参与,避免状态混乱
状态机的精确控制
通过 sizeCtl
这一个变量,同时维护:
- 扩容标识(高16位):确保是同一轮扩容
- 线程计数(低16位):精确控制参与线程数
- 整体状态:正数=阈值,负数=扩容中
自我保护机制
这些判断条件形成了完整的自我保护:
- 资源保护:防止线程爆炸
- 状态保护:防止错误参与
- 进度保护:防止干扰收尾工作
这种精妙的设计让 ConcurrentHashMap 在高并发扩容时既能充分利用多核优势,又能避免过度竞争和资源浪费!
sc = rs + 2
的原因
这确实不是巧合,而是 精心设计的数学关系!让我深入解释这个设计的巧妙之处。
🔢 数字关系的数学原理
rs
的结构分析
bash
int rs = resizeStamp(n) << RESIZE_STAMP_SHIFT; // RESIZE_STAMP_SHIFT = 16
rs
的特点:
- 高16位包含扩容标识信息
- 低16位全为0(因为左移了16位)
举例(假设 n=16):
scss
resizeStamp(16) = 0x801B // 高16位: 0x801B, 低16位: 0x0000
rs = 0x801B << 16 = 0x801B0000 // 高16位: 0x801B, 低16位: 0x0000
🎯 rs + 2
的深层含义
状态编码设计
在 ConcurrentHashMap 中,sizeCtl
在扩容期间被设计为:
ini
sizeCtl = rs + (1 + 协助线程数)
具体分解:
rs + 1
= 扩容完成或即将完成的状态rs + 2
= 初始状态(1个主导线程 + 1)rs + 3
= 有2个线程在扩容rs + 4
= 有3个线程在扩容- ...
rs + MAX_RESIZERS
= 达到最大线程数
为什么是 +2
而不是 +1
?
kotlin
// 第一个线程发起扩容
else if (U.compareAndSetInt(this, SIZECTL, sc, rs + 2))
transfer(tab, null);
关键洞察 :+2
表示 "1个主导线程 + 1个计数基准"
数学关系:
- 实际扩容线程数 =
(sizeCtl - rs) - 1
- 当
sizeCtl = rs + 2
时:实际线程数 =(rs + 2 - rs) - 1 = 1
🔍 状态转换的完整生命周期
扩容状态演进
ini
正常状态: sizeCtl = 12 (阈值)
↓
开始扩容: sizeCtl = rs + 2 (1个线程)
↓
线程A协助: sizeCtl = rs + 3 (2个线程)
↓
线程B协助: sizeCtl = rs + 4 (3个线程)
↓
...
↓
完成前: sizeCtl = rs + 1 (0个线程,即将完成)
↓
完成: sizeCtl = 24 (新阈值)
代码中的状态检查
ini
if (sc == rs + MAX_RESIZERS) // 线程数达上限
if (sc == rs + 1) // 扩容即将完成
💡 设计精妙之处
1. 统一的数学框架
scss
// 所有状态检查都基于同一个数学关系
线程数 = (sc - rs) - 1
// 因此可以统一判断:
if (线程数 >= MAX_RESIZERS) → if (sc == rs + MAX_RESIZERS)
if (线程数 == 0) → if (sc == rs + 1)
2. 避免特殊值冲突
如果使用 rs + 1
作为初始值:
- 完成状态就无法用
rs + 1
表示 - 需要另外定义特殊值,增加复杂度
3. 自然的计数增长
kotlin
// 线程协助时的操作非常自然
if (U.compareAndSetInt(this, SIZECTL, sc, sc + 1))
- 不需要特殊处理初始状态
- 每个线程简单地
+1
,数学关系自动保持
4. 清晰的状态边界
rs + 1
:明确的完成边界rs + 2
:明确的开始边界- 中间状态:线性的线程计数
🎪 现实世界比喻
团队项目的版本号管理
把扩容比作一个软件开发项目:
ini
版本号 = 项目代号 + 参与人数
rs = "CHM-2024" (项目基础代号)
rs + 1 = "CHM-2024-FINAL" // 项目完成,准备发布
rs + 2 = "CHM-2024-TEAM1" // 项目启动,1个核心开发者
rs + 3 = "CHM-2024-TEAM2" // 第2个开发者加入
...
rs + MAX = "CHM-2024-TEAMMAX" // 团队满员
工作流程:
- 项目经理 (主导线程)创建项目:
rs + 2
- 开发者A 加入:检查是否
当前版本 == FINAL
?不是 →版本+1
- 开发者B 加入:检查是否
当前版本 == TEAMMAX
?不是 →版本+1
- 项目完成 :版本回到
FINAL
状态
🔧 实际代码验证
让我们验证这个设计的一致性:
kotlin
// 初始状态:1个线程在扩容
sizeCtl = rs + 2
// 线程协助成功
U.compareAndSetInt(this, SIZECTL, sc, sc + 1) // sc = rs + 2 → rs + 3
// 现在有2个线程:(rs + 3 - rs) - 1 = 2
// 线程退出时
U.compareAndSetInt(this, SIZECTL, sc, sc - 1) // sc = rs + 3 → rs + 2
// 回到1个线程
// 最后一个线程退出
U.compareAndSetInt(this, SIZECTL, sc, sc - 1) // sc = rs + 2 → rs + 1
// 现在有0个线程,扩容完成
🏆 总结:这不是巧合,而是精妙设计
sc = rs + 2
的选择体现了 "简单即美" 的设计哲学:
- 数学优雅:用简单的算术关系表达复杂的状态机
- 操作一致 :所有状态转换都通过简单的
+1
/-1
完成 - 边界清晰:开始和结束状态有明确的数学定义
- 并发安全:CAS 操作天然适合这种线性增长
这种设计让复杂的多线程协作变得可预测、可推理、可维护,正是 ConcurrentHashMap 高并发能力的基石之一!
所以,这不是"巧合",而是计算机科学和软件工程的精妙结合!
binCount 的作用详解
在 putVal
方法中,binCount
是一个非常重要的变量,它有多重作用,让我详细解释:
📋 binCount 的基本含义
binCount
表示当前桶的节点数量(在插入新节点之前),具体含义因桶类型而异:
- 链表:链表的长度(从0开始计数)
- 红黑树:固定值 2(作为标记值)
- 空桶:值为 0
🎯 binCount 的三大核心作用
1. 树化判断依据(最主要作用)
scss
// 在链表遍历插入时
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 树化判断:链表长度达到阈值
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// ... 其他逻辑
}
树化条件:
binCount >= TREEIFY_THRESHOLD - 1
(即链表长度 ≥ 8)- 为什么要
-1
?因为 binCount 从0开始计数
举例:
css
链表:A → B → C → D → E → F → G → H
binCount 变化:0→1→2→3→4→5→6→7
当 binCount=7 时,插入第9个节点,触发树化
2. 扩容检查的触发条件
scss
// 在 putVal 方法最后
addCount(1L, binCount);
binCount
作为第二个参数传递给 addCount
,控制扩容检查的粒度:
binCount 值 | 含义 | 是否检查扩容 |
---|---|---|
0 | 空桶插入 | 是 |
1-7 | 短链表插入 | 是 |
≥ 8 | 长链表或树节点 | 是 |
设计思想:无论桶的长度如何,每次插入都会检查是否需要扩容,但检查的紧迫性不同。
3. 区分桶类型的标记
java
else if (f instanceof TreeBin) {
// 红黑树处理
binCount = 2; // 固定标记值
}
对于红黑树,binCount
被固定设为 2,这有几个目的:
- 标记作用:表示这是树节点,不是链表
- 避免误树化:值2 < 8,不会重复触发树化
- 兼容性:保持方法调用的一致性
🔍 实际代码流程分析
场景1:空桶插入
scss
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// 非空桶处理
}
// 调用 addCount
addCount(1L, 0); // binCount=0 表示空桶插入
场景2:链表插入
ini
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // 树化检查
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break; // 键已存在
p = e;
}
// binCount 是最终的链表长度
addCount(1L, binCount);
场景3:树节点插入
ini
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;
}
}
addCount(1L, binCount); // binCount=2
🎪 形象比喻:超市收银台
把 ConcurrentHashMap 比作超市 ,桶比作收银台:
binCount 就是收银台排队人数
-
空收银台(binCount=0)
- 新顾客直接结账
- 通知经理:"现在有1个顾客了"
-
短队伍(binCount=1-7)
- 顾客排队等候
- 通知经理:"现在有X个顾客在排队"
-
长队伍(binCount=8)
- 经理看到:"这个收银台人太多了!"
- 决定:"升级为快速收银通道!"(链表→红黑树)
-
快速通道(binCount=2)
- 已经是快速通道了,标记为"特殊通道"
- 不再重复升级,但继续统计顾客数量
💡 设计哲学思考
为什么用 binCount 而不用实际长度?
-
性能优化:
- 遍历时自然计数,无需额外计算长度
- 避免对长链表的重复遍历
-
状态融合:
- 一个变量同时承载长度信息和类型标记
- 简化方法签名和逻辑判断
-
渐进式决策:
- 在插入过程中实时判断,避免事后分析
- 及时触发树化或扩容,防止性能恶化
binCount 的智能之处
- 动态感知:实时反映桶的负载情况
- 多重职责:既是计数器,也是决策依据
- 阈值触发:在关键时刻(长度=8)自动升级数据结构
这个精巧的设计让 ConcurrentHashMap 能够自适应地调整内部结构,在保持高性能的同时优雅地处理哈希冲突!
Transfer逻辑
ini
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
//stride,每个线程处理的桶的数量,根据NCPU计算
//但是即使CPU很多,每个线程也应该有一个保底的数量,不会太少,不至于少于16个。
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
//扩容,表的大小直接翻倍
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
nextTab = nt;
//Out of memery,把sizeCtl设置成最大数,所以就不会再触发transfer的逻辑了,即使触发了,也没有办法了。
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE;
return;
}
nextTable = nextTab;
transferIndex = n;
}
int nextn = nextTab.length;
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
//看似初始值i=0,但其实i是倒着走的,它的值表示当前正在工作的槽, i = nextIndex - 1;
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
//任务分配逻辑,不管怎样,不管哪一个if,反正至少要进入到其中一个if的逻辑中,advance才能变成false。
while (advance) {
int nextIndex, nextBound;
//任务还能继续干,淦!
//这里为什么多出来一个finishing呢?因为后来可以回出现循环检查的情况,
//进入这个逻辑,直接advance=false,就不需要后面的任务判断,然后每一个都直接走出while逻辑,--i
//会直接走到 already processed的部分。
if (--i >= bound || finishing)
advance = false;
//干完了,应该就是0
//transferIndex是一个成员变量
//初始化的线程会把它设置成n,
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
//nextIndex,开始处理的index+1
//nextBound,结束处理的位置。
//思考一下,为什么CAS的时候使用了nextIndex,而不是直接使用transferIndex?
else if (U.compareAndSetInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
//虽然我不能理解为什么需要这样写?
//i >= n, 其实就是在完成所有任务的时候, i = n; // recheck before commit,再重新检查一下
//这里或许会出现并发问题,检查一下,看有没有遗漏的槽。
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) {
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1);
return;
}
if (U.compareAndSetInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true;
i = n; // recheck before commit
}
}
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
//上锁,这才是正式干货的逻辑。
synchronized (f) {
//进来再判断一下,双重检查,防止并发问题。
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
//链表的迁移
if (fh >= 0) {
int runBit = fh & n;
Node<K,V> lastRun = f;
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.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);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
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;
}
else if (f instanceof ReservationNode)
throw new IllegalStateException("Recursive update");
}
}
}
}
}
ConcurrentHashMap.transfer() 方法深度解析与可视化
这是 ConcurrentHashMap 中最核心、最复杂的扩容迁移方法。让我通过文字 + 可视化图表的方式详细解析。
📊 方法整体架构
方法三个阶段:
- 初始化阶段:计算步长、创建新数组
- 任务分配阶段:多线程协作分配迁移任务
- 数据迁移阶段:实际移动节点到新数组
🔧 第一阶段:初始化准备
1.1 步长计算(Stride Calculation)
ini
int n = tab.length, stride;
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE;
可视化计算过程:
ini
假设:n=64, NCPU=4, MIN_TRANSFER_STRIDE=16
计算步骤:
n >>> 3 = 64 / 8 = 8
8 / NCPU = 8 / 4 = 2
2 < 16 → stride = 16
最终:每个线程处理16个桶
1.2 新表初始化
ini
if (nextTab == null) { // 第一个发起扩容的线程
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1]; // 容量翻倍
nextTab = nt;
nextTable = nextTab;
transferIndex = n; // 从后往前迁移,起始位置=旧表长度
}
内存状态变化:
scss
扩容前:
table → [0][1][2]...[63] (长度64)
nextTable → null
扩容后:
table → [0][1][2]...[63] (长度64)
nextTable → [0][1][2]...[127] (长度128)
transferIndex = 64
🎯 第二阶段:任务分配机制
2.1 核心循环结构
ini
boolean advance = true;
boolean finishing = false;
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
while (advance) {
// 任务分配逻辑
}
// 迁移逻辑
}
2.2 任务分配状态机
ini
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing) // 情况1:当前段内继续处理
advance = false;
else if ((nextIndex = transferIndex) <= 0) { // 情况2:所有任务已完成
i = -1;
advance = false;
}
else if (U.compareAndSetInt // 情况3:获取新任务段
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ? nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
任务分配可视化:
css
初始状态:n=64, stride=16, transferIndex=64
时间线:
┌─────────┬────────────┬──────────┬─────────┬─────────┐
│ 线程 │ 操作顺序 │ 获取区间 │ bound │ i │
├─────────┼────────────┼──────────┼─────────┼─────────┤
│ 线程A │ 第1步 │ [48,64) │ 48 │ 63 ││ 线程B │ 第2步 │ [32,48) │ 32 │ 47 │ │ 线程C │ 第3步 │ [16,32) │ 16 │ 31 ││ 线程D │ 第4步 │ [0,16) │ 0 │ 15 │└─────────┴────────────┴──────────┴─────────┴─────────┘
图形化表示任务分配:
less
旧数组索引: 0 1 2 ... 15 16 17 ... 31 32 33 ... 47 48 49 ... 63
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
任务分配: │线程D │ 线程C │ 线程B │ 线程A │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
🚀 第三阶段:数据迁移逻辑
3.1 迁移完成检查
ini
if (i < 0 || i >= n || i + n >= nextn) {
// 扩容完成处理
if (finishing) {
nextTable = null;
table = nextTab; // 切换主表引用
sizeCtl = (n << 1) - (n >>> 1); // 新阈值 = 1.5n
return;
}
// 当前线程退出扩容
if (U.compareAndSetInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return; // 不是最后一个线程
finishing = advance = true; // 最后一个线程,重新扫描
i = n; // 从最后开始检查
}
}
线程退出可视化:
ini
初始:sizeCtl = rs + 4 (4个线程参与)
线程A完成:sizeCtl = rs + 3
线程B完成:sizeCtl = rs + 2
线程C完成:sizeCtl = rs + 1
线程D(最后一个)完成:切换table引用,清理状态
3.2 空桶处理
ini
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd); // 直接标记为ForwardingNode
空桶迁移过程:
css
迁移前:
tab[i] = null
迁移后:
tab[i] = ForwardingNode → nextTable
3.3 链表迁移(核心算法)
ini
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
// 1. 寻找最后一段连续相同位的节点
int runBit = fh & n;
Node<K,V> lastRun = f;
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
// 2. 构建两个链表
// ... 详细逻辑
}
}
}
链表迁移优化算法图解:
步骤1:寻找 lastRun
less
原始链表:A → B → C → D → E → F → G → H
hash & n 计算:
A:0, B:0, C:0, D:1, E:1, F:1, G:1, H:1
↑
lastRun (从这里开始都是连续的1)
结果:lastRun = D
步骤2:链表拆分优化
css
第一次遍历(lastRun之前):
低位链表:C → B → A (头插法)
高位链表:不需要创建新节点
第二次遍历(lastRun及之后):
直接复用:D → E → F → G → H
最终:
低位链表:C → B → A
高位链表:D → E → F → G → H
为什么这样优化?
- 减少对象创建:lastRun 之后的节点直接复用
- 保持顺序:虽然前半部分头插法,但并发场景下顺序本身就不保证
步骤3:放置到新表
scss
setTabAt(nextTab, i, ln); // 原位置:i
setTabAt(nextTab, i + n, hn); // 新位置:i + 旧容量
setTabAt(tab, i, fwd); // 标记为已迁移
位置计算原理:
bash
因为新容量是2的幂,hash & (2n-1) 的结果:
- 等于 hash & (n-1) → 留在位置 i
- 等于 hash & (n-1) + n → 移动到位置 i + n
3.4 红黑树迁移
ini
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) {
// 根据 (h & n) 分为低位和高位链表
}
// 判断是否需要树化或链化
ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) : new TreeBin<K,V>(lo);
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) : new TreeBin<K,V>(hi);
}
树迁移决策逻辑:
perl
节点数判断:
lc ≤ 6 → 链化低位链表
hc ≤ 6 → 链化高位链表
否则 → 树化
特殊情况:如果一边为0,复用原树
🎪 完整迁移过程可视化示例
场景:容量16→32的扩容
初始状态:
ini
旧表 (n=16):
索引: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
[A] [ ] [C] [D] [E] [F] [ ] [G] [H] [I] [J] [K] [L] [M] [N] [O]
↓
B→C
新表 (nextn=32): [空表]
transferIndex = 16
迁移过程:
第1步:线程A获取任务 [12,16)
makefile
处理桶15: O → 新表位置15或31
处理桶14: N → 新表位置14或30
处理桶13: M → 新表位置13或29
处理桶12: L → 新表位置12或28
第2步:线程B获取任务 [8,12)
makefile
处理桶11: K → 新表位置11或27
处理桶10: J → 新表位置10或26
处理桶9: I → 新表位置9或25
处理桶8: H → 新表位置8或24
第3步:处理链表桶2
ini
桶2: B → C
计算:
B.hash & 16 = 0 → 低位
C.hash & 16 = 0 → 低位
结果:
新表[2] = B → C
标记旧表[2] = ForwardingNode
最终状态:
makefile
旧表: 所有桶都是ForwardingNode
新表: 数据均匀分布在32个桶中
table引用指向新表
💡 设计亮点总结
1. 并行迁移
- 多线程同时处理不同区段
- 无锁任务分配,避免竞争
2. 优化算法
- lastRun优化减少对象创建
- 链表保持部分顺序
3. 状态安全
- 精确的线程计数控制
- 安全的表切换机制
4. 渐进式
- 迁移过程中可继续读写
- ForwardingNode引导访问新表
5. 资源控制
- 根据CPU数量动态调整步长
- 防止过多线程参与造成竞争
这个精妙的迁移算法是 ConcurrentHashMap 高并发性能的基石,通过巧妙的并行设计和状态管理,实现了真正无阻塞的扩容!
辅助机制
同步扩容
在putVal()
方法中有这样一行:
ini
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
这是 ConcurrentHashMap 中的 协助扩容方法(helpTransfer),当线程在操作时发现当前桶正在迁移,会调用此方法来协助完成扩容工作。
前因是什么呢?为什么会出现这样一种情况呢?
当然是,已经有了线程正在执行迁移的逻辑,迁移的时候会对node上锁,迁移结束之后,会把node设置成一个特殊的fwd
scss
setTabAt(tab, i, fwd);
之后其他节点过来的时候,就会进入这个if的逻辑。
方法作用
swift
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
- 目的:让当前线程协助完成哈希表的扩容迁移工作
- 触发条件 :当操作过程中遇到
ForwardingNode
(转发节点)
代码逻辑分析
1. 条件检查
yaml
if (tab != null && (f instanceof ForwardingNode) &&
(nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
- 检查表不为空
- 当前节点是
ForwardingNode
(表示该桶正在迁移) - 获取新表
nextTable
不为空
一般来说,都会进入这个逻辑,想不到什么时候不为true。
2. 计算扩容标识
ini
int rs = resizeStamp(tab.length) << RESIZE_STAMP_SHIFT;
resizeStamp(tab.length)
:生成与当前容量相关的扩容戳- 左移
RESIZE_STAMP_SHIFT
(16位)得到扩容状态的基础值
arduino
/**
* Returns the stamp bits for resizing a table of size n.
* Must be negative when shifted left by RESIZE_STAMP_SHIFT.
*/
static final int resizeStamp(int n) {
return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}
逻辑也挺简单的,最低位是前导零,接着在第十六位上一个1,如果再右移16位,也就是32位上一个1,又是优雅的位运算。
3. 循环协助迁移
ini
while (nextTab == nextTable && table == tab &&
(sc = sizeCtl) < 0) {
循环条件:
- 新表未改变
- 旧表未改变
sizeCtl < 0
(表示扩容仍在进行中)
4. 退出条件检查
ini
if (sc == rs + MAX_RESIZERS || sc == rs + 1 ||
transferIndex <= 0)
break;
退出条件:
sc == rs + MAX_RESIZERS
:协助线程数已达上限sc == rs + 1
:扩容即将完成transferIndex <= 0
:所有迁移任务已完成
5. CAS 尝试参与扩容
kotlin
if (U.compareAndSetInt(this, SIZECTL, sc, sc + 1)) {
transfer(tab, nextTab);
break;
}
- 通过 CAS 将
sizeCtl
加1(增加一个协助线程) - 成功后调用
transfer
执行实际的迁移工作
关键设计思想
1. 协作式扩容
- 多线程并行迁移:多个线程可以同时协助完成数据迁移
- 工作窃取:线程完成自己的迁移任务后可以协助其他线程
2. 状态控制机制
sizeCtl
的不同值表示不同状态:
- 正数:扩容阈值
- -1:表正在初始化
- 其他负数:高16位是扩容标识,低16位是参与扩容的线程数
3. 转发节点(ForwardingNode)
scala
static final class ForwardingNode<K,V> extends Node<K,V> {
final Node<K,V>[] nextTable;
// ...
}
- 特殊标记节点,表示该桶已迁移完成
- 包含指向新表的引用
工作流程示例
- 线程A 在 put 操作时发现桶节点是
ForwardingNode
- 线程A 调用
helpTransfer
检查是否可以协助 - 如果条件满足,线程A通过 CAS 注册为协助线程
- 线程A 调用
transfer
参与数据迁移 - 迁移完成后,操作在新表上继续执行
性能优势
- 避免操作阻塞 :读操作遇到
ForwardingNode
可以直接转到新表 - 加速扩容:多线程并行迁移大幅缩短扩容时间
- 负载均衡:自动根据系统负载调整迁移速度
这种设计体现了 ConcurrentHashMap 的"人人为我,我为人人"思想:每个线程在需要时都能得到其他线程的协助,同时自己也会在可能时协助他人,共同完成繁重的扩容任务。
transferIndex
的 CAS 操作深度解析
ini
//transferIndex是一个成员变量
//初始化的线程会把它设置成n,
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
//nextIndex,开始处理的index+1
//nextBound,结束处理的位置。
//思考一下,为什么CAS的时候使用了nextIndex,而不是直接使用transferIndex?
else if (U.compareAndSetInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
你问到了一个非常关键的设计点!让我详细解释为什么需要 nextIndex
局部变量,以及 transferIndex
的 volatile 特性如何影响这个设计。
🔍 transferIndex
的 volatile 特性
是的,transferIndex
是 volatile 变量!
java
// 在 ConcurrentHashMap 中的定义
private transient volatile int transferIndex;
volatile 的作用:
- 内存可见性 :所有线程都能立即看到
transferIndex
的最新值 - 禁止指令重排序:保证操作的顺序性
🎯 为什么需要 nextIndex
局部变量?
1. 竞态条件保护
问题场景:
kotlin
// 错误写法:直接在 CAS 中使用 transferIndex
else if (U.compareAndSetInt(this, TRANSFERINDEX, transferIndex,
transferIndex - stride)) {
// 潜在问题:transferIndex 可能已被其他线程修改!
}
竞态风险:
ini
时间线:
1. 线程A读取 transferIndex = 64
2. 线程B修改 transferIndex = 48
3. 线程A执行 CAS(TRANSFERINDEX, 64, 48) → 失败!
因为实际值已经是48,不是64了
2. CAS 操作的原子性要求
CAS 操作需要 "比较-交换" 的原子性:
kotlin
U.compareAndSetInt(this, TRANSFERINDEX, expectedValue, newValue)
expectedValue
:期望的旧值(必须与内存中当前值匹配)newValue
:要设置的新值
使用局部变量的正确模式:
ini
int nextIndex = transferIndex; // 快照当前值
// 基于快照值计算
int nextBound = (nextIndex > stride) ? nextIndex - stride : 0;
// CAS 使用快照值作为期望值
if (U.compareAndSetInt(this, TRANSFERINDEX, nextIndex, nextBound)) {
// 成功:基于快照值设置局部变量
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
🔄 完整的线程安全逻辑
代码解析:
ini
else if (U.compareAndSetInt
(this, TRANSFERINDEX, nextIndex, // 期望值:快照的nextIndex
nextBound = (nextIndex > stride ? // 新值:计算出的下界
nextIndex - stride : 0))) {
bound = nextBound; // 任务段的下界
i = nextIndex - 1; // 从后往前处理,起始索引
advance = false; // 退出任务分配循环,开始处理
}
执行流程:
-
读取快照 :
nextIndex = transferIndex
(volatile 读) -
计算新值 :
nextBound = nextIndex - stride
-
原子更新 :CAS 尝试将
transferIndex
从nextIndex
更新为nextBound
-
结果处理:
- 成功 :当前线程获得任务段
[nextBound, nextIndex)
- 失败:循环重试(其他线程已修改)
- 成功 :当前线程获得任务段
🎪 现实世界比喻
超市排队取号系统
把迁移任务分配比作银行/医院的取号系统:
transferIndex
= 总服务台
arduino
volatile int transferIndex = 100; // 当前可服务的最大号码
nextIndex
= 你看到的号码牌
ini
// 你走到服务台前
int nextIndex = transferIndex; // 看到"当前服务到100号"
// 你决定取10个号码(stride=10)
int nextBound = nextIndex - 10; // 你要90-100号
// 向服务员申请
if (CAS(当前号码=100, 期望改为=90)) {
// 成功!你获得了90-100号的处理权
bound = 90; // 从90号开始
i = 99; // 先处理99号(从后往前)
}
如果不用局部变量的灾难场景:
arduino
你: "我要100-90号"
服务员: "好的...(正在处理)"
同时,另一个人: "我要100-90号"
服务员: "混乱!到底给谁?"
使用局部变量的正确场景:
arduino
你: "我看到现在是100号,我要100-90号"
服务员: "检查现在确实是100号 → 改为90号 → 成功!"
另一个人: "我看到现在是90号,我要90-80号"
服务员: "检查现在确实是90号 → 改为80号 → 成功!"
💡 设计精妙之处
1. 乐观锁思想
objectivec
// 不加锁,而是"先尝试,失败了重试"
do {
int nextIndex = transferIndex; // 读取最新值
// 计算、尝试CAS...
} while (CAS失败);
2. 无锁并行
- 多个线程可以同时尝试获取任务段
- 只有成功的线程继续工作,失败的重新尝试
- 避免线程阻塞,提高CPU利用率
3. 任务分配的原子性
ini
// 这三个操作在CAS成功后是原子的:
bound = nextBound; // 1. 设置下界
i = nextIndex - 1; // 2. 设置起始索引
advance = false; // 3. 退出分配循环
4. 从后往前的处理顺序
ini
i = nextIndex - 1; // 从区间的最大值开始处理
好处:减少与正常put操作的冲突(新元素通常插入到前面)
🔧 并发场景分析
场景:4个线程竞争任务
初始状态 :transferIndex = 64
, stride = 16
ini
时间线:
t0: 线程A读取 nextIndex=64, 计算 nextBound=48
t1: 线程B读取 nextIndex=64, 计算 nextBound=48
t2: 线程A CAS成功: transferIndex=64→48
t3: 线程B CAS失败: 期望64, 实际48 → 重新循环
t4: 线程B读取 nextIndex=48, 计算 nextBound=32
t5: 线程B CAS成功: transferIndex=48→32
结果:
- 线程A:处理区间 [48, 64)
- 线程B:处理区间 [32, 48)
- 没有重叠,完美并行!
🚨 如果不这样设计的后果
错误实现1:直接使用 transferIndex
kotlin
// 错误!transferIndex可能被修改
else if (U.compareAndSetInt(this, TRANSFERINDEX,
transferIndex, transferIndex - stride))
问题:CAS 的期望值可能已经过时,导致永远失败或错误成功
错误实现2:没有 volatile
java
private transient int transferIndex; // 非volatile
问题:
- 线程间看不到最新值
- 可能出现多个线程处理相同区间
- 数据一致性被破坏
✅ 总结
nextIndex
局部变量的使用体现了 "读取-计算-比较交换" 的标准无锁编程模式:
- volatile 读取:获取共享状态的最新快照
- 本地计算:基于快照计算新值(无副作用)
- 原子更新:CAS 确保状态变更的原子性
- 失败重试:乐观锁的典型模式
这种设计确保了:
- 🏃 高并发:多线程无锁协作
- 🔒 线程安全:不会出现任务重叠或遗漏
- ⚡ 高性能:避免线程阻塞
- 🎯 精确控制:任务段分配准确无误
这正是 ConcurrentHashMap 能够在高并发环境下保持高性能的关键技术之一!
等一下,volatile不是重点,volatile只是一个安全性的补充罢了,重点在于,这里需要一个快照进行CAS,才能实现乐观锁。所以直接上transferIndex,乐观锁就失效了,因为CAS永远通过......
位运算
在ConcurrentHashMap中,多处使用到位运算。
我不是科班出生,大学上的计算机基础又确实太基础了,当时也太傻。不过,仔细学习,感觉位运算实在很优雅。一直都很有趣,如果我时间多一点就好了,就可以再仔细钻研了。
首先回答一个问题:为什么补码是反码+1?就如同,为什么时针倒退两小时和时针正走十小时的效果一样。减去一个数,等于加上这个数距离那个数
的距离。那个数,这里具体取决于有多少位,或者是232,或者是264,因为最高位是符号位。
我现在理解为什么补码是反码+1
问题1:补码为什么等于反码+1
这涉及到计算机中负数的表示方法,让我从数学原理和设计逻辑来解释:
数学原理
对于一个 n 位二进制系统:
- 模数 = 2ⁿ
- 补码的设计目标是:x + (-x) = 2ⁿ(在模运算下等于0)
推导过程
以 8 位系统为例(n=8,模数=256):
对于负数 -x(x>0):
-
反码 = (2ⁿ - 1) - x
- 因为 2ⁿ - 1 是全 1(11111111)
- 减去 x 就是按位取反
-
补码 = 2ⁿ - x
- 根据补码定义:x + 补码 = 2ⁿ
-
关系推导:
ini补码 = 2ⁿ - x = [(2ⁿ - 1) - x] + 1 = 反码 + 1
实际例子
求 -5 的补码(8位):
- +5 原码:00000101
- 反码:11111010(按位取反)
- 补码:11111011(反码+1)
验证:5 + (-5) = 00000101 + 11111011 = 100000000 = 256 ≡ 0 (mod 256)
问题2:tableSizeFor
函数解析
这个函数用于找到大于等于给定容量的最小2的幂。
代码分析
arduino
private static final int tableSizeFor(int c) {
int n = -1 >>> Integer.numberOfLeadingZeros(c - 1);
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
算法步骤
c - 1
:处理当 c 本身就是2的幂的情况Integer.numberOfLeadingZeros(c - 1)
:计算前导零的个数-1 >>> ...
:将全1的 -1 右移,得到掩码n + 1
:得到最终的2的幂
举例说明
假设 c = 10:
ini
c = 10
c - 1 = 9 (二进制: 00001001)
前导零个数 = 28 (32位系统中)
-1 >>> 28 = 00000000000000000000000000001111 (十进制15)
n + 1 = 16
结果:16 是大于等于 10 的最小2的幂
更多例子
- c=1 → 1
- c=5 → 8
- c=16 → 16
- c=33 → 64
在 ConcurrentHashMap 中的作用
- 保证容量是2的幂 :便于使用位运算
hash & (n-1)
代替取模运算 - 提高性能:位运算比除法运算快得多
- 优化哈希分布:2的幂容量能更好地分散哈希冲突
这个巧妙的位运算技巧来自《Hacker's Delight》这本书,是经典的位操作算法。
源码
看源码还是有很多好处的,就比如说这里。
当然,回过头来,我们可以看一下,好了,现在就不优雅了。
ini
@IntrinsicCandidate
public static int numberOfLeadingZeros(int i) {
// HD, Count leading 0's
if (i <= 0)
return i == 0 ? 32 : 0;
int n = 31;
if (i >= 1 << 16) { n -= 16; i >>>= 16; }
if (i >= 1 << 8) { n -= 8; i >>>= 8; }
if (i >= 1 << 4) { n -= 4; i >>>= 4; }
if (i >= 1 << 2) { n -= 2; i >>>= 2; }
return n - (i >>> 1);
}
这个算法实在是太朴素了......
也太强大了!
先看前面有没有16个零,就先右移,右移之后n也-16
之后8个零
4个零
2个零
最后就判断,还有没有1,这里直接减去右移的数就可以了。
但是说起来,还有另外一种更粗暴(当然,也就是优雅)的实现,找到大于等于给定容量的最小2的幂。
arduino
/**
* Returns a power of two table size for the given desired capacity.
* See Hackers Delight, sec 3.2
*/
private static final int tableSizeFor(int c) {
int n = c - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
我也不懂哪一个更好。前导零的算法应该更通用一些,因为其他地方也用到了这个。
内存操作
java
@SuppressWarnings("unchecked")
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>)U.getReferenceAcquire(tab, ((long)i << ASHIFT) + ABASE);
}
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
Node<K,V> c, Node<K,V> v) {
return U.compareAndSetReference(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
U.putReferenceRelease(tab, ((long)i << ASHIFT) + ABASE, v);
}
这些方法是 ConcurrentHashMap 中用于直接内存操作的核心方法,通过 Unsafe 类实现高效的并发访问。让我详细解释 ASHIFT 和 ABASE 的计算原理:
方法作用
tabAt()
:原子性地读取数组元素casTabAt()
:CAS 原子更新数组元素setTabAt()
:原子性地设置数组元素
ASHIFT 和 ABASE 的计算
在 ConcurrentHashMap 的静态初始化块中:
ini
// 获取 Unsafe 实例
U = sun.misc.Unsafe.getUnsafe();
Class<?> ak = Node[].class;
// 计算 ABASE:数组第一个元素的基准地址偏移量
ABASE = U.arrayBaseOffset(ak);
// 计算 ASHIFT:元素大小对数的移位量
int scale = U.arrayIndexScale(ak); // 获取数组中每个元素的大小
if ((scale & (scale - 1)) != 0) // 检查是否是2的幂
throw new Error("data type scale not a power of two");
ASHIFT = 31 - Integer.numberOfLeadingZeros(scale);
详细计算过程
1. ABASE (Array Base Offset)
ini
ABASE = U.arrayBaseOffset(Node[].class);
- 作用 :获取
Node[]
数组对象中第一个元素的内存偏移地址 - 值:通常是数组对象头的大小(在64位JVM中约16-24字节)
2. ASHIFT (Array Shift)
ini
int scale = U.arrayIndexScale(Node[].class); // 返回每个Node引用的大小
// 在64位JVM中,开启压缩指针时scale=4,关闭时scale=8
ASHIFT = 31 - Integer.numberOfLeadingZeros(scale);
计算原理:
Integer.numberOfLeadingZeros(scale)
计算前导零个数31 - ...
得到以2为底的对数
内存地址计算
arduino
((long)i << ASHIFT) + ABASE
计算过程:
(long)i << ASHIFT
=i * scale
(通过移位实现乘法)+ ABASE
= 加上数组基准偏移量
举例(scale=4, ABASE=16):
- i=0:
(0 << 2) + 16
= 16(第0个元素地址) - i=1:
(1 << 2) + 16
= 20(第1个元素地址) - i=2:
(2 << 2) + 16
= 24(第2个元素地址)
为什么这样设计?
性能优势
- 避免边界检查:直接内存操作跳过数组边界检查
- 原子性保证:通过CPU原语实现真正的原子操作
- 内存屏障控制:精确控制内存可见性
内存屏障语义
getReferenceAcquire
:获取操作,具有acquire语义putReferenceRelease
:设置操作,具有release语义compareAndSetReference
:CAS操作,具有完整的原子性
与普通数组访问对比
ini
// 普通访问(有边界检查)
Node<K,V> node = tab[i];
// Unsafe直接访问(无边界检查,有内存屏障)
Node<K,V> node = (Node<K,V>)U.getReferenceAcquire(tab, ((long)i << ASHIFT) + ABASE);
这种设计是 ConcurrentHashMap 实现高并发性能的关键技术之一,通过直接内存操作和精确的内存屏障控制,在保证线程安全的同时达到接近原生数组的性能。
spread和哈希
在ConcurrentHashMap
的spread
方法中,使用了无符号右移操作符>>>
而不是有符号右移操作符>>
。这是因为>>>
和>>
在处理负数时的行为不同,而spread
方法需要确保生成的哈希值始终是非负的,以避免在哈希表中产生负索引。
有符号右移>>
和无符号右移>>>
的区别
• 有符号右移>>
:
• 有符号右移操作符>>
会保留符号位(最高位),即在右移时,左边补符号位。
• 对于正数,>>
和>>>
的行为相同。
• 对于负数,>>
会在左边补 1,结果仍然是负数。
• 无符号右移>>>
:
• 无符号右移操作符>>>
会忽略符号位,即在右移时,左边补 0。
• 对于正数,>>>
的行为与>>
相同。
• 对于负数,>>>
会在左边补 0,结果变为正数。
spread
方法的实现
arduino
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
作用
• h ^ (h >>> 16)
:
• 将哈希值h
与其右移 16 位后的值进行异或操作。右移 16 位可以将高 16 位的值移到低 16 位,这样可以更好地混合哈希值的高位和低位,减少哈希冲突。
• 使用>>>
确保右移后的值是非负的,即使h
是负数,右移后也会变成正数。
• & HASH_BITS
:
• 将混合后的哈希值与HASH_BITS
进行按位与操作,确保结果在哈希表的有效索引范围内。
• HASH_BITS
通常是2^n - 1
,用于将哈希值映射到哈希表的索引范围内。
为什么使用>>>
而不是>>
假设h
是一个负数,例如h = -1
(其二进制表示为0xFFFFFFFF
):
• 使用>>
:
ini
int result = (h ^ (h >> 16)) & HASH_BITS;
• h >> 16
的结果是-1
(二进制表示为0xFFFFFFFF
)。
• h ^ (h >> 16)
的结果是0
(二进制表示为0x00000000
)。
• 0 & HASH_BITS
的结果是0
。
• 使用>>>
:
ini
int result = (h ^ (h >>> 16)) & HASH_BITS;
• h >>> 16
的结果是65535
(二进制表示为0x0000FFFF
)。
• h ^ (h >>> 16)
的结果是4294901760
(二进制表示为0xFFFF0000
)。
• 4294901760 & HASH_BITS
的结果是一个非负的哈希值。
总结 使用>>>
而不是>>
的原因是:
• 确保结果始终是非负的:>>>
会将负数右移后变成正数,避免生成负的哈希值。
• 更好的哈希混合:通过将高 16 位的值移到低 16 位,更好地混合哈希值的高位和低位,减少哈希冲突。
因此,使用>>>
可以确保spread
方法生成的哈希值始终是非负的,并且具有更好的分布特性。
if ((eh = e.hash) == h)
在ConcurrentHashMap
的实现中,if ((eh = e.hash) == h)
是一个关键的检查步骤,用于快速判断两个节点是否可能相等。这个检查的目的是减少不必要的对象比较操作,提高性能。具体来说,if ((eh = e.hash) == h)
检查的是两个节点的哈希值是否相等。如果哈希值不相等,那么这两个节点一定不相等,可以直接跳过后续的比较。
接下来的if ((ek = e.key) == key || (ek != null && key.equals(ek)))
是一个更严格的检查,用于确定两个节点的键是否真正相等。这里使用了||
(逻辑或)运算符,原因如下:
1.直接引用比较
ini
(ek = e.key) == key
• 作用:首先检查两个键的引用是否相同。
• 原因:如果两个键的引用相同,那么它们一定相等。这种比较非常快速,因为它直接比较了两个对象的内存地址。
• 效率:引用比较非常高效,因为它避免了调用equals
方法,减少了不必要的对象比较操作。
2.内容比较
csharp
(ek != null && key.equals(ek))
• 作用:如果两个键的引用不同,但键的值可能相等,需要进一步检查它们的内容是否相等。
• 原因:即使两个键的引用不同,它们的内容也可能相同。例如,两个不同的String
对象可能包含相同的字符序列。
• 效率:内容比较相对更耗时,因为它需要调用equals
方法,这可能涉及复杂的逻辑,如字符串比较。
为什么使用||
运算符
• 短路逻辑:||
是一个短路逻辑运算符,即如果第一个条件为true
,则不会执行第二个条件。这可以提高效率,因为如果引用比较已经确定两个键相等,就没有必要再进行内容比较。
• 确保准确性:通过结合引用比较和内容比较,可以确保两个键是否真正相等。这在处理哈希表时非常重要,因为哈希表依赖于键的唯一性来存储和检索值。
代码解析
ini
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
• 哈希值比较:
• if ((eh = e.hash) == h)
:检查当前节点的哈希值e.hash
是否与目标哈希值h
相等。
• 如果哈希值不相等,直接跳过后续的键比较,因为哈希值不同,键一定不同。
• 键比较:
• if ((ek = e.key) == key)
:首先检查两个键的引用是否相同。
• 如果引用相同,直接返回当前节点的值e.val
,因为键一定相等。
• 如果引用不同,进一步检查内容是否相等:
• ek != null && key.equals(ek)
:确保ek
不为null
,然后调用equals
方法比较两个键的内容。
• 如果内容相等,返回当前节点的值e.val
。
总结 使用||
运算符的原因是:
• 提高效率:通过短路逻辑,避免不必要的内容比较。
• 确保准确性:结合引用比较和内容比较,确保两个键是否真正相等。
这种设计在ConcurrentHashMap
中非常常见,因为它在保证线程安全的同时,也优化了性能。
eh<0
在ConcurrentHashMap
的实现中,else if (eh < 0)
这一行代码处理的是特殊情况,即当前节点的哈希值eh
是负数。这种情况通常发生在使用了特殊的节点类型,如树化节点(TreeNodes)或转发节点(ForwardingNodes)时。这些节点的哈希值被设置为负数,以区分普通节点。
1.背景知识 在ConcurrentHashMap
中,节点的哈希值通常是非负的。但是,为了支持一些特殊功能,如树化(将链表转换为红黑树)和并发控制,某些节点的哈希值被设置为负数。具体来说:
• 树化节点(TreeNodes):当链表长度超过一定阈值时,链表会被转换为红黑树,以提高查找效率。树化节点的哈希值通常设置为负数。
• 转发节点(ForwardingNodes):在并发操作中,某些节点可能被用于转发操作,这些节点的哈希值也设置为负数。
2.代码解析
kotlin
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
2.1 条件检查
arduino
else if (eh < 0)
• eh < 0
:检查当前节点的哈希值是否为负数。如果是负数,说明当前节点是一个特殊节点,如树化节点或转发节点。
2.2 调用find
方法
yaml
(p = e.find(h, key)) != null ? p.val : null;
• e.find(h, key)
:调用当前节点的find
方法,传入目标哈希值h
和键key
,尝试找到匹配的节点。
• find
方法的具体实现取决于节点的类型。对于树化节点,find
方法会在红黑树中查找匹配的节点;对于转发节点,find
方法会处理转发逻辑。
• p != null
:检查find
方法返回的节点是否为null
。
• 如果返回的节点不为null
,说明找到了匹配的节点,返回该节点的值p.val
。
• 如果返回的节点为null
,说明没有找到匹配的节点,返回null
。
3.具体场景
3.1 树化节点 当链表长度超过一定阈值时,链表会被转换为红黑树。树化节点的哈希值通常设置为负数。在这种情况下,find
方法会在红黑树中查找匹配的节点。
3.2 转发节点 在并发操作中,某些节点可能被用于转发操作,这些节点的哈希值也设置为负数。转发节点通常用于处理并发更新操作,如扩容或迁移。
4.为什么需要特殊处理
• 树化节点:红黑树的查找效率比链表高,尤其是在链表长度较长时。通过将链表转换为红黑树,可以显著提高查找效率。
• 转发节点:转发节点用于处理并发操作中的复杂情况,如扩容或迁移。通过使用转发节点,可以确保在并发操作中保持线程安全和高效。
5.总结 这段代码的作用是处理哈希值为负数的特殊节点。具体步骤如下:
• 检查当前节点的哈希值是否为负数。
• 如果是负数,调用find
方法在特殊节点中查找匹配的节点。
• 如果找到匹配的节点,返回该节点的值;否则返回null
。
这种设计确保了ConcurrentHashMap
在处理特殊节点时的高效性和线程安全性。