孤舟笔记 Java 集合篇十 ConcurrentHashMap是如何保证线程安全的?CAS和synchronized的精妙配合
文章目录
个人网站
上一篇文章讲了 ConcurrentHashMap 的底层结构,这篇聚焦一个核心问题:它是如何保证线程安全的? 很多人知道"CAS + synchronized",但具体怎么配合、每种场景用什么策略、为什么这样设计,说不清楚。
今天咱们把 ConcurrentHashMap 的线程安全保证从"为什么"到"怎么做"彻底讲透。
一、先说结论:三层安全保障
| 层次 | 机制 | 保护什么 |
|---|---|---|
| 第一层 | volatile | 可见性——get 不加锁也能看到最新值 |
| 第二层 | CAS | 原子性——空桶插入、计数更新等轻量操作 |
| 第三层 | synchronized | 互斥性——链表/树的插入、扩容迁移等复杂操作 |
一句话记住:volatile 保可见、CAS 保轻量原子、synchronized 保复杂互斥——三层配合,安全又高效。
二、第一层:volatile 保证可见性
ConcurrentHashMap 的 Node 中,val 和 next 都是 volatile:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val; // 👈 volatile 保证可见性
volatile Node<K,V> next; // 👈 volatile 保证可见性
}
为什么 get 不加锁?
// get 完全不加锁!
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; // volatile 读 👈
}
// ...
}
return null;
}
volatile 的可见性保证: 线程 A 修改了 val/next,线程 B 读取时一定能看到最新值——这是 JMM 的 happens-before 保证。
tabAt 也用了 volatile 语义:
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); // 👈 volatile 读
}
生活类比: volatile 像公告栏——有人更新了公告,所有人立刻看到最新内容,不需要通知每个人。
三、第二层:CAS 保证轻量原子操作
CAS(Compare And Swap)——比较并交换,无锁并发:
CAS 语义:
if (当前值 == 期望值) {
当前值 = 新值;
return true;
} else {
return false; // 被其他线程改了,重试
}
场景一:空桶插入
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<>(hash, key, value, null)))
break; // 👈 CAS 成功,插入完成,无锁!
}
为什么空桶用 CAS? 空桶插入只有一个操作——把 null 换成新 Node,CAS 一次就行,不需要加锁。
如果 CAS 失败? 说明其他线程先插入了,进入下一轮 for 循环重新处理。
场景二:计数更新
// addCount 中的 CAS
if (!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
// CAS 失败 → 分散到 CounterCell
// 不同线程更新不同的 Cell 👈
}
场景三:初始化数组
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0)
Thread.yield(); // 其他线程正在初始化,让出 CPU
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { // 👈 CAS 抢占初始化权
try {
if ((tab = table) == null || tab.length == 0) {
Node<K,V>[] nt = new Node[sc]; // 只有一个线程能进这里
table = tab = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
CAS 的优势: 不阻塞线程,失败就重试,适合"竞争不激烈"的场景。
四、第三层:synchronized 保证复杂互斥操作
链表/树的插入、扩容迁移等复杂操作用 synchronized:
synchronized (f) { // f 是头节点 👈
if (tabAt(tab, i) == f) { // double-check
if (fh >= 0) {
// 链表插入:遍历 → 覆盖 or 尾插
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == h && ((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<>(hash, key, value, null); // 尾插 👈
break;
}
}
} else if (f instanceof TreeBin) {
// 红黑树插入
}
}
}
为什么用 synchronized 而不是 CAS? 链表插入需要多步操作(遍历 + 判断 + 插入),CAS 无法保证多步的原子性,必须加锁。
为什么锁头节点? 锁的粒度最小——只锁一个桶,其他桶不受影响。
double-check 的意义: 获取锁到进入同步块之间,头节点可能被其他线程修改(如扩容),需要再次确认。
五、三层配合:不同场景的策略选择
| 场景 | 策略 | 原因 |
|---|---|---|
| 空桶插入 | CAS | 一步操作,CAS 足够 |
| 链表插入 | synchronized | 多步操作,需要互斥 |
| 红黑树插入 | synchronized | 多步操作 + 树旋转 |
| 读取操作 | 无锁(volatile) | 只读,可见性足够 |
| 初始化数组 | CAS | 一步操作,CAS 抢占 |
| 计数更新 | CAS + CounterCell | 分散热点 |
| 扩容 | synchronized + CAS | 迁移用 synchronized,状态用 CAS |
核心思想:能 CAS 就不加锁,能加轻量锁就不加重锁。
六、和 Hashtable 的对比
| 维度 | Hashtable | ConcurrentHashMap |
|---|---|---|
| 锁粒度 | 整个 Map | 单个桶 |
| 锁实现 | synchronized 方法 | CAS + synchronized 代码块 |
| get 加锁 | ✅ | ❌ |
| 并发性能 | 极差 | 极好 |
Hashtable 每次操作锁整个 Map,ConcurrentHashMap 只锁一个桶——并发度差距可达数百倍。
ConcurrentHashMap 线程安全 全景
ConcurrentHashMap 线程安全 全景
三层安全保障
├── volatile ── 可见性(get 无锁读)
├── CAS ── 轻量原子(空桶插入、计数、初始化)
└── synchronized ── 互斥性(链表/树插入、扩容)
volatile 的作用
├── Node.val ── get 读到最新值
├── Node.next ── 遍历链表可见
└── table 引用 ── tabAt volatile 读
CAS 的场景
├── 空桶插入 ── casTabAt
├── 初始化 ── CAS sizeCtl
├── 计数 ── CAS baseCount / CounterCell
└── 扩容状态 ── CAS transferIndex
synchronized 的场景
├── 链表插入 ── synchronized(头节点)
├── 红黑树插入 ── synchronized(TreeBin)
└── 扩容迁移 ── synchronized(头节点)
设计原则
├── 能 CAS 就不加锁
├── 能加轻量锁就不加重锁
├── 锁的粒度尽可能小
└── 读操作完全无锁
口诀:volatile 保可见,CAS 做轻量,
synchronized 保复杂,三层配合安全高效,
读不加锁写细锁,并发性能无人敌。
回答技巧与点评
标准回答
ConcurrentHashMap 通过三层机制保证线程安全:第一层 volatile——Node 的 val 和 next 用 volatile 修饰,get 操作完全不加锁,依赖 volatile 可见性读到最新值;第二层 CAS——空桶插入用 casTabAt 原子替换、初始化用 CAS 抢占、计数用 CAS + CounterCell 分散更新;第三层 synchronized——链表和红黑树插入锁头节点,保证多步操作的互斥性。核心设计思想是"能 CAS 就不加锁,能加轻量锁就不加重锁,锁的粒度尽可能小"。
加分回答
- 锁升级与 synchronized 优化:ConcurrentHashMap 锁的头节点对象不会逃逸出同步块,JIT 可以进行锁消除或锁偏置优化。而且竞争不激烈时,synchronized 只用偏向锁(几乎零开销);竞争中等时用轻量级锁(自旋);竞争激烈时才膨胀为重量级锁。这是 ConcurrentHashMap 选择 synchronized 而非 ReentrantLock 的原因之一
- 扩容时的安全性:ConcurrentHashMap 的扩容通过 ForwardingNode(hash = MOVED)标记已迁移的桶,其他线程 put 时发现 ForwardingNode 会协助扩容(helpTransfer)。transferIndex 用 CAS 分配迁移任务,多个线程各负责一段桶的迁移。这种"协作式扩容"比单线程扩容快得多,同时保证了迁移过程的线程安全
- LongAdder 的分散热点思想:ConcurrentHashMap 的计数方案借鉴了 LongAdder(Striped64)——将原子计数分散到多个 Cell,不同线程更新不同 Cell,减少 CAS 竞争。这是一种"空间换时间"的策略,在高并发场景下比 AtomicLong 快数倍。Java 8 中 LongAdder 和 ConcurrentHashMap 是同时设计的,共享了同一套思路
面试官点评
这道题考的是你对并发安全机制的深入理解。能说出"volatile + CAS + synchronized"是基本要求,能分别讲清楚每层保护什么、什么场景用哪种策略,才算及格。如果你能从锁升级优化、协作式扩容、LongAdder 分散热点等角度深入分析,面试官会认为你对 ConcurrentHashMap 的线程安全机制理解得非常透彻,达到了源码级和设计级的水平。
内容有帮助?点赞、收藏、关注三连!评论区等你 💪
更多推荐

所有评论(0)