ConcurrentHashMap 是如何保证线程安全的?
简化版
JDK 8 的 ConcurrentHashMap 结构和 HashMap 一样是「数组 + 链表/红黑树」,线程安全靠 CAS + synchronized 锁单个桶:空桶用 CAS 无锁写入,非空桶只对该桶的头节点加 synchronized。锁粒度细到一个桶,不同桶的读写互不阻塞,并发度远高于早期的分段锁。读操作则基本无锁。
详细版
JDK 7 vs JDK 8 是这道题的核心对比:
- JDK 7——分段锁(Segment):按 concurrencyLevel 估算并创建若干 Segment(常见默认值为 16),每段一把
ReentrantLock。同一段内的写操作串行、不同段可并发;Segment 数量创建后不随 Map 扩容增长,因此并发上限受初始分段数约束,并非任何配置都固定为 16。 - JDK 8——桶级锁(CAS + synchronized):去掉 Segment,直接用
Node[] table。- 空桶:用 CAS 把新节点原子地放进去,无锁,失败就自旋重试;
- 非空桶:只对该桶的头节点加
synchronized,锁住这一个桶,其他桶完全不受影响; - 并发度不再是固定 16,而是约等于桶的数量,随容量增长而提升。
其他关键机制:
- 读无锁:
Node的val和next都是volatile,读操作直接读,不加锁,保证可见性。 - 多线程协同扩容:扩容时多个线程能一起帮忙迁移(
transfer),已迁移完的桶用ForwardingNode标记,别的线程看到它就知道该去新表操作。 - 分散计数:
size用baseCount+CounterCell[]分散累加,避免所有线程抢一个计数器(借鉴了LongAdder的思路)。
完整版教学
一、演进主线:锁粒度越拆越细
把整个演进史串起来看:并发容器的进化,就是锁粒度不断变细的过程。
Hashtable:一把大锁锁整个表,任何操作都互斥,并发度 = 1,最慢。- JDK 7
ConcurrentHashMap:锁「段」,并发度 = 段数(16)。 - JDK 8
ConcurrentHashMap:锁「桶」,并发度 ≈ 桶数(可能上千)。
锁的范围从「整张表」缩到「一个桶」,冲突概率大幅下降,这就是它高并发的根本原因。
二、为什么用 synchronized 而不是 ReentrantLock
JDK 7 用 ReentrantLock(Segment 继承自它),JDK 8 反而换回了 synchronized,看着像「退步」,其实是因为:
- 现代 HotSpot 对
synchronized提供轻量级快速路径、锁消除等优化,低竞争下不必直接进入重量级阻塞;偏向锁是较早 JDK 的历史优化,已在新版本中移除,不能当作永久机制来背; - 锁的对象是单个桶的头节点,竞争本就很低,大多数时候是轻量级锁,性能优秀;
synchronized由 JVM 管理,出锁不用手动unlock,不会因忘记释放而死锁,代码更简洁。
三、为什么读操作能不加锁
get 全程无锁,靠的是 volatile:
static class Node<K,V> {
final int hash;
final K key;
volatile V val; // volatile 保证写入立即可见
volatile Node<K,V> next;
}
val 和 next 都是 volatile,一个线程的写入对读线程立即可见。所以读线程直接遍历桶就能拿到最新值,不需要加锁。这让「读多写少」场景(绝大多数缓存场景)性能极好。
四、为什么不允许 null 键和 null 值
HashMap 允许 null,但 ConcurrentHashMap 禁止 key 和 value 为 null,会抛 NPE。原因是并发下的二义性:
map.get(key); // 返回 null
在并发 map 里,返回 null 有两种可能:「key 不存在」或「key 存在但值就是 null」。单线程可以再用 containsKey 确认,但并发下这两步之间值可能被改,无法区分。所以干脆禁止 null,消除歧义。
五、复合操作仍需原子 API
ConcurrentHashMap 保证的是单个方法的线程安全,「先检查再写入」这种复合操作它管不了:
// ❌ 非原子!check 和 put 之间可能被别的线程插入
if (!map.containsKey(k)) map.put(k, v);
// ✅ 用原子方法
map.putIfAbsent(k, v);
map.computeIfAbsent(k, key -> createValue(key));
记忆点:容器线程安全 ≠ 你的业务逻辑线程安全。涉及「读-改-写」组合时,必须用
putIfAbsent、compute、merge这类原子方法。
六、协同扩容与近似 size 怎么理解
扩容不是让一个线程独自搬完整张表。线程发现容量不足后会申请两倍新表,并通过共享的 transferIndex 领取一段桶区间;迁移完成的旧桶放入 ForwardingNode。其他线程遇到该标记,会沿 nextTable 去新表查找,或参与剩余迁移,因此扩容期间读写仍能推进。
旧表 16 桶 新表 32 桶
线程 T1:迁移桶 12..15 ──→ 对应低位/高位桶
线程 T2:迁移桶 8..11 ──→ 对应低位/高位桶
已迁移桶:ForwardingNode ─→ nextTable
size() 也不是每次都锁住全表统计。更新先尝试 CAS 修改 baseCount,竞争激烈时分散到多个 CounterCell,读取时再求和;并发更新过程中得到的是一个瞬时近似值,不能把 size()==0 当成后续操作仍为空的事务条件。
| 场景 | 内部策略 | 并发意义 |
|---|---|---|
| 空桶插入 | CAS | 成功时无需阻塞 |
| 冲突桶更新 | 锁桶头节点 | 只串行同桶写入 |
| 普通读取 | volatile 可见性 | 不获取桶锁 |
| 扩容 | 多线程分段迁移 | 分摊 O(n) 搬迁工作 |
| 计数 | baseCount + CounterCell | 降低单计数器热点 |
七、常见误区与追问
- 误区:JDK 8 ConcurrentHashMap 仍然使用 Segment。 Segment 是 JDK 7 的分段锁结构,JDK 8 已改为 Node 数组、CAS 与桶级 synchronized。
- 误区:用了 ConcurrentHashMap,任意多步业务操作都自动原子。 单方法安全不覆盖“先读再写”,应使用 compute、merge、replace 等原子 API。
- 误区:get 完全没有同步语义。 它不加互斥锁,但依赖 volatile 读和安全发布获得可见性。
- 追问:computeIfAbsent 的映射函数能做慢 I/O 吗? 不宜;计算可能占用桶级同步路径,还必须避免递归更新同一映射和不可控副作用。
- 追问:为什么禁止 null value? null 被保留为“不存在”的无歧义信号,使单次 get 足以判断结果,不需要并发竞态下再 containsKey。
- 追问:size 能否用于并发配额控制? 不能把瞬时统计当强一致事务条件,严格配额应另用锁、信号量或专门计数协议。
记忆钩子:ConcurrentHashMap 的核心不是“没有锁”,而是让锁只落在真正冲突的桶上,其余读写和迁移尽量并行。
八、加强记忆
JDK 8 ConcurrentHashMap 以 Node 数组为骨架:空桶 CAS 插入,冲突桶锁住头节点,读路径靠 volatile 保持可见;扩容时线程按区间协作迁移,计数则分散到 baseCount 与 CounterCell。它禁止 null 来消除并发判空歧义,并提供 compute、merge、putIfAbsent 等原子复合 API。记住“桶级协调、读不互斥、迁移可协作、业务复合操作仍要选原子接口”,就能把线程安全与业务原子性分开。