ConcurrentHashMap 全过程原理
ConcurrentHashMap 是 Java 并发集合里最常考、也最容易被讲混的类。它不是“给 HashMap 加 synchronized”这么简单。JDK 7 和 JDK 8 的结构完全不同:JDK 7 重点是 Segment 分段锁,JDK 8 重点是数组 + 链表 + 红黑树、volatile、CAS、桶级 synchronized、多线程协助扩容。
学习目标
| 目标 | 要能说清楚 |
|---|---|
| 为什么需要 | HashMap 多线程写为什么不安全 |
| JDK 7 原理 | Segment 分段锁、HashEntry、concurrencyLevel |
| JDK 8 原理 | Node 数组、CAS、桶锁、红黑树、ForwardingNode |
| put 流程 | 初始化、定位桶、空桶 CAS、冲突加锁、树化、计数、扩容 |
| get 流程 | 为什么大多数读操作不加锁 |
| resize 流程 | sizeCtl、transferIndex、多线程协助迁移怎么配合 |
| 原子复合操作 | 为什么 get 后 put 不安全,merge/compute 怎么用 |
| 生产风险 | computeIfAbsent 慢逻辑、热 key、size 判断、value 非线程安全 |
| 面试闭环 | 能把 JDK7/JDK8 区别、原理和项目用法讲清楚 |
HashMap 多线程写为什么不安全
HashMap 在单线程下很好用,但它没有并发保护。多线程同时 put 时,可能出现:
| 问题 | 原因 |
|---|---|
| 数据覆盖 | 两个线程同时读到旧桶,再分别写回 |
| 计数不准 | size++ 不是原子操作 |
| 扩容混乱 | 多线程同时迁移桶结构 |
| 结构异常 | 链表、树节点修改没有互斥保护 |
错误示例:
import java.util.HashMap;
import java.util.Map;
public class HashMapUnsafeCounter {
private static final Map<String, Integer> MAP = new HashMap<>();
public static void main(String[] args) throws Exception {
Thread t1 = new Thread(HashMapUnsafeCounter::add);
Thread t2 = new Thread(HashMapUnsafeCounter::add);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(MAP.get("order"));
}
private static void add() {
for (int i = 0; i < 10000; i++) {
Integer old = MAP.get("order");
MAP.put("order", old == null ? 1 : old + 1);
}
}
}它可能输出小于 20000,甚至在更复杂场景里出现结构问题。正确方向是使用 ConcurrentHashMap,但也要注意复合操作必须用原子方法。
JDK 7:Segment 分段锁
JDK 7 的 ConcurrentHashMap 可以理解为很多个小 HashMap 拼起来,每个小 HashMap 由一个 Segment 保护。
flowchart TD
A["ConcurrentHashMap"] --> B["Segment 0"]
A --> C["Segment 1"]
A --> D["Segment 2"]
B --> E["HashEntry[]"]
C --> F["HashEntry[]"]
D --> G["HashEntry[]"]
E --> H["链表节点"]结构特点:
| 组件 | 说明 |
|---|---|
Segment[] | 分段数组,每个 Segment 是一个可重入锁 |
HashEntry[] | 每个 Segment 内部自己的桶数组 |
HashEntry | key、hash、value、next |
concurrencyLevel | 期望并发级别,大致影响 Segment 数量 |
JDK 7 写入流程:
flowchart TD
A["put key/value"] --> B["计算 hash"]
B --> C["定位 Segment"]
C --> D["锁住 Segment"]
D --> E["定位 Segment 内桶"]
E --> F["插入或更新链表"]
F --> G["必要时扩容该 Segment"]
G --> H["释放 Segment 锁"]优点:不同 Segment 可以并发写。
缺点:
| 缺点 | 说明 |
|---|---|
| 锁粒度仍偏大 | 同一个 Segment 内不同桶写入也互斥 |
| 结构复杂 | Segment + HashEntry 双层结构 |
| 并发度受 Segment 限制 | Segment 数量固定后不容易更细 |
| 没有红黑树 | 极端 hash 冲突下链表查询慢 |
JDK 8:数组、CAS、桶锁、红黑树
JDK 8 去掉 Segment,结构更像 HashMap:
flowchart TD
A["ConcurrentHashMap"] --> B["Node[] table"]
B --> C["空桶"]
B --> D["链表桶"]
B --> E["红黑树桶"]
B --> F["ForwardingNode 扩容迁移标记"]JDK 8 主要机制:
| 机制 | 用途 |
|---|---|
volatile Node<K,V>[] table | 保证其他线程看到最新数组 |
volatile V val | 保证 value 更新可见 |
volatile Node<K,V> next | 保证链表节点发布可见 |
| CAS | 初始化 table、空桶插入、计数、扩容任务领取 |
synchronized 桶头 | 桶内冲突写入时锁一个桶 |
| 红黑树 | 单桶冲突过多时降低查找成本 |
| ForwardingNode | 扩容迁移完成的桶用它标记并引导访问新表 |
为什么 JDK 8 还用 synchronized?
JDK 6 以后 synchronized 已经有偏向锁、轻量级锁、自旋等优化。在 ConcurrentHashMap 中它只锁桶头节点,锁粒度很小,比维护 Segment 结构更简单。
先把“锁的到底是谁”讲清楚
这是理解 JDK 8 ConcurrentHashMap 最关键的一点。它没有一个覆盖整个 Map 的全局写锁,也没有 JDK 7 的固定 Segment 锁数组。发生哈希冲突时,源码把当前桶的首节点引用保存到局部变量 f,然后执行:
synchronized (f) {
// 修改这个桶里的链表或红黑树
}所以常说“锁桶”,准确说法其实是:
使用当前桶头节点对象
f作为synchronized的监视器,互斥修改该桶当前对应的链表或树结构。
数组槽位本身不是 Java 对象,不能写 synchronized(tab[i]) 后又假设槽位永远不变。源码先用 volatile 语义读出桶头节点 f,锁住 f 后还会再次检查数组的第 i 个槽位当前是否仍然等于 f。
flowchart TD
A["线程计算桶下标 i"] --> B["读取 f = tabAt table i"]
B --> C{"f 是否为空"}
C -- "是" --> D["CAS 写入,不加 synchronized"]
C -- "否" --> E{"f 是否为迁移节点"}
E -- "是" --> F["协助扩容,不锁普通链表"]
E -- "否" --> G["synchronized 锁住对象 f"]
G --> H{"tabAt table i 仍等于 f 吗"}
H -- "否" --> I["桶已变化,本轮不修改并重新循环"]
H -- "是" --> J["在锁内修改链表或 TreeBin"]不同桶为什么可以并发写
假设 table 长度为 16:
线程 T1:keyA -> 下标 3 -> 桶头对象 Node-A
线程 T2:keyB -> 下标 11 -> 桶头对象 Node-BT1 锁的是 Node-A,T2 锁的是 Node-B。两个监视器不是同一个对象,因此可以同时进入各自的临界区。
如果两个 key 都落到下标 3:
线程 T1:synchronized(Node-A)
线程 T2:synchronized(Node-A)二者竞争同一个对象监视器,同一时刻只有一个线程能修改该桶。锁粒度因此是“发生冲突的桶”,不是一个 key 一把锁,也不是整个 Map 一把锁。
为什么不是严格的“每个 key 一把锁”
两个不相等的 key 只要 hash 后落到同一个桶,也会竞争同一个桶头锁。于是并发性能不仅取决于线程数,还取决于:
- table 容量是否合理;
- key 的
hashCode()分布是否均匀; - 是否存在热 key;
- 是否在
compute等锁内逻辑中执行慢操作。
这就是为什么糟糕的 hashCode() 不只会让链表变长,还会把原本可并行的写操作挤到同一把桶锁上。
JDK 7 源码:Segment 为什么是一把锁
下面是 OpenJDK 7 源码结构的教学化摘取,省略了与当前问题无关的字段:
static final class Segment<K,V>
extends ReentrantLock
implements Serializable {
transient volatile HashEntry<K,V>[] table;
transient int count;
transient int modCount;
transient int threshold;
}Segment 直接继承 ReentrantLock,所以 Segment 自己就是锁。put 会先尝试获取当前 Segment 的锁,再修改这个 Segment 内的桶:
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
HashEntry<K,V> first = entryAt(tab, index);
// 在当前 Segment 锁内遍历链表并更新或插入
// 必要时扩容的也是当前 Segment 自己的 table
} finally {
unlock();
}
return oldValue;
}这意味着 JDK 7 中,即使两个 key 位于同一个 Segment 的不同桶,也必须竞争同一把 Segment 锁:
Segment 2
├── 桶 1:T1 要写
└── 桶 9:T2 要写
T1、T2 仍然竞争 Segment 2 这一把 ReentrantLockconcurrencyLevel 会影响 Segment 数量,但不是说设置为 16 后系统永远只能有 16 个线程。它表达的是写入锁分片数量大致为 16;落在不同 Segment 的写可以并行,落在同一 Segment 的写互斥。Segment 数量创建后基本固定,这也是 JDK 8 改为桶级控制的重要原因之一。
JDK 8 putVal 源码逐行分析
以下代码以 OpenJDK 8 的 putVal 为主线,删除了少量非核心细节,但保留了锁语义和关键判断。先看整体,再逐段解释:
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) {
throw new NullPointerException();
}
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table; ; ) {
Node<K,V> f;
int n;
int i;
int fh;
if (tab == null || (n = tab.length) == 0) {
tab = initTable();
} else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null))) {
break;
}
} 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;
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;
}
}
} else if (f instanceof TreeBin) {
binCount = 2;
// 调用 TreeBin.putTreeVal 插入或找到树节点
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD) {
treeifyBin(tab, i);
}
if (oldVal != null) {
return oldVal;
}
break;
}
}
}
addCount(1L, binCount);
return null;
}第一段:空桶为什么不用锁
if (casTabAt(tab, i, null, new Node<>(hash, key, value, null))) {
break;
}桶为空时,只需要保证“只有一个线程能把 null 改成新节点”。CAS 正好能原子完成比较与写入:
T1 期望槽位为 null,准备写 Node-A
T2 期望槽位为 null,准备写 Node-B
T1 CAS 成功:null -> Node-A
T2 CAS 失败:实际值已经是 Node-A
T2 不覆盖 T1,而是回到 for 循环,按非空桶流程处理如果这里使用普通赋值,T1、T2 都可能先看到 null,然后后写入者覆盖先写入者。使用 CAS 后,空桶的第一次插入没有锁竞争,又不会丢数据。
第二段:f 就是准备锁住的桶头对象
f = tabAt(tab, i);
synchronized (f) {
// ...
}tabAt 不是普通数组读取。JDK 8 源码借助 Unsafe.getObjectVolatile 读取槽位,保证线程能观察到并发写入或迁移后的桶状态。对应的 casTabAt 和 setTabAt 也使用具有并发语义的底层操作。
教学化表示如下:
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);
}第三段:为什么锁内必须再判断 tabAt(tab, i) == f
这是很多讲解遗漏的一行:
synchronized (f) {
if (tabAt(tab, i) == f) {
// 才能安全修改
}
}线程读取到 f 和真正获得 f 的监视器之间存在时间窗口。在这个窗口里,其他线程可能已经迁移或替换了该桶。当前线程虽然成功锁住旧对象 f,但旧对象可能已经不是数组槽位里的有效桶头。如果不校验,就可能修改一个已经脱离 table 的旧链表,修改结果不会进入当前 Map。
sequenceDiagram
participant T1 as "线程 T1"
participant T2 as "线程 T2"
participant TAB as "table 第 i 个槽位"
T1->>TAB: "读取桶头 f"
T2->>TAB: "完成迁移或替换桶头"
T1->>T1: "获得旧对象 f 的监视器"
T1->>TAB: "重新读取并比较是否仍为 f"
TAB-->>T1: "不相等"
T1->>T1: "不修改旧桶,退出锁后重试"这条二次检查体现的是典型并发原则:
在锁外观察到的条件,等真正拿到锁后必须重新验证,因为等待锁期间共享状态可能已经变化。
第四段:同一桶的链表更新为什么安全
所有准备修改同一个非空普通桶的线程,读取到的有效桶头都是同一个 f,因此竞争同一个监视器。拿到锁的线程在链表中做两类操作:
- 找到相同 key,更新
e.val; - 没找到相同 key,在尾节点执行
pred.next = new Node(...)。
另一个写线程必须等前一个线程退出 synchronized 后才能进入。进入后又会从当前链表重新查找,因此不会基于同一份旧结构并发接链而互相覆盖。
第五段:锁什么时候释放
Java 编译器会为 synchronized 生成监视器进入和退出逻辑。无论正常结束还是抛出异常,离开同步块时都会释放 f 的监视器。源码中的这些工作在锁外执行:
treeifyBin的入口判断;- 最终
addCount计数; - 根据计数决定是否触发或协助扩容。
不过 treeifyBin 自身在真正转换桶结构时还会重新读取并同步当前桶头,不能理解成“树化完全无锁”。
红黑树桶到底锁谁
当桶已经树化时,table 槽位存放的首对象不是普通 TreeNode,而是 TreeBin 包装节点:
table[i]
↓
TreeBin
├── first:用于遍历的链表头
└── root:红黑树根节点putVal 中的 f 此时就是 TreeBin,外层仍执行:
synchronized (f) {
if (tabAt(tab, i) == f && f instanceof TreeBin) {
((TreeBin<K,V>) f).putTreeVal(hash, key, value);
}
}因此树桶的结构性写入仍由 TreeBin 对象监视器串行保护。TreeBin 内部另外还有 lockState、WAITER、WRITER、READER 等状态,用于协调树查找与结构调整。面试中至少要区分:
synchronized(TreeBin):保护 put/remove 等树结构写入的外层桶级互斥;TreeBin.lockState:树内部为了在并发读取与树平衡期间协调访问使用的状态机制;- 它们不是 JDK 7 的
Segment,作用范围仍然是当前树桶。
扩容时到底锁什么
迁移某个非空普通桶时,transfer 同样会锁住旧表当前桶头 f,再校验它仍是槽位当前值:
synchronized (f) {
if (tabAt(tab, i) == f) {
// 把旧桶拆成低位链和高位链
setTabAt(nextTab, i, lowHead);
setTabAt(nextTab, i + n, highHead);
setTabAt(tab, i, forwardingNode);
}
}这保证迁移线程和正在向该桶 put 的线程不会同时改链表:
- put 先拿到
f:先完成写入,迁移线程随后迁移包含新节点的完整桶; - transfer 先拿到
f:先迁移并把旧槽位改成ForwardingNode,put 下一轮发现MOVED后转去协助扩容并在新表继续; - 不同桶仍可以被不同迁移线程并行处理。
ForwardingNode 的 hash 是特殊值 MOVED。它不是业务数据,也不是一把锁,而是“这个旧桶已经迁移,请去 nextTable”的状态标记和跳转入口。
一张时序图看懂两个线程写同一桶
sequenceDiagram
participant T1 as "线程 T1:put keyA"
participant T2 as "线程 T2:put keyB"
participant B as "桶头对象 f"
T1->>B: "读取 f 并进入 synchronized(f)"
T2->>B: "读取同一个 f,等待监视器"
T1->>B: "二次校验槽位仍为 f"
T1->>B: "遍历链表并追加 keyA"
T1-->>B: "退出同步块,释放监视器"
T2->>B: "获得监视器"
T2->>B: "二次校验并读取包含 keyA 的新链表"
T2->>B: "更新或追加 keyB"
T2-->>B: "释放监视器"如果两个线程写不同桶,上图中的 B 是两个不同对象,两个线程不会互相等待。
JDK 8 put 全流程
flowchart TD
A["put(key,value)"] --> B{"key/value 是否为 null"}
B -->|是| C["抛 NullPointerException"]
B -->|否| D["计算扰动 hash"]
D --> E{"table 是否初始化"}
E -->|否| F["initTable 使用 CAS 初始化"]
F --> G["重新进入 put 循环"]
E -->|是| H["定位桶下标"]
H --> I{"桶是否为空"}
I -->|是| J["CAS 放入新 Node"]
I -->|否| K{"桶是否 ForwardingNode"}
K -->|是| L["helpTransfer 协助扩容"]
K -->|否| M["synchronized 锁桶头"]
M --> N["链表或红黑树插入/更新"]
N --> O["释放桶锁"]
J --> P["addCount 更新计数"]
O --> P
P --> Q{"是否达到扩容阈值"}
Q -->|是| R["触发或协助扩容"]
Q -->|否| S["put 完成"]细节解释:
| 步骤 | 为什么这样做 |
|---|---|
| null 检查 | 禁止 null,避免并发下 get == null 语义不清 |
| hash 扰动 | 让高位信息参与低位下标计算,降低冲突 |
| initTable | 多线程并发初始化时只允许一个线程成功 |
| 空桶 CAS | 没冲突时无锁插入,提高性能 |
| 桶头加锁 | 有冲突时只锁当前桶,不锁整个 Map |
| treeify | 冲突过多时转红黑树,降低查询成本 |
| addCount | 更新计数,并判断是否需要扩容 |
get 为什么通常不加锁
flowchart TD
A["get(key)"] --> B["计算 hash"]
B --> C["读取 volatile table"]
C --> D["定位桶"]
D --> E{"桶为空吗"}
E -->|是| F["返回 null"]
E -->|否| G{"首节点命中吗"}
G -->|是| H["返回 value"]
G -->|否| I{"是否特殊节点"}
I -->|ForwardingNode| J["到新表继续查"]
I -->|TreeBin| K["红黑树查找"]
I -->|普通链表| L["遍历链表"]get 大多无锁依赖的是可见性设计:
| 字段 | 作用 |
|---|---|
| table volatile | 读线程能看到最新表引用 |
| Node.val volatile | 能看到更新后的 value |
| Node.next volatile | 能看到链表新节点 |
| CAS 和锁发布 | 写入完成后对读线程可见 |
读线程不加锁并不表示没有并发控制,而是通过 volatile 和节点不可变/可见字段保证读到的结构是可理解的。
为什么不允许 null
ConcurrentHashMap 禁止 null key 和 null value。
核心原因:并发下 get(key) == null 必须有明确含义。
如果允许 null:
V value = map.get(key);
if (value == null) {
// 是 key 不存在?
// 还是 key 存在但 value 就是 null?
}单线程可以再 containsKey 判断,但并发下:
if (map.get(key) == null) {
if (map.containsKey(key)) {
// 中间可能已经被别的线程修改,判断不稳定。
}
}禁止 null 后,get(key) == null 就能明确表示没有取到有效映射。
sizeCtl 是什么
sizeCtl 是 JDK 8 ConcurrentHashMap 里的控制字段,很重要,但面试不用死背所有位运算细节,要理解它的状态含义。
| sizeCtl 状态 | 大致含义 |
|---|---|
0 | table 还没初始化,使用默认容量 |
| 正数 | 下一次扩容阈值,或初始化容量 |
-1 | 正在初始化 table |
小于 -1 | 正在扩容,且有线程参与迁移 |
它解决的是多线程下“谁初始化、谁扩容、多少线程协助扩容”的协调问题。
扩容全过程
扩容不是简单创建新数组再复制。ConcurrentHashMap 要允许多个线程继续读写,还要让多个线程一起迁移。
flowchart TD
A["addCount 后发现超过阈值"] --> B["CAS 修改 sizeCtl 标记扩容"]
B --> C["创建 nextTable,容量翻倍"]
C --> D["多个线程领取迁移区间"]
D --> E["迁移旧 table 中的一段桶"]
E --> F["迁移完成的桶放 ForwardingNode"]
F --> G{"所有桶是否迁移完成"}
G -->|否| D
G -->|是| H["table 指向 nextTable"]
H --> I["更新新的扩容阈值"]几个关键对象:
| 名称 | 作用 |
|---|---|
nextTable | 扩容期间的新数组 |
transferIndex | 记录还有哪些桶区间没迁移 |
ForwardingNode | 标记某个旧桶已经迁移,读写遇到它就去新表 |
helpTransfer | 其他写线程发现正在扩容后帮忙迁移 |
为什么要多线程协助扩容?
如果只有一个线程迁移大表,停顿会很明显。多线程协助迁移可以把扩容成本分摊给多个写线程,避免单个线程承担全部迁移压力。
ForwardingNode 是什么
扩容期间,旧表里某个桶迁移完后,会被替换成 ForwardingNode。
flowchart TD
A["线程访问旧 table 某个桶"] --> B{"桶是否 ForwardingNode"}
B -->|否| C["按普通桶处理"]
B -->|是| D["说明该桶已迁移"]
D --> E["根据 ForwardingNode 指向 nextTable"]
E --> F["到新表继续 get 或协助 put"]ForwardingNode 的意义:
| 作用 | 说明 |
|---|---|
| 标记迁移完成 | 避免重复迁移同一个桶 |
| 引导读请求 | get 遇到它可以去新表查 |
| 引导写请求 | put 遇到它可以协助扩容 |
| 保证并发扩容可进行 | 旧表和新表过渡期间不会乱 |
计数为什么不是一个 size 变量
如果所有线程都 CAS 同一个 size,高并发写入时竞争会非常激烈。
JDK 8 使用类似 LongAdder 的思路:
flowchart TD
A["写线程增加元素"] --> B{"baseCount CAS 是否成功"}
B -->|成功| C["更新 baseCount"]
B -->|失败| D["使用 CounterCell 分散计数"]
D --> E["sumCount 时汇总 baseCount 和 Cells"]所以:
| 方法 | 说明 |
|---|---|
size() | 返回 int,可能需要汇总,极大时有上限 |
mappingCount() | 返回 long,更适合大 map 估算 |
| 并发修改中的 size | 不是强一致快照 |
不要用 size() 做强一致业务判断。例如“如果 map.size() < 100 就提交”,并发下不可靠。
树化和退化
JDK 8 HashMap 和 ConcurrentHashMap 都有链表转红黑树的思想,但 ConcurrentHashMap 还要考虑并发。
| 条件 | 含义 |
|---|---|
| 链表长度达到阈值 | 可能尝试树化 |
| table 容量太小 | 优先扩容而不是树化 |
| 树节点变少 | 可能退化回链表 |
为什么容量小时优先扩容?
因为冲突可能只是数组太小导致的。扩容后元素重新分布,链表可能自然变短。只有容量已经足够大但单桶仍然很长,才说明 hash 冲突严重,树化更合适。
原子复合操作
ConcurrentHashMap 的单个方法是线程安全的,但组合操作不一定安全。
错误计数:
Integer old = map.get("success");
map.put("success", old == null ? 1 : old + 1);两个线程可能都读到 1,然后都写回 2。
正确写法一:merge
map.merge("success", 1, Integer::sum);正确写法二:compute
map.compute("success", (key, oldValue) -> oldValue == null ? 1 : oldValue + 1);高并发统计推荐:
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.LongAdder;
public class RequestCounter {
private final ConcurrentHashMap<String, LongAdder> counter = new ConcurrentHashMap<>();
public void record(String api) {
counter.computeIfAbsent(api, key -> new LongAdder()).increment();
}
public long count(String api) {
LongAdder adder = counter.get(api);
return adder == null ? 0 : adder.sum();
}
}computeIfAbsent 的坑
computeIfAbsent 很好用,但不要在 mapping function 里做慢 IO。
先理解它为什么能保证同一个 key 的计算不会被普通并发 put 随意穿透。JDK 8 对空桶和非空桶采取不同方案。
空桶:先放 ReservationNode,再锁住它计算
下面是 OpenJDK 8 computeIfAbsent 空桶路径的教学化源码:
Node<K,V> reservation = new ReservationNode<K,V>();
synchronized (reservation) {
if (casTabAt(tab, i, null, reservation)) {
Node<K,V> node = null;
try {
V value = mappingFunction.apply(key);
if (value != null) {
node = new Node<K,V>(hash, key, value, null);
}
} finally {
setTabAt(tab, i, node);
}
}
}ReservationNode 表示“这个空桶正在为某次计算预留”。第一个线程 CAS 放入预留节点后执行计算;其他线程访问该桶时不会把它当普通空桶再次 CAS 插入。finally 很关键:无论计算成功还是抛异常,都要把预留节点替换成真实节点或恢复为空,避免桶永久停留在预留状态。
非空桶:锁当前桶头后计算
如果桶已经存在普通链表或 TreeBin,computeIfAbsent 会像 putVal 一样锁当前桶头,在确认 key 不存在后执行 mapping function,再把结果插入当前桶。因此 mapping function 可能处于桶级临界区内。
同桶线程 T1:computeIfAbsent -> 持有桶头锁 -> 调用远程接口 2 秒
同桶线程 T2:put/compute/merge -> 等待同一个桶头锁这就是“不要在 computeIfAbsent 中做慢 SQL、HTTP、文件 IO”的源码原因。它不是说整个 Map 被锁了,而是相同桶上的结构写入会被这个慢计算阻塞。热 key 或 hash 冲突严重时,局部阻塞会被放大。
为什么递归更新危险
mapping function 不应该再次更新会落到同一计算路径的 Map 内容,例如:
map.computeIfAbsent("A", key ->
map.computeIfAbsent("A", inner -> "value"));JDK 8 的实现会使用 ReservationNode 检测部分递归更新并可能抛出 IllegalStateException: Recursive update。即使递归 key 不完全相同,只要业务形成锁内复杂嵌套,也容易产生不可预测的锁等待、长临界区或逻辑循环。mapping function 应当是短小、无副作用、不会递归修改当前 Map 的纯内存计算。
危险写法:
cache.computeIfAbsent(userId, id -> remoteUserService.query(id));为什么危险?
| 原因 | 后果 |
|---|---|
| 计算可能在桶锁内执行 | 同桶其他更新被阻塞 |
| 远程调用慢 | 请求堆积,线程池被拖住 |
| 远程调用异常 | 缓存没写入,下次继续打下游 |
| 热 key 并发 | 可能形成局部热点 |
更稳妥的做法是:缓存 value 尽量是已准备好的对象,慢 IO 移到锁外,或者使用专门缓存组件如 Caffeine。
不过把慢 IO 简单移到锁外会产生“缓存击穿时多个线程重复加载”的新问题。商业项目通常根据语义选择:
| 方案 | 优点 | 代价 |
|---|---|---|
computeIfAbsent 内直接加载 | 同 key 原子计算语义直观 | 慢加载会延长桶级临界区 |
锁外加载后 putIfAbsent | 不长时间占桶锁 | 多个线程可能重复调用下游 |
CompletableFuture 作为 value | 同 key 可共享一次异步加载结果 | 要处理超时、异常结果移除和线程池隔离 |
| Caffeine LoadingCache | 有成熟的并发加载、容量和过期能力 | 增加组件用法和监控要求 |
不能只为了“少一把锁”牺牲下游稳定性,应结合加载成本、重复加载是否允许、热 key 程度和失败策略选型。
value 线程安全问题
ConcurrentHashMap 只保证 Map 结构并发安全,不保证 value 对象内部安全。
危险示例:
ConcurrentHashMap<String, ArrayList<String>> map = new ConcurrentHashMap<>();
map.computeIfAbsent("order", key -> new ArrayList<>()).add("A");computeIfAbsent 获取列表是线程安全的,但多个线程同时对同一个 ArrayList 调用 add 仍然不安全。
修复方式:
| 需求 | 方案 |
|---|---|
| value 是集合且多线程写 | 使用线程安全集合或在 value 内加锁 |
| 只追加少量监听器 | CopyOnWriteArrayList |
| 高频计数 | LongAdder |
| 复杂对象更新 | 不可变对象整体替换或加锁 |
商业场景:接口访问统计
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.LongAdder;
public class ApiMetrics {
private final ConcurrentHashMap<String, LongAdder> apiCounter =
new ConcurrentHashMap<>();
public void record(String apiName) {
apiCounter.computeIfAbsent(apiName, key -> new LongAdder()).increment();
}
public Map<String, Long> snapshot() {
Map<String, Long> result = new java.util.HashMap<>();
apiCounter.forEach((api, count) -> result.put(api, count.sum()));
return result;
}
}为什么这样设计?
| 设计 | 原因 |
|---|---|
| ConcurrentHashMap | 多接口、多线程并发写安全 |
| LongAdder | 热接口计数竞争少 |
| snapshot 复制 | 对外返回快照,避免暴露内部结构 |
商业场景:本地配置快照
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class LocalConfigCenter {
private final ConcurrentHashMap<String, String> configs = new ConcurrentHashMap<>();
public String get(String key) {
return configs.get(key);
}
public void refresh(Map<String, String> newConfigs) {
configs.clear();
configs.putAll(newConfigs);
}
}这个写法虽然线程安全,但有短暂空窗:clear() 后、putAll() 前,读线程可能读不到配置。
更好的快照替换:
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
public class SnapshotConfigCenter {
private final AtomicReference<Map<String, String>> configs =
new AtomicReference<>(Collections.emptyMap());
public String get(String key) {
return configs.get().get(key);
}
public void refresh(Map<String, String> newConfigs) {
configs.set(Collections.unmodifiableMap(new HashMap<>(newConfigs)));
}
}这说明:不是所有并发读写都必须用 ConcurrentHashMap。配置整体刷新更适合不可变快照。
线上排查
CPU 高
flowchart TD
A["CPU 高"] --> B["查看热点线程栈"]
B --> C{"是否大量 ConcurrentHashMap 操作"}
C -->|是| D["检查热 key 或 compute 慢逻辑"]
D --> E["检查是否频繁 resize"]
E --> F["检查 hashCode 是否质量差"]
C -->|否| G["继续查业务计算、GC、锁竞争"]常见原因:
| 原因 | 处理 |
|---|---|
| 热 key 计数 | LongAdder、分片 key |
compute 内慢逻辑 | 慢 IO 移出 compute |
| 初始容量太小 | 创建时预估容量 |
| hashCode 冲突严重 | 修复 key 的 hashCode |
接口偶发慢
检查是否在这些方法的 lambda 里做了慢操作:
compute()
computeIfAbsent()
computeIfPresent()
merge()这些方法要保证同一个 key 或桶上的原子更新,内部可能持有桶锁。lambda 里应该只做快速内存计算,不要查数据库、调 HTTP、写文件。
统计不准确
如果使用:
map.put(key, map.get(key) + 1);那不是 ConcurrentHashMap 的问题,是复合操作不原子。改成 merge、compute 或 LongAdder。
常见坑
| 坑 | 后果 | 正确做法 |
|---|---|---|
| 认为 CHM 所有组合操作都安全 | get 后 put 仍丢更新 | 用 compute/merge |
在 computeIfAbsent 里远程调用 | 桶锁持有时间长 | 慢 IO 移出或用缓存组件 |
用 size() 做强一致判断 | 并发下判断不可靠 | 使用独立原子计数或限流器 |
| value 是 ArrayList | Map 安全但 value 不安全 | value 也要线程安全 |
| 初始容量太小 | 频繁扩容影响性能 | 根据数据量预估容量 |
| 自定义 key 的 hashCode 很差 | 大量冲突,性能下降 | 修复 hashCode/equals |
面试标准回答
ConcurrentHashMap 为什么线程安全
JDK 8 的 ConcurrentHashMap 使用 volatile 保证 table、Node value、next 等字段可见;空桶插入、初始化、计数和扩容任务领取使用 CAS;同一个桶发生冲突写入时只 synchronized 锁桶头节点;链表过长会转红黑树;扩容时用 ForwardingNode 和 helpTransfer 支持多线程协助迁移。所以它不是锁整个 Map,而是尽量降低锁粒度。
JDK 7 和 JDK 8 ConcurrentHashMap 区别
JDK 7 使用 Segment 分段锁,每个 Segment 类似一个小 HashMap,写操作锁 Segment。JDK 8 去掉 Segment,使用 Node 数组 + 链表/红黑树,空桶 CAS 插入,冲突时 synchronized 锁桶头,读操作大多无锁,扩容支持多线程协助迁移。JDK 8 锁粒度更细,结构也更贴近 HashMap。
ConcurrentHashMap 的 put 流程
put 时先检查 key/value 不能为 null,然后计算 hash。如果 table 未初始化,就 CAS 初始化;定位桶后,如果桶为空就 CAS 放入新节点;如果桶是 ForwardingNode,说明正在扩容,当前线程会协助迁移;如果桶非空,就 synchronized 锁桶头,在链表或红黑树中插入或更新,最后更新计数并判断是否需要扩容。
get 为什么不用加锁
get 主要依赖 volatile 可见性。table、Node 的 value 和 next 等字段具备可见性保证,写线程通过 CAS 或桶锁发布节点后,读线程能看到可理解的结构。遇到 ForwardingNode 时,get 会去新表继续查。因此大多数读操作不需要加锁。
为什么不允许 null
因为并发环境下 get(key) == null 必须有明确含义。如果允许 null value,就无法区分 key 不存在还是 value 本身就是 null。单线程 HashMap 可以用 containsKey 补充判断,但并发下 get 和 containsKey 之间可能被其他线程修改,判断不稳定。
ConcurrentHashMap 一定适合所有并发场景吗
不一定。它保证 Map 结构线程安全,但不保证 value 内部线程安全;复合操作要用 compute、merge 等原子方法;lambda 里不能做慢 IO;如果是整体配置刷新,不可变快照加 AtomicReference 可能比 ConcurrentHashMap 更合适。
关联知识点
| 知识点 | 为什么要看 |
|---|---|
| 并发集合与阻塞队列 | 并发容器整体导读 |
| 集合框架 | HashMap、equals/hashCode、容量和冲突基础 |
| volatile与Atomic全过程 | 理解 volatile、CAS、LongAdder |
| synchronized全过程 | 理解 JDK 8 为什么可用 synchronized 锁桶 |
| ThreadLocal全过程 | 对比线程隔离和并发容器的不同问题 |
