ConcurrentHashMap 是如何保证线程安全的?
结论先行:JDK 1.8 的 CHM 靠三招:空桶用 CAS 直接放入;非空桶用 synchronized 锁住桶头节点再操作链表或红黑树;读操作完全不加锁,靠 volatile 读桶数组元素与节点 value。计数用 baseCount + counterCells 分散热点,扩容支持多线程协作迁移。
1. 关键字段
transient volatile Node<K,V>[] table; // 桶数组,引用 volatile 保证可见性
private transient volatile int sizeCtl; // 初始化/扩容的控制位
private transient volatile long baseCount;
private transient volatile CounterCell[] counterCells;
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val; // 读线程能看到最新值
volatile Node<K,V> next; // 链表指针也带 volatile
}
与 HashMap 不同,CHM 的 Node.val 与 Node.next 都是 volatile;table 的读取通过 tabAt(Unsafe 的 volatile 语义)完成,写入则用 casTabAt 或 setTabAt。
2. 写路径
putVal(key, value)
│
├─ table 未初始化 ──> initTable():CAS 抢 sizeCtl,抢到的线程建表
│
├─ 桶 i 为空 ──> casTabAt(tab, i, null, new Node) ← 无锁
│
├─ 桶头 hash == MOVED ──> helpTransfer():协助迁移
│
└─ 否则 synchronized (桶头 f)
├─ if (tabAt(tab, i) == f) 双重检查,防止锁期间桶头被替换
├─ 链表尾插 / 树插入 putTreeVal
├─ 命中 key:只改 val(volatile 写)
└─ 链表长度达标:treeifyBin
│
└─ addCount(1, binCount):累加计数,可能触发 transfer
锁住的是桶头节点对象,不是整张表,所以不同桶的写可以完全并行;空桶连桶头都没有,于是用 CAS 一步完成“检查 + 放入”。
3. 读路径为什么可以不加锁
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) {
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
} else if (eh < 0) { // 树节点或迁移节点
return (p = e.find(h, key)) != null ? p.val : null;
}
while ((e = e.next) != null) {
if (e.hash == h && ((ek = e.key) == key ||
(ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
- 桶数组引用用 volatile 读,写线程发布的节点对读线程可见。
val是 volatile,覆盖写立刻对其他线程可见。- 树桶用
TreeBin内部的读写状态位协调,读操作不阻塞。 - 读路径全程没有
synchronized,也不 CAS,因此不会阻塞写线程。
4. 计数与扩容
| 机制 | 1.8 的做法 | 解决的问题 |
|---|---|---|
| 空桶初始化 | casTabAt | 无锁完成“检查 + 写入” |
| 冲突桶写入 | synchronized (桶头) | 锁粒度缩到单个桶 |
| 读取 | volatile | 读不加锁、不阻塞 |
| 计数 | baseCount + counterCells | 分散 CAS 热点,思路同 LongAdder |
| 扩容 | transfer + ForwardingNode,其他线程 helpTransfer | 大表迁移可并行 |
| 树化 | TreeBin + 红黑树 | 单桶最坏 O(log n) |
addCount 先尝试 CAS 更新 baseCount,失败则把一个 CounterCell 槽的值加一;size() 是 sumCount() 的近似和,需要精确值用 mappingCount()。扩容时每个线程按步长认领一段桶,迁移完的桶放一个 ForwardingNode(hash = MOVED),其他线程遇到它就加入迁移。
5. 代码:复合操作必须用原子方法
import java.util.concurrent.ConcurrentHashMap;
public class ChmDemo {
public static void main(String[] args) {
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
// 错误:get 后再 put,两步之间可能被其他线程改掉
Integer old = map.get("k");
map.put("k", old == null ? 1 : old + 1);
// 正确:merge 在桶锁内完成读改写
map.merge("k", 1, Integer::sum);
// 正确:computeIfAbsent 只计算一次(注意 lambda 里不要再操作同一个 map)
map.computeIfAbsent("v", k -> 42);
System.out.println(map); // 输出:{k=2, v=42}
}
}
6. 常见误区
- CHM 是“绝对线程安全”吗? 单次操作安全,复合操作仍要
merge、compute、putIfAbsent这类原子方法。 - size 精确吗? 不精确,只能当近似值;监控告警不要依赖它做精确判断。
- 能存 null 吗? key 和 value 都不允许,源码里直接
if (key == null || value == null) throw new NullPointerException()。 - 迭代器会抛 CME 吗? 不会,弱一致性,可能看不到迭代开始后的修改。
- get 会不会读到“半插入”的节点? 不会,节点先构造完成再通过 CAS/volatile 写发布。
常见追问
- CHM 的 size 为什么是近似值? 计数分散在多个 cell 中,求和过程不是原子的。
- get 会阻塞吗? 不会,1.8 的读路径不需要互斥锁;树桶用
TreeBin的读写状态位协调,同样不阻塞。 - 为什么 key/value 不能为 null? 并发下无法区分“不存在”和“值为 null”,
containsKey+get也不是原子组合。 - 1.7 与 1.8 最大差别? 分段锁变成 CAS + 桶级
synchronized,并引入红黑树。 - 为什么用 synchronized 而不是 ReentrantLock? 锁对象是桶头,持有时间极短,JVM 内置锁经过偏向锁/轻量级锁优化后更轻,代码也更简单。
- 扩容期间读写怎么协调? 迁移中的桶放 ForwardingNode,读线程转到新表查,写线程协助迁移。
小结
CHM 的线程安全不是靠“一把大锁”,而是把锁拆到桶级别:空桶 CAS、冲突桶 synchronized、读走 volatile、计数分散、扩容协作。理解这五点时,几乎所有追问——size 为什么不精确、get 为什么不加锁、复合操作为什么要 merge——都能顺着同一套机制推出来。