ConcurrentHashMap 怎么保证线程安全?从分段锁到 CAS+synchronized
面试必问的并发集合,从 JDK 7 的分段锁到 JDK 8 的 CAS + synchronized,设计哲学发生了根本性变化。
一、为什么需要 ConcurrentHashMap?
HashMap 在多线程下会出问题(数据覆盖、size 不准、甚至 JDK7 的死循环),而 Hashtable 用 synchronized 锁住整个 table,并发度极低:
scss
Hashtable (全表锁):
┌──────────────────────────────────────────┐
│ ┌───┬───┬───┬───┬───┬───┬───┬───┐ │
│ │ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │ │ ← 一个锁锁全部
│ └───┴───┴───┴───┴───┴───┴───┴───┘ │
└──────────────────────────────────────────┘
10 个线程操作 → 串行执行 → 吞吐量极低
ConcurrentHashMap (分段锁/桶级锁):
┌──────────────────────────────────────────┐
│ ┌───┬───┬───┬───┬───┬───┬───┬───┐ │
│ │ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │ │ ← 每个桶独立锁
│ └─L0─┴─L1─┴─L2─┴─L3─┴─L4─┴─L5─┴─L6─┴─L7┘│
└──────────────────────────────────────────┘
10 个线程操作不同桶 → 并行执行 → 高吞吐
二、JDK 7:分段锁(Segment)
JDK 7 的 ConcurrentHashMap 采用 Segment + HashEntry 两级哈希结构:
css
┌─────────────────────────────────────────────────────┐
│ JDK 7 ConcurrentHashMap 结构 │
├─────────────────────────────────────────────────────┤
│ │
│ Segment[] (默认 16 个段,并发度 = 16) │
│ ┌──────┬──────┬──────┬──────┬──────┐ │
│ │ Seg0 │ Seg1 │ Seg2 │ Seg3 │ ... │ │
│ └──┬───┴──┬───┴──────┴──────┴──────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────┐ ┌──────┐ │
│ │Entry │ │Entry │ 每个 Segment 内部是一个 │
│ │table │ │table │ 小型 HashMap │
│ └──┬───┘ └──┬───┘ │
│ │ │ │
│ ┌──┴──┐ ┌──┴──┐ │
│ │HE 0 │ │HE 0 │ HashEntry 链表 │
│ └──┬──┘ └──┬──┘ │
│ │ │ │
│ ┌──┴──┐ ┌──┴──┐ │
│ │HE 1 │ │HE 1 │ │
│ └─────┘ └─────┘ │
│ │
│ Segment 继承 ReentrantLock │
│ 锁粒度 = Segment 级别 │
│ │
└─────────────────────────────────────────────────────┘
java
// JDK 7 核心结构
static final class Segment<K,V> extends ReentrantLock {
transient volatile HashEntry<K,V>[] table;
// put 时 lock(),get 时不需要锁(volatile)
}
static final class HashEntry<K,V> {
final int hash;
final K key;
volatile V value;
volatile HashEntry<K,V> next;
}
JDK 7 put 链路
java
V put(K key, int hash, V value, boolean onlyIfAbsent) {
// tryLock 尝试获取锁
HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(hash, key, value);
// 获取锁后执行插入
try {
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
HashEntry<K,V> first = entryAt(tab, index);
// 遍历链表,key 存在则替换,不存在则头插法插入
// ...
} finally {
unlock();
}
}
JDK 7 get 链路
get 操作不需要加锁,依赖 volatile 保证可见性:
java
V get(Object key, int hash) {
HashEntry<K,V> e = entryAt(tab, (tab.length - 1) & hash);
while (e != null) {
if (e.hash == hash && key.equals(e.key))
return e.value;
e = e.next;
}
return null;
}
三、JDK 8:CAS + synchronized 桶级锁
JDK 8 抛弃了 Segment,改为 Node 数组 + 链表/红黑树 + CAS + synchronized:
css
┌─────────────────────────────────────────────────────┐
│ JDK 8 ConcurrentHashMap 结构 │
├─────────────────────────────────────────────────────┤
│ │
│ Node[] table (与 HashMap 结构一致) │
│ ┌─────┬─────┬─────┬─────┬─────┬─────┐ │
│ │ 0 │ 1 │ 2 │ 3 │ 4 │ ... │ │
│ └──┬──┴─────┴──┬──┴─────┴──┬──┴─────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ Node │ │ null │ │ Node │ │
│ └──┬───┘ └──────┘ └──┬───┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────┐ ┌──────┐ │
│ │ Node │ │TreeNd│ │
│ └──────┘ └──────┘ │
│ │
│ 锁粒度 = 桶级(table[i] 的头节点) │
│ 并发度 = table.length(默认 16,可随扩容增长) │
│ │
└─────────────────────────────────────────────────────┘
JDK 7 vs JDK 8 对比
| 对比维度 | JDK 7 | JDK 8 |
|---|---|---|
| 数据结构 | Segment\[\] + HashEntry\[\] | Node\[\] + 链表/红黑树 |
| 锁实现 | ReentrantLock(Segment) | CAS + synchronized(桶头节点) |
| 锁粒度 | Segment 级(默认 16 段) | 桶级(tablei) |
| 并发度 | 固定 16 | 随 table 长度增长 |
| get 是否加锁 | 否(volatile) | 否(volatile + CAS) |
| size 计算 | 遍历所有 Segment | baseCount + CounterCell\[\] |
| 红黑树 | 无 | 有(链表 ≥ 8 时转换) |
| 扩容 | Segment 级独立扩容 | 全表扩容(多线程协助迁移) |
四、JDK 8 put 全链路
java
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, i, fh;
// 1. table 为空,初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable();
// 2. 桶位置为空,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;
}
// 3. 桶头节点是 ForwardingNode(扩容中),协助迁移
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
// 4. 桶不为空,synchronized 锁住头节点
else {
V oldVal = null;
synchronized (f) {
// 双重检查:确保 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) {
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;
}
}
}
}
// 链表长度 ≥ 8,转红黑树
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
// 5. 更新计数
addCount(1L, binCount);
return null;
}
put 流程图:
scss
┌──────────────┐
│ put(key,val) │ key/value 不能为 null
└──────┬───────┘
▼
┌────────────────┐ 是 ┌──────────────┐
│ table 为空? │────────▶│ initTable() │ CAS 初始化
└──────┬─────────┘ └──────┬───────┘
│ 否 │
▼ <─────────────────────────┘
┌────────────────────┐ 是 ┌────────────────────┐
│ 桶位置为空? │────────▶│ CAS 插入新节点 │ 无锁!
└──────┬─────────────┘ └──────┬─────────────┘
│ 否 │
▼ <────────────────────────────┘
┌────────────────────┐ 是 ┌────────────────────┐
│ MOVED(扩容中)? │────────▶│ helpTransfer() │ 协助迁移
└──────┬─────────────┘ └──────┬─────────────┘
│ 否 │
▼ <────────────────────────────┘
┌────────────────────────┐
│ synchronized(头节点) │ 桶级锁
│ ├─ 链表:遍历+尾插 │
│ └─ 红黑树:putTreeVal │
└──────┬─────────────────┘
▼
┌────────────────────┐ 是 ┌────────────────┐
│ 链表 ≥ 8? │────────▶│ treeifyBin() │
└──────┬─────────────┘ └──────┬─────────┘
▼ │
┌──────────────┐ │
│ addCount() │ ←─────────────────────┘
└──────────────┘
五、CAS 操作详解
ConcurrentHashMap 大量使用 CAS 实现无锁操作:
java
// 获取桶位置节点(volatile 读)
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>)U.getReferenceAcquire(tab, ((long)i << ASHIFT) + ABASE);
}
// CAS 设置桶位置节点
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
Node<K,V> c, Node<K,V> v) {
return U.compareAndSetReference(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
// volatile 写入桶位置
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
U.putReferenceRelease(tab, ((long)i << ASHIFT) + ABASE, v);
}
java
CAS 乐观锁原理:
线程1: 读取 tab[3] = null
CAS(tab, 3, null, newNode) → 成功!
线程2: 读取 tab[3] = null (在线程1写入前读取)
CAS(tab, 3, null, newNode) → 失败!
→ 进入下一轮 for 循环重试
重新读取 tab[3] = 线程1的节点
→ 走 synchronized 分支
六、扩容与多线程协助迁移
JDK 8 的扩容支持多线程协助:
java
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
// 每个线程处理的桶数量(最小 16)
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE;
if (nextTab == null) {
// 第一个发起扩容的线程创建新数组
Node<K,V>[] nt = new Node[n << 1]; // 容量翻倍
nextTab = nt;
nextTable = nt;
transferIndex = n; // 从右往左分配桶
}
int nextn = nextTab.length;
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true;
boolean finishing = false;
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
// 1. 分配迁移区间 [bound, i]
while (advance) {
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
else if (U.compareAndSetInt(this, TRANSFERINDEX,
nextIndex, nextBound = nextIndex > stride ?
nextIndex - stride : 0)) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
// 2. 迁移完成
if (i < 0 || i >= n || i + n >= nextn) {
if (finishing) {
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1); // 新阈值
return;
}
// CAS 减少 sizeCtl,表示一个线程完成迁移
if (U.compareAndSetInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return; // 还有其他线程在迁移
finishing = advance = true;
i = n;
}
}
// 3. 空桶,放 ForwardingNode
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
// 4. 已迁移(ForwardingNode),跳过
else if ((fh = f.hash) == MOVED)
advance = true;
// 5. 迁移链表/红黑树
else {
synchronized (f) {
if (tabAt(tab, i) == f) {
// 与 HashMap 类似:hash & oldCap 分为低位链和高位链
// 迁移完成后在原位置放 ForwardingNode
}
}
}
}
}
ini
多线程协助扩容:
线程1: 负责 [0, 15]
线程2: 负责 [16, 31]
线程3: 负责 [32, 47]
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ F │ F │ F │ F │ F │ N │ N │ N │ N │ N │ N │ N │ N │ N │ N │ N │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
← 线程1 已完成 → ← 线程2 正在迁移 → ← 线程3 待迁移 →
F = ForwardingNode (已迁移)
N = 未迁移节点
遇到 F 的 put 请求 → helpTransfer() 协助迁移
七、size 计算:baseCount + CounterCell
JDK 7 用遍历所有 Segment 的方式计算 size(先乐观读,重试 3 次不一致再加锁)。
JDK 8 借鉴了 LongAdder 的设计:
java
// 基础计数
private transient volatile long baseCount;
// 计数桶(分散竞争)
private transient volatile CounterCell[] counterCells;
static final class CounterCell {
volatile long value;
CounterCell(long x) { value = x; }
}
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
// 1. 先 CAS 更新 baseCount
if ((as = counterCells) != null ||
!U.compareAndSetLong(this, BASECOUNT, b = baseCount, s = b + x)) {
// 2. baseCount CAS 失败 → 分散到 CounterCell
CounterCell a; long v; int m;
boolean uncontended = true;
if (as == null || (m = as.length - 1) < 0 ||
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSetLong(a, CELLVALUE, v = a.value, v + x))) {
fullAddCount(x, uncontended); // 初始化/扩容 CounterCell
return;
}
s = sumCount();
}
// 3. 检查是否需要扩容
if (check >= 0) {
while (s >= (long)(sc = sizeCtl) && sc < MAXIMUM_CAPACITY) {
// 触发扩容
}
}
}
public int size() {
long n = sumCount();
return (n < 0L) ? 0 : (n > Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n;
}
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
if (as != null) {
for (CounterCell cell : as)
if ((a = cell) != null)
sum += a.value;
}
return sum;
}
css
size 计算策略:
高并发场景:
线程1 → CAS(baseCount) → 成功 → baseCount += 1
线程2 → CAS(baseCount) → 失败 → CounterCell[2] += 1
线程3 → CAS(baseCount) → 失败 → CounterCell[5] += 1
size = baseCount + sum(CounterCell[])
本质: 将一个热点计数分散到多个 cell,减少 CAS 冲突
类似 LongAdder 的 "分散热点" 思想
八、ForwardingNode:扩容中的哨兵
java
static final class ForwardingNode<K,V> extends Node<K,V> {
final Node<K,V>[] nextTable;
ForwardingNode(Node<K,V>[] tab) {
super(MOVED, null, null, null);
this.nextTable = tab;
}
}
ForwardingNode 的 hash 值固定为 MOVED = -1,作用是:
- 标记该桶已完成迁移
- get 操作遇到 ForwardingNode 时,会转发到 nextTable 查找
- put 操作遇到 ForwardingNode 时,会协助扩容
九、常见面试追问
Q1:为什么 JDK 8 放弃了 Segment?
- Segment 粒度太粗,并发度固定为 16
- 两级哈希查找开销大
- 桶级锁粒度更细,并发度随 table 增长
- synchronized 在 JDK 8 后做了大量优化(锁升级),性能不亚于 ReentrantLock
Q2:ConcurrentHashMap 为什么不允许 null key 和 null value?
HashMap 允许 null,但 ConcurrentHashMap 不允许。原因是 二义性 :get 返回 null 时,无法区分是"不存在"还是"值为 null"。在单线程下可以 containsKey 确认,但在多线程下,containsKey 和 get 之间可能有其他线程修改了 map,导致结果不可靠。
Q3:ConcurrentHashMap 的 get 需要加锁吗?
不需要。Node 的 val 和 next 都是 volatile 的,保证可见性。整个 get 过程是无锁的。
Q4:sizeCtl 是什么?
sizeCtl 是一个多用途的 volatile int:
- -1:表示正在初始化
- -(1 + N):表示有 N 个线程正在扩容
- 0:默认值(未初始化)
- 正数:表示扩容阈值(初始化或扩容后)
十、总结
arduino
JDK 7: Segment(ReentrantLock) + HashEntry → 分段锁,并发度固定
JDK 8: CAS + synchronized(桶头节点) + Node/TreeBin → 桶级锁,并发度动态
put: 空桶CAS → 非空synchronized → 链表/红黑树 → addCount
get: volatile读 → 遍历链表/树 → 无锁
size: baseCount + CounterCell[] → 分散热点
扩容: 多线程协助 → ForwardingNode标记 → 低位链/高位链分离
ConcurrentHashMap 的演进体现了并发设计的核心理念:锁粒度越细,并发度越高;能用无锁就不用锁,必须加锁也要最小化锁范围。