JAVA · Vol.I · DAY 05 · 集合框架源码
ConcurrentHashMap 深度剖析:put 流程、size 统计与扩容
一次线上事故:HashMap 在多线程下「CPU 100%」
凌晨 2 点,监控告警:某用户中心服务的 CPU 冲到 100%,接口 RT 从 30ms 飙到 8s,大量请求超时。运维抓到的线程栈里,所有业务线程都卡在同一个地方——HashMap.get() 内部的 next 指针遍历上,形成一个永不结束的环形链表。原因不复杂:HashMap 被多个线程并发 put,JDK 1.7 的头插法在扩容时让链表成环,读线程从此死循环。
解决方案当然是换成 ConcurrentHashMap(下文简称 CHM)。但「换一个类」背后是一整套并发设计:无锁读、CAS 插入、桶级 synchronized、分段计数、多线程协作扩容。本文就把 JDK 1.8 的 CHM 从数据结构到方法级源码拆给你看——这也是 Java 后端面试里出现频率最高、最能拉开差距的考点之一。
Hashtable 相当于给整张表加了一把大锁——任何读写都串行,等同「数据库表锁」;1.7 的 Segment 分段锁相当于「分区表 + 每分区一把锁」,并发度固定为分区数;1.8 的桶级锁相当于「行锁 + 索引定位」,锁只落在命中的那一行上。并发设计的第一性原理:锁的粒度越细,并发上限越高。
- HashMap 线程不安全:1.7 头插法在并发扩容时形成环形链表,get 死循环;1.8 尾插法修复了环,但 put 覆盖、size 计数仍会丢数据。
- CHM 的目标:并发场景下既保证线程安全,又让读操作尽量无锁、写操作只锁最小范围。
- 本文主线:
数据结构 → put 流程 → addCount 计数 → size 弱一致 → transfer 协作扩容,全部落到 JDK 1.8 源码。
演进总览:从一把大锁到桶级细粒度并发
三代实现,三个时代
| 版本 | 并发控制 | 数据结构 | 锁粒度 | 并发上限 |
|---|---|---|---|---|
| JDK 1.0 ~ 1.4 | Hashtable:整表 synchronized | Entry 数组 + 链表 | 整张表 | 1 |
| JDK 1.5 ~ 1.7 | Segment 分段锁(继承 ReentrantLock) | Segment[] → HashEntry[] 链表 | Segment(默认 16 个) | 16(固定) |
| JDK 1.8 | CAS + synchronized(锁桶头节点) | Node[] 数组 + 链表 + 红黑树 | 单个桶(数组元素) | 随数组长度线性扩展 |
1.8 的 CHM 与 1.8 的 HashMap 结构完全一致(数组 + 链表 + 红黑树),唯一的区别是:写操作在定位到桶之后,对桶头节点加 synchronized;而读操作利用 volatile 语义完全不加锁。你可以把 1.8 的 CHM 理解为「HashMap 的每个桶都戴了一把小锁」。
JDK 1.7 分段锁:Segment 结构与 put 流程
结构:Segment 继承 ReentrantLock
1.7 的 CHM 是一个 Segment[] 数组。构造时把「并发级别」concurrencyLevel(默认 16)向上取整到 2 的幂得到 ssize,作为 Segment 数组长度;再把「总容量」均分到每个 Segment。每个 Segment 继承 ReentrantLock,内部持有一个 HashEntry[] 桶数组——锁和数据结构是合体的。
// 常量:默认初始容量 16,负载因子 0.75,并发级别 16,最大段数 1 << 16
static final int DEFAULT_CONCURRENCY_LEVEL = 16;
final Segment<K,V>[] segments;
static class Segment<K,V> extends ReentrantLock {
transient volatile HashEntry<K,V>[] table; // 本段桶数组
transient int count; // 本段元素个数(size 统计用)
transient int modCount; // 本段修改次数(一致性校验用)
transient int threshold; // 本段扩容阈值 = 容量 × 0.75
}
static class HashEntry<K,V> {
final int hash;
final K key;
volatile V value; // volatile 保证读可见性
volatile HashEntry<K,V> next; // volatile 且用 putOrderedObject 安全发布
}
注意一个常被误解的点:1.7 的 HashEntry.next 是 volatile 的,写链表时用 Unsafe.putOrderedObject(释放写)发布,配合 key/hash 的 final 不可变性,才让链表读可以不加锁。它不是「不可变字段」,而是「volatile + 安全发布」。
put 流程:tryLock 自旋 + 头插法
V put(K key, int hash, V value, boolean onlyIfAbsent) {
HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
// ① tryLock 成功直接往下走;失败则 scanAndLockForPut:
// 在锁外先定位桶、顺带构造好新节点,自旋 retries 次后 lock() 阻塞等待
V oldValue;
try {
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
if (e != null) {
if (key 已存在) { 覆盖 value; break; }
e = e.next;
} else {
// ② 头插法:新节点插到链表头部
node.setNext(first);
if (++count > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node); // ③ 本段独立扩容(2 倍)
else
setEntryAt(tab, index, node);
break;
}
}
} finally { unlock(); }
return oldValue;
}
为了减少锁竞争等待:tryLock() 失败说明段锁被占,此时锁外先干能干的活——定位桶、构造好新节点,再自旋 MAX_SCAN_RETRIES(单核 1、多核 64)次;自旋期间若抢到锁就直接用,抢不到才 lock() 挂起。这是典型的「锁外准备 + 短临界区」优化。但注意:rehash 是单线程做的(持有段锁),大 Segment 扩容会阻塞同段所有写线程。
1.7 的三个硬伤
- 并发度封顶:最大并发 = Segment 数(默认 16),即使 128 核也只并行 16 路写。
- size() 昂贵且不精确:先无锁遍历两遍各段 count 并比对 modCount 总和,不一致就 tryLock 全部段再数,仍不行就全部加锁重数——大表下开销极大。
- 无红黑树:hash 冲突多时链表 O(n),且扩容是段内单线程 2 倍。
- 1.7 = Segment(继承 ReentrantLock,锁与桶数组合体)+ HashEntry 链表 + 头插法。
- put 路径:tryLock → scanAndLockForPut 自旋 → 锁内定位桶 → 头插 → 段内 rehash。
- 局限:并发度固定 16、size 要锁全段、链表无树化——这三个问题 1.8 全部重写。
JDK 1.8 数据结构与字段全景
四个节点类型,四种语义
// 普通链表节点:注意字段名是 val,不是 value(与 HashMap 不同)
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // 经 spread() 处理后的散列值
final K key; // key 不可变 → 读不用锁
volatile V val; // volatile 保证读可见性
volatile Node<K,V> next;
}
// 红黑树桶的根包装:hash 固定为 TREEBIN(-2)
static final class TreeBin<K,V> extends Node<K,V> {
volatile TreeNode<K,V> root; // 树根
volatile TreeNode<K,V> first; // 链表头(保留链表便于遍历)
volatile Thread waiter;
volatile int lockState; // 0 空闲 / WRITER=1 / WAITER=2 / READER=4(按位叠加)
}
// 扩容占位节点:hash 固定为 MOVED(-1),指向新数组
static final class ForwardingNode<K,V> extends Node<K,V> {
final Node<K,V>[] nextTable;
}
// computeIfAbsent 的临时占位节点:hash 固定为 RESERVED(-3)
static final class ReservationNode<K,V> extends Node<K,V> {}
关键字段与常量
| 字段 / 常量 | 含义 |
|---|---|
transient volatile Node[] table | 主桶数组,volatile 引用;数组元素本身无 volatile 语义(要靠 Unsafe 读) |
transient volatile Node[] nextTable | 扩容时的新数组,仅扩容期间非 null |
transient volatile long baseCount | 基础计数,无竞争时直接 CAS 累加 |
transient volatile CounterCell[] counterCells | 高竞争时的分段计数单元(仿 LongAdder) |
transient volatile int sizeCtl | 「状态 + 阈值」双用:负数表示初始化/扩容中,正数表示下次扩容阈值 |
transient volatile int transferIndex | 扩容时待迁移桶区间的全局分配指针 |
static final int MOVED = -1 | ForwardingNode 的 hash,表示该桶已迁移 |
static final int TREEBIN = -2 | TreeBin 的 hash,表示该桶是红黑树 |
static final int RESERVED = -3 | ReservationNode 的 hash,计算中的占位 |
static final int HASH_BITS = 0x7fffffff | spread() 后与运算,保证普通节点 hash 恒为非负 |
因为 MOVED(-1) / TREEBIN(-2) / RESERVED(-3) 三个特殊值全为负。读代码时到处都是 fh >= 0(普通链表)与 fh < 0(特殊节点)的分支判断——负数 hash 就是「这不是普通节点」的哨兵。所以 spread() 在高低 16 位异或之后还要 & HASH_BITS 把符号位清零。
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS; // 高 16 位参与散列,并保证结果非负
}
tabAt / casTabAt / setTabAt:数组元素的 volatile 化
CHM 全篇几乎不直接写 tab[i],而是通过 Unsafe 的三个静态方法读写数组元素。为什么?这是 CHM 最容易被忽略却最本质的一个点:Java 数组本身不是 volatile 的,数组元素也不具备 volatile 语义——即使 table 字段是 volatile,普通读 tab[i] 也可能读到过期值(对 JMM 来说,数组元素读写是普通操作,无法借助 table 字段的 volatile 建立 happens-before)。
// ① 按 volatile 语义读数组元素 —— get 无锁的基石
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);
}
// ② 原子 CAS:空桶插入、初始化置位、transferIndex 分配全靠它
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);
}
// ③ 释放写(lazy set):写链表/替换桶头后安全发布
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
U.putOrderedVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
}
| 方法 | 底层指令 | 语义 | 典型用途 |
|---|---|---|---|
tabAt | getObjectVolatile | volatile 读(全屏障) | get/put 定位桶头、锁内二次校验 tabAt(tab,i)==f |
casTabAt | compareAndSwapObject | 原子比较并交换 | 空桶 CAS 插入、sizeCtl 置位、transferIndex 分片 |
setTabAt | putOrderedVolatile | 释放写(不强制立即可见) | 扩容迁移后写新表桶位、替换桶头 |
普通读 tab[i] 类似 MySQL 的「快照读」——可能读到旧值;tabAt 的 volatile 读类似「当前读」——一定读到最新提交值。CHM 的 get 之所以无锁还安全,就是因为每个桶头都是用 tabAt(当前读)拿到的,配合节点自身 volatile 字段,构成了整条无锁读链。
- 数组元素没有 volatile 语义,必须用
Unsafe.getObjectVolatile按地址读——这是 CHM 依赖 Unsafe 的根本原因。 - 锁内写完后要二次校验
tabAt(tab, i) == f,防止锁等待期间桶头已被替换(如被扩容迁移)。 setTabAt用的是putOrderedVolatile(释放写)而非putVolatile:写链表场景下保证「先置好 next 再发布桶头」即可,不需要全屏障,性能更优。
put 全流程:CAS 空桶插入 → synchronized 锁头遍历
这是全篇最核心的一站。先看完整源码(JDK 1.8 putVal),再逐行拆解。
final V putVal(K key, V value, boolean onlyIfAbsent) {
// ① CHM 不允许 null key/value(HashMap 允许,这是二者的一个重要差异)
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode()); // ② 高低 16 位混合,保证非负
int binCount = 0; // 记录桶内节点数,用于树化判断
for (Node<K,V>[] tab = table;;) { // ③ 自旋:CAS 失败 / 帮助扩容后重试
Node<K,V> f; int n, i, fh;
// ④ table 未初始化 → initTable()(sizeCtl CAS 置 -1)
if (tab == null || (n = tab.length) == 0)
tab = initTable();
// ⑤ 桶头为 null:CAS 无锁插入,成功即退出(全程不加锁!)
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break; // 无锁插入成功
}
// ⑥ 桶头是 ForwardingNode(hash == MOVED)→ 帮别人扩容,然后重试
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) { // ⑦ 锁住桶头节点(不是锁数组下标)
if (tabAt(tab, i) == f) { // ⑧ 二次校验:等待锁期间桶头没被换走
if (fh >= 0) { // ⑨ 普通链表桶
binCount = 1;
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; // ⑩ 覆盖已有 key
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; // ⑪ 尾插法追加(1.7 是头插,1.8 改尾插)
}
}
}
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) {
// ⑬ 链表节点数 ≥ 8 → treeifyBin(数组 < 64 则先扩容)
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null) return oldVal; // 覆盖返回旧值
break;
}
}
}
addCount(1L, binCount); // ⑭ 计数 +1,并顺带检查是否需要扩容
return null; // 新插入返回 null
}
流程拆解:哪一步加锁,哪一步不加?
| 步骤 | 行为 | 是否加锁 |
|---|---|---|
| ④ 初始化 | initTable(),CAS 抢初始化权 | 无锁(CAS) |
| ⑤ 空桶插入 | casTabAt(tab, i, null, newNode) | 无锁(CAS) |
| ⑥ 遇到 MOVED | helpTransfer 帮助扩容后重试 | 无锁(CAS 记账) |
| ⑦ 锁头遍历 | synchronized(f) 锁桶头,链表尾插/覆盖或树插入 | 有锁(桶级) |
| ⑬ 树化检查 | treeifyBin(内部再锁桶头一次) | 有锁(仅树化时) |
| ⑭ 计数 | addCount:baseCount CAS / CounterCell 分散 | 无锁(CAS) |
空桶插入只有一个前提——「该位置还是 null」,这是典型的 CAS 适用场景(compare-and-swap 一步完成检查与写入),失败就重试,无需阻塞。而锁桶头对象 f 的精妙之处在于:锁对象与数据绑定——所有要修改这个桶的线程,都必须先拿到同一个 f 的监视器;而不同桶的 f 不同,互不阻塞。为什么能锁「节点」?因为桶头一旦被替换(扩容迁移成 FWD),旧节点对象仍安全(不再被修改),新来的线程看到 FWD 会转去 helpTransfer,不会有人再等旧锁——锁的归属自然切换,不需要「全局锁表」。
空桶 CAS 插入像「乐观锁」(版本号=null 才更新);非空桶 synchronized 像「数据库行锁」(只锁命中行)。而 1.7 的 Segment 锁相当于「表分区锁」,Hashtable 相当于「整表锁」。同一条 put 路径,三代实现的锁成本递减。
- put 的四种分支:
未初始化 → initTable、空桶 → CAS 插入、MOVED → helpTransfer、非空 → synchronized(f) 遍历。 - 只有「锁头遍历」和「树化」两步加锁,其余全部无锁;锁的粒度是桶头节点对象。
- 1.8 链表是尾插法(1.7 头插),配合 1.8 无环扩容,杜绝了死循环问题。
- 新 key 插入返回 null,覆盖返回旧值;
putIfAbsent就是putVal(key, value, true)。
initTable 与 sizeCtl:一个字段的三副面孔
sizeCtl 是 CHM 里信息密度最高的一个字段,不同取值代表完全不同的含义。很多源码读不懂,都是卡在这个字段上。
| sizeCtl 取值 | 含义 |
|---|---|
0 | 默认值:table 尚未初始化 |
正数 | 下一个扩容阈值(≈ 0.75 × 容量);构造器里则暂存初始容量 |
-1 | 有线程正在执行 initTable 初始化 |
-(1 + n) | 正在扩容,低 16 位记录「参与扩容的线程数 + 1」;高 16 位是本次扩容的 resizeStamp(与数组长度 n 绑定) |
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
// ① 别人正在初始化:让出 CPU 自旋等待(不阻塞)
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) {
// ③ 初始容量:构造器传了就用,否则默认 16
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
Node<K,V>[] nt = (Node<K,V>[]) new Node<?,?>[n];
table = tab = nt;
// ④ 阈值 = n - n/4 = 0.75n(等价于 容量 × 负载因子 0.75)
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc; // ⑤ 释放初始化权,同时写入阈值
}
break;
}
}
return tab;
}
并发初始化时,多个线程同时进入 initTable。如果直接 sizeCtl = -1,两个线程可能同时通过 if (sc < 0) 的判断、同时建数组——浪费内存且结果不确定。CAS 保证只有一个线程能把 sizeCtl 改成 -1,其余线程看到负数后 yield 自旋等待,谁先抢到谁干活。
注意细节:n >>> 2 是无符号右移两位 = n/4,所以阈值 n - n/4 = 0.75n——负载因子 0.75 在 CHM 里被写死为位运算,与 HashMap 的 DEFAULT_LOAD_FACTOR = 0.75f 等价但省一次浮点乘法。扩容的触发条件也是这个阈值:元素数 >= sizeCtl。
索引定位用的是 (n - 1) & hash(位与代替取模)。只有当 n 是 2 的幂时,n - 1 的低位才全是 1,位与结果才等价于 hash % n 且均匀分布。这也直接决定了扩容必须 2 倍——后面 transfer 站会看到,2 倍扩容让「旧桶 i 的节点只可能去新表 i 或 i+n」,不需要重新散列。
树化与退化:8 / 64 / 6 三个数字的由来
private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n, sc;
if (tab != null) {
// ① 数组长度 < 64:不树化,直接预扩容 2 倍(节点分散后冲突自然缓解)
if ((n = tab.length) < MIN_TREEIFY_CAPACITY) // 64
tryPresize(n << 1);
// ② 数组 ≥ 64 且桶头是普通链表(b.hash >= 0,排除已树化/迁移中)
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
synchronized (b) { // 锁桶头,防止并发树化
if (tabAt(tab, index) == b) { // 二次校验
// ③ 把链表节点逐个转成 TreeNode,串成双向链表
TreeNode<K,V> hd = null, tl = null;
for (Node<K,V> e = b; e != null; e = e.next) { ... }
// ④ 用 TreeBin 包装树根,替换桶头(setTabAt 发布)
setTabAt(tab, index, new TreeBin<K,V>(hd));
}
}
}
}
}
为什么树化阈值是 8?——泊松分布的数学背书
这个论证写在 HashMap 的源码注释里,CHM 沿用了同一套数字:在负载因子 0.75、随机 hashCode 的理想假设下,桶中链表长度服从泊松分布(均值为 0.5),各长度的出现概率约为:
| 链表长度 k | 出现概率 |
|---|---|
| 0 | ≈ 0.6065 |
| 1 | ≈ 0.3033 |
| 2 | ≈ 0.0758 |
| 3 | ≈ 0.0126 |
| 4 | ≈ 0.0016 |
| 5 | ≈ 0.00016 |
| 6 | ≈ 0.000013 |
| 7 | ≈ 0.00000094 |
| 8 | ≈ 0.00000006(千万分之六) |
也就是说:只要 hashCode 均匀,链表长到 8 的概率只有千万分之六。一旦真的出现,几乎可以断定是 hashCode 分布出了问题(比如 hashCode 恒为常数),此时链表 O(n) 会拖垮性能,不如花代价转树 O(log n)。所以 8 是「正常分布下几乎不会误触、异常分布下必须兜底」的平衡点。而 UNTREEIFY_THRESHOLD = 6 比 8 小 2,留出 1 个元素的缓冲,避免树和链表在 7/8 之间来回切换的抖动。
- 不一定:如果数组长度 < 64,先走
tryPresize(n << 1)扩容——扩容后节点重新分布,冲突可能自然消失。只有数组 ≥ 64 才真正树化。所以完整条件是「链表 ≥ 8 且数组 ≥ 64」。 - 退化发生在两处:一是扩容迁移时,树按
hash & n拆成 lo/hi 两半,若某半节点数 ≤ 6 就untreeify()还原成链表;二是删除节点后树过小(根为 null 或右链过短),removeTreeNode返回 true,调用方执行untreeify(t.first)。
- 树化触发:
binCount >= 8(桶内链表达到 8 个节点)且数组 ≥ 64;数组 < 64 时先扩容不树化。 - 8 来自泊松分布论证(0.75 负载下概率千万分之六);退化阈值 6 是为了防止树/链表频繁切换的 1 元素缓冲。
- 树化过程:锁桶头 → 链表转 TreeNode 双向链 →
new TreeBin(hd)替换桶头;树桶 hash 为 TREEBIN(-2)。
addCount 与 CounterCell:并发计数如何不抢一把锁
put 之后要维护元素个数。最朴素的做法是给一个 size 字段加锁累加——但那样每次写操作都串行化。CHM 的做法是分段计数:先把计数 CAS 到 baseCount;竞争激烈就分散到多个 CounterCell 里各自累加;统计时把 baseCount 与所有 cell 相加。这正是 LongAdder 的思想,源码注释原话就是 "Adaptation of LongAdder and Striped64"。
// @Contended 注解:把该对象填充到独立的 CPU 缓存行,避免伪共享(false sharing)
@sun.misc.Contended
static final class CounterCell {
volatile long value;
CounterCell(long x) { value = x; }
}
所有线程抢一个 baseCount,就像所有请求打一个计数器(热点写);分散到 N 个 cell,就像分库分表后每个分片各自计数,报表时再 SUM 汇总。热点被摊平,吞吐线性上升。而 @Contended 解决的是 CPU 层面的问题:相邻 cell 若落在同一缓存行,一个线程改 cell[0] 会导致持有 cell[1] 的其他线程缓存行失效——伪共享。填充后各 cell 独占缓存行。
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
// ① 快速路径:无 cell 数组(低竞争)时直接 CAS baseCount
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a; long v; int m;
boolean uncontended = true;
// ② 慢路径:按线程探针值选一个 cell,CAS 累加
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); // ③ cell 数组初始化/扩容/换 cell 重试,全在这
return;
}
if (check <= 1) return; // ④ 链表短(binCount ≤ 1)就不查扩容,省一次 sumCount
s = sumCount();
}
// ⑤ check >= 0(put 场景)且 s >= sizeCtl → 进入扩容逻辑(下一站展开)
if (check >= 0) { ... resizeStamp / transfer 分支 ... }
}
几个值得记住的细节:
ThreadLocalRandom.getProbe()返回线程自身的随机「探针值」,probe & (len-1)选定 cell——同一线程稳定命中同一 cell,减少跨线程的缓存行竞争。fullAddCount负责:cell 数组为 null 时 CAS 初始化(初始 2 个);目标 cell 为 null 时 CAS 创建;目标 cell 竞争失败时换随机 cell 重试;重试仍失败则把 cell 数组 2 倍扩容;最后兜底 CASbaseCount。sumCount()无锁遍历:sum = baseCount; for (cell : counterCells) sum += cell.value;——这也是后面 size() 弱一致性的根源。check参数:put 传的是binCount(链表短则跳过扩容检查);remove 传 -1(不做扩容检查)。
size():为什么它只是一个「弱一致性近似值」
public int size() {
long n = sumCount(); // 无锁遍历求和,不重试、不加锁
return (n < 0L) ? 0 : (n > (Integer.MAX_VALUE)) ? Integer.MAX_VALUE : (int)n;
}
public long mappingCount() { // 推荐用这个:long 精度,避免 int 溢出截断
long n = sumCount();
return (n < 0L) ? 0L : n;
}
JDK 1.8 的 size() 就是一次裸的 sumCount():把 baseCount 和各 CounterCell 加起来。求和期间别的线程可能正在 put/remove,甚至正在 fullAddCount 里扩容 cell 数组——所以结果只是一个时间点的近似值,既不保证实时,也不保证自洽(可能比真实值略大或略小)。这正是 CHM 官方的设计取舍:size() 从来不是强一致操作。
1.7 的 size() 反而「更努力」但更贵
// ① 无锁遍历两遍:若两次 modCount 总和一致 → 返回(快路径)
// ② 不一致 → tryLock 所有 Segment 再数一遍
// ③ 还不一致 → 全部加锁(lock())重数,然后解锁
// 结论:1.7 的 size() 可能阻塞整表,1.8 的 size() 永远不阻塞——但都不保证精确
三条路:
- 业务侧自维护计数器:写操作后同步更新自己的
AtomicLong / LongAdder,读它做判断——代价是要和 put/remove 逻辑耦合。 - 能接受近似就用
size()/mappingCount():适合监控告警、分页总数展示、缓存容量提示等场景。 - 极端精确场景用外部一致性手段(如分布式锁包住写操作 + 快照),但那样 CHM 的并发优势就没了——通常不值得。
size() 如果要做强一致,就必须在统计瞬间「冻结」整个结构——要么加全局锁(退化回 Hashtable),要么等所有写线程停下(不可行)。CHM 的选择是:放弃 size 的强一致,换取写路径的全程无锁。这是并发容器「按需一致性」的典型设计:每个 API 提供与其成本匹配的一致性等级。
- JDK 1.8 size() = 一次无锁 sumCount(),弱一致、永不阻塞、无重试机制。
- JDK 1.7 size() = 两遍无锁统计 → tryLock 全段 → 全段加锁,可能阻塞但同样不保证精确。
- 大数据量用
mappingCount()(long),避免 size() 的 int 截断。 - 生产上别拿 size() 做精确阈值判断(如「缓存达到 100 万就淘汰」),要自己维护计数器。
transfer:多线程协作扩容,一个桶都不浪费
当元素数 ≥ sizeCtl(0.75 × 容量)时触发扩容。与 1.7 的「单段单线程 rehash」不同,1.8 的扩容是全表协作的:发起线程把待迁移桶区间按 stride 分成多片,其他线程发现 MOVED 桶后也能加入帮忙。核心是三个机制:stride 分片、transferIndex 全局指针、高低位拆分。
① stride:按 CPU 数算每片桶数
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
// ① 每个线程认领的桶数:多核时 = 容量/8/核数,最少 16 个
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE) // 16
stride = MIN_TRANSFER_STRIDE;
// ② 发起者创建 2 倍长的新数组,并初始化 transferIndex = n
if (nextTab == null) {
Node<K,V>[] nt = (Node<K,V>[]) new Node<?,?>[n << 1]; // 2 倍
nextTable = nextTab = nt;
transferIndex = n; // 待分配区间 [0, n)
}
int nextn = nextTab.length;
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab); // 迁移完成桶的占位
...
② transferIndex:CAS 认领一片区间
每个参与线程进入循环后,通过 CAS 把 transferIndex 往下挪一个 stride,认领 [nextBound, nextIndex) 这一片,然后从高索引往低索引迁移:
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; // 认领成功:本片 [bound, nextIndex)
i = nextIndex - 1;
advance = false;
}
}
③ 高低位拆分:为什么 2 倍扩容不用重新散列
旧索引 i = (n-1) & hash,新容量 2n 后索引 (2n-1) & hash。因为 2n-1 比 n-1 多出第 n 位(bit n),所以新索引只可能是 i 或 i+n,取决于 hash & n 是 0 还是 1。因此不需要重新散列,只要按 hash 的第 n 位把链表劈成两半:
// 只针对普通链表桶(fh >= 0),synchronized(f) 锁桶头后:
int runBit = fh & n; // 桶头 hash 的第 n 位
Node<K,V> lastRun = f;
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) { runBit = b; lastRun = p; } // 找最后一个分界点
}
// lastRun 优化:lastRun 及其之后的节点位相同,整段复用,不用逐个 new
Node<K,V> ln = (runBit == 0) ? lastRun : null; // 低位链(留在 i)
Node<K,V> hn = (runBit == 0) ? null : lastRun; // 高位链(去 i+n)
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); // 低位链 → 新表 i
setTabAt(nextTab, i + n, hn); // 高位链 → 新表 i+n
setTabAt(tab, i, fwd); // 旧表桶位换成 ForwardingNode(占位 + 指路)
advance = true;
④ sizeCtl 记账与扫尾:最后一个线程负责收工
if (i < 0 || i >= n || i + n >= nextn) { // 自己认领的区间干完了
int sc;
if (finishing) { // ③ 扫尾确认全表迁移完
nextTable = null;
table = nextTab; // 正式切换主数组引用
sizeCtl = (n << 1) - (n >>> 1); // 新阈值 = 1.5n = 0.75 × 2n
return;
}
// ① 退出前 CAS sizeCtl - 1(自己不再是活跃迁移线程)
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
// ② 若减完后不等于 (rs << 16) + 1,说明还有别的线程在干,直接走人
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true; // 自己是最后一个 → 再扫一遍全表
i = n;
}
}
扩容线程数的记账逻辑:发起时 sizeCtl = (rs << 16) + 2(1 个活跃线程,+2 而不是 +1,是为了让「全部完成」哨兵值 (rs << 16) + 1 与「只剩发起者」区分开)。每加入一个线程 sizeCtl + 1,每完成一个 - 1。当某线程发现减完等于 (rs << 16) + 1,说明自己是最后一个,负责把 table 切换成新数组并更新阈值。
树桶同样按 hash & n 拆成 lo/hi 两条 TreeNode 链,若某条节点数 ≤ UNTREEIFY_THRESHOLD(6),就调用 untreeify() 还原成普通链表再放入新表——小树退化,避免红黑树在小数据量下的常数开销。
- stride = (NCPU > 1) ? (n >>> 3) / NCPU : n,下限 16——每个线程每次认领一片。
transferIndex是全局分片指针,CAS 认领,从高索引往低索引迁移。- 2 倍扩容 +
(hash & n)高低位拆分 = 免重新散列;lastRun优化整段复用节点。 - 旧桶迁移完立即放
ForwardingNode,读写遇到它就知道「这个桶在扩容」。 - sizeCtl 低 16 位记账线程数,最后一个线程扫尾:切换 table、写入新阈值 1.5n。
helpTransfer:普通线程如何「顺手」帮扩容
扩容不是发起线程一个人的事。任何 put / remove / compute 线程,只要定位桶时发现桶头是 ForwardingNode(hash == MOVED),就会调用 helpTransfer 加入战斗——这就是「多线程协助扩容」的入口。
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
Node<K,V>[] nextTab; int sc;
// ① 三重校验:确实是 FWD、nextTable 已建好、table 还是旧的
if (tab != null && (f instanceof ForwardingNode) &&
(nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
int rs = resizeStamp(tab.length); // 由旧表长度算出本次扩容的 stamp
while (nextTab == nextTable && table == tab && (sc = sizeCtl) < 0) {
// ② 以下情况不参与:stamp 不匹配(扩容已换代)/ 只剩最后线程 / 满员 / 没有剩余区间
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || transferIndex <= 0)
break;
// ③ CAS sizeCtl + 1 记账,然后真正进 transfer 干活
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
resizeStamp:给「这一次扩容」发身份证
static final int resizeStamp(int n) {
return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1)); // 高位置 1
}
resizeStamp(n) 由数组长度 n 的「前导零个数」计算而来——n 是 2 的幂,所以不同的 n 一定对应不同的 stamp。发起扩容时把它放到 sizeCtl 的高 16 位(rs << 16),后续任何线程想加入,都要先拿当前旧表长度算 stamp 对比:不一致说明扩容已经换了一代(比如又触发了一次新的扩容),绝不能加入上一代的收尾。这就是「协助扩容」的合法性校验,防止新线程帮旧扩容、旧线程污染新表。
就像大促时应用服务自动扩容——不是只有运维(发起线程)在加机器,任何一个路过的请求(普通线程)发现负载高(MOVED)也会顺手注册一台新实例(CAS sizeCtl+1)加入处理。而 resizeStamp 相当于「本次扩容的版本号」,防止旧版本的 worker 混进新版本的任务。
put 遇到 MOVED 会先 helpTransfer 把该桶迁移完,再重新走循环——此时桶位已指向新表,新节点必然写进新表;即使没遇到 MOVED,迁移中的桶也是「迁移完一个换一个 FWD」,写旧表中未迁移的桶也安全(迁移时锁桶头,写线程也会拿到同一把锁)。读则更简单:get 遇到 FWD 会顺着 nextTable 去新表找(见下一站),所以扩容期间读写都不会丢数据、不会阻塞。
get 为什么无锁:volatile 读链 + 特殊节点转发
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 &&
(e = tabAt(tab, (n - 1) & h)) != null) { // ① volatile 读桶头
if ((eh = e.hash) == h) { // ② 头节点直接命中(快速路径)
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
// ③ hash < 0:FWD(扩容转发) / TreeBin(树查) / Reservation(占位,返回 null)
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
// ④ 普通链表:顺着 volatile next 遍历
while ((e = e.next) != null) {
if (e.hash == h && ((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
为什么 get 全程无锁还安全?——三层保证
- 引用级:桶头用
tabAt(getObjectVolatile)读,等价 volatile 读——一定看到最新发布的桶头。 - 字段级:
key/hashfinal(不可变),val/nextvolatile——读到节点后,节点内容要么不可变要么可见。 - 语义级:写线程都是「先建好节点、再发布」(尾插法先连 next 再挂链表、setTabAt 释放写),读线程不会看到半成品。
特殊节点的 find 转发:扩容期间读不丢数据
| 桶头类型 | hash | find 行为 |
|---|---|---|
Node(普通链表) | ≥ 0 | 沿 next 遍历(get 主循环) |
ForwardingNode | MOVED(-1) | 转发到 nextTable 对应桶继续找——扩容期间读到新表 |
TreeBin | TREEBIN(-2) | 走红黑树查找,内部用 lockState 加「读锁」(READER=4,CAS 累加,不阻塞写) |
ReservationNode | RESERVED(-3) | 计算尚未完成,返回 null |
可能读到「正在被覆盖前的旧值」(val 是 volatile,读到的要么是旧值要么是新值,不会读到脏的中间态),也可能读不到刚 put 的值(无 happens-before)。这是 CHM 明确定义的弱一致性语义:get 保证「不抛异常、不阻塞、不读脏数据」,但不保证「读到最新」。对绝大多数读多写少场景足够;需要强一致的读后写场景,请用 compute/merge 这类原子操作。
remove:锁桶头 + 链表摘除 / 树删除
final V replaceNode(Object key, V value, Object cv) {
// 与 putVal 同构:定位 → MOVED 则 helpTransfer → 否则 synchronized(f) 锁桶头
for (Node<K,V>[] tab = table;;) {
// 桶空 / 桶头为 null → 直接 break(不存在)
if (tab == null || ... || (f = tabAt(tab, i = (n - 1) & hash)) == null) break;
else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f);
else {
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) { // 链表:摘除节点
for (Node<K,V> e = f, pred = null;;) {
if (命中 key) {
if (pred == null) setTabAt(tab, i, e.next); // 删桶头
else pred.next = e.next; // 摘中间节点
break;
}
pred = e; if ((e = e.next) == null) break;
}
}
else if (f instanceof TreeBin) { // 树:删除并可能退化
if (t.removeTreeNode(p))
setTabAt(tab, i, untreeify(t.first)); // 树过小 → 还原链表
}
}
}
if (validated) {
if (oldVal != null) {
if (value == null)
addCount(-1L, -1); // 计数 -1,check=-1 跳过扩容检查
return oldVal;
}
break;
}
}
}
return null;
}
树桶的并发读写还有一层保护:TreeBin 用 lockState 实现读写锁语义——写操作(putTreeVal/removeTreeNode)先把 lockState CAS 成 WRITER(1);读操作(find)CAS 累加 READER(4);写线程发现读者在树上会记录 waiter 并等待。这样红黑树旋转时不至于让无锁读者看到中间态。
- get 无锁的根基:volatile 读桶头(tabAt)+ final key/hash + volatile val/next + 写端「先构建后发布」。
- 遇到 hash < 0 走
find多态:FWD 转发新表、TreeBin 树查(读锁)、Reservation 返回 null。 - remove 与 put 同构(定位 → helpTransfer → 锁桶头),删除后树过小会 untreeify 退化回链表。
- 弱一致性是设计语义而非缺陷:不阻塞、不读脏、不抛 CME;强一致操作请用 compute/merge。
全维度对比:1.7 / 1.8 / Hashtable / synchronizedMap
一张表讲清四个容器
| 维度 | Hashtable | synchronizedMap | CHM 1.7 | CHM 1.8 |
|---|---|---|---|---|
| 锁粒度 | 整表 | 整表 | Segment(默认 16) | 单个桶 |
| 并发控制 | synchronized 方法 | synchronized 包装 | ReentrantLock(继承) | CAS + synchronized |
| 读操作 | 加锁 | 加锁 | 无锁(volatile 链) | 无锁(volatile + find 转发) |
| 数据结构 | Entry 链表 | 同被包装容器 | HashEntry 链表 | Node 链表 + 红黑树 |
| 扩容 | 全表 2 倍 | 同左 | 段内独立 2 倍 | 全表多线程协作 2 倍 |
| size() | 加锁遍历 | 加锁遍历 | 无锁两遍 + 锁全段重试 | sumCount 弱一致 |
| 迭代器 | fail-fast | 同左 | 弱一致 | 弱一致 |
| null key/value | 不允许 | 取决于容器 | 不允许 | 不允许 |
| 最大并发写 | 1 | 1 | 16(固定) | ≈ 桶数量级 |
为什么 1.8 从 ReentrantLock 换回 synchronized?
// ① 场景变了:1.7 锁粒度是 Segment,竞争相对激烈,需要 tryLock 无阻塞尝试 +
// scanAndLockForPut 自旋,这是 ReentrantLock 的强项(lock() 可中断/可超时)
// ② 1.8 锁粒度细到桶,临界区极短、竞争极低:synchronized 的偏向/轻量级锁
// 在低竞争下就是一次 CAS 或几条指令,性能不输 ReentrantLock
// ③ synchronized 是 JVM 内建:锁消除、锁粗化等优化开箱即用;代码也更简洁
// ④ 结论不是「synchronized 比 ReentrantLock 强」,而是「桶级低竞争下 synchronized 够用且更省」
成立。JDK 15 起偏向锁废弃,但轻量级锁(CAS 自旋)仍然在低竞争下非常高效,synchronized 的整体成本没有本质回升;而且 CHM 的绝大多数 put 走的是「空桶 CAS」路径,根本不进锁。这个追问考的是你对 JVM 锁演进是否有持续跟踪——答「偏向锁移除不影响 CHM 的设计合理性,因为主要成本在 CAS 与轻量级锁」即可。
Hashtable 像 MySQL 默认的「表锁」(MyISAM);1.7 分段锁像「分区表 + 每分区一把锁」;1.8 桶级锁像 InnoDB 的「索引 + 行锁」。你会发现并发演进的主线永远一致:把锁从「整块资源」拆到「最小冲突单位」,再用无锁读把读路径彻底解放。
- 选型口诀:读多写少高并发用 CHM;需要严格快照遍历用 CopyOnWriteArrayList(但写成本高);并发度要求低图省事才考虑同步包装。
- 1.8 换 synchronized 是「场景适配」而非「技术倒退」:细粒度锁 + 低竞争下 JVM 锁足够高效。
- CHM 扩容是 2 倍(配合位运算免重散列);对比记忆:HashMap 也是 2 倍,ArrayList 是 1.5 倍。
生产实践与高频踩坑:从理论到上线
七个高频坑,每个都是面试题
// HashMap 允许 null key/value,CHM 不允许 —— 老代码直接换容器会莫名 NPE
map.put(null, "x"); // HashMap OK;CHM → NullPointerException
// 排查:查 putVal 第一行 if (key == null || value == null) throw new NullPointerException();
// ① 递归:mappingFunction 里再对同一个 key 调 computeIfAbsent → IllegalStateException("Recursive update")
map.computeIfAbsent(key, k -> map.computeIfAbsent(k, ...)); // 递归更新,直接抛异常
// ② 重活:mappingFunction 内部做 RPC/DB 查询(耗时 100ms)→ 持有桶锁,同桶线程全部阻塞
map.computeIfAbsent(userId, k -> userService.query(k)); // 桶锁被占 100ms,吞吐骤降
// 正确姿势:先 get 快速路径;mappingFunction 只做轻量构造;重活放锁外再 putIfAbsent
V v = map.get(key);
if (v == null) { v = expensiveLoad(key); map.putIfAbsent(key, v); }
- 坑 3:size()/mappingCount() 弱一致——别拿它做「达到上限就淘汰」的精确判断;要精确就自己维护
LongAdder。 - 坑 4:迭代器弱一致——不会抛
ConcurrentModificationException,但遍历期间可能看到新增/删除,别当快照用;大批量遍历要注意「遍历期间扩容」导致的重复或遗漏元素。 - 坑 5:hashCode 质量差——所有 key 的 hashCode 相同 → 全挤一个桶 → 树化后也是 O(log n) 且常数大;更隐蔽的是用可变对象做 key,hash 字段随对象状态变化,改完就找不到了。
- 坑 6:没有容量上限与淘汰策略——CHM 不是缓存,只增不减会内存泄漏;要配 Caffeine/Guava 这类带淘汰策略的缓存,CHM 只做底层存储。
- 坑 7:扩容抖动——大表扩容(transfer)期间写路径要额外帮忙迁移,可能观察到 RT 尖刺;可以预估容量(
new ConcurrentHashMap(预估 / 0.75f))减少扩容次数。
回到第 1 站的线上事故:完整复盘
| 环节 | 问题 | 修复 |
|---|---|---|
| 根因 | 多线程并发 put 普通 HashMap,扩容成环 | 换成 CHM,靠 1.8 尾插 + CAS + 桶锁天然免疫成环 |
| 诱因 | 单例缓存被全服务共享,读多写多 | 评估读写比:读多写少用 CHM 缓存 + 预热;写多考虑读写分离 |
| 放大 | 无容量上限,缓存只增不减 | 外层套 Caffeine(LRU/W-TinyLFU 淘汰),CHM 当内部存储 |
| 监控 | 无指标 | 上报 CHM 的 mappingCount() 与桶数,发现 size 异常膨胀提前告警 |
- 能预估容量就传初始容量(除以 0.75),减少扩容次数与 transfer 抖动。
- mappingFunction 必须无副作用、轻量、无递归;重活走「get → 锁外算 → putIfAbsent」。
- key 用不可变对象且正确重写 hashCode/equals;绝不用可变字段参与 hash 的对象做 key。
- 需要淘汰、过期、容量上限 → 上 Caffeine/Guava;CHM 只当线程安全的底层 Map。
- 精确计数自维护 LongAdder;监控用 mappingCount();遍历大集合注意弱一致语义。
这一篇你掌握了什么
核心知识点回顾
- 演进主线:Hashtable 整表锁 → 1.7 Segment 分段锁(并发度固定 16)→ 1.8 桶级 CAS + synchronized(并发度随数组扩展)。
- 数据结构:Node[] 数组 + 链表 + 红黑树;四种节点(Node / TreeBin / ForwardingNode / ReservationNode)用 hash 哨兵区分:MOVED=-1、TREEBIN=-2、RESERVED=-3,普通节点 hash 经
spread() & HASH_BITS恒非负。 - put 流程:判空 → spread → initTable(CAS 置 sizeCtl=-1)→ 空桶
casTabAt无锁插入 → MOVED 则 helpTransfer → 否则synchronized(f)锁桶头,二次校验后链表尾插/覆盖或 TreeBin.putTreeVal → binCount ≥ 8 触发 treeifyBin(数组 < 64 先扩容)→ addCount(1, binCount)。 - 计数与 size:baseCount CAS 快速路径 + CounterCell 分段(@Contended 防伪共享)+ fullAddCount 初始化/扩容,sumCount 无锁求和;size() 弱一致,大数据量用 mappingCount()。
- 协作扩容:stride = (n>>>3)/NCPU(下限 16),transferIndex CAS 分片认领;2 倍扩容 +
hash & n高低位拆分免重散列;lastRun 优化;sizeCtl 低 16 位记账,最后线程扫尾切换 table 并写入新阈值 1.5n;读写遇到 FWD 分别走 helpTransfer / find 转发。 - 无锁读:tabAt volatile 读桶头 + final key/hash + volatile val/next + 先构建后发布;TreeBin 用 lockState(WRITER/WAITER/READER)实现读写锁语义。
- 关键数字:树化 8 / 数组 64 / 退化 6 / 负载因子 0.75 / 扩容 2 倍(ArrayList 1.5 倍);树化阈值 8 有泊松分布(概率约千万分之六)背书。
- 生产红线:不支持 null;mappingFunction 轻量无递归;key 不可变;size 弱一致别做精确判断;无淘汰策略要配缓存框架。
面试追问清单(自测)
- put 流程中哪几步加锁、哪几步无锁?为什么空桶可以用 CAS?
- 为什么 1.8 用 synchronized 而 1.7 用 ReentrantLock?
- 为什么数组元素必须用 tabAt 读,不能直接 tab[i]?
- sizeCtl 的四种取值分别代表什么?resizeStamp 怎么算、为什么能防串代?
- 扩容为什么是 2 倍?(ph & n) 拆分、lastRun 优化分别解决什么问题?
- size() 为什么弱一致?精确计数怎么做?
ConcurrentHashMap 值得反复读源码——它不是某个天才算法,而是「CAS + 细粒度锁 + 无锁读 + 协作式扩容」这一整套并发工具箱的组合。把 put / addCount / transfer 三条主路径读通,你对 Java 并发设计的理解会上一个台阶。
Comments · 评论