介绍一下 ConcurrentHashMap:JDK 1.7 分段锁与 JDK 1.8 CAS + synchronized
介绍一下 ConcurrentHashMap:JDK 1.7 分段锁与 JDK 1.8 CAS + synchronized
面试指北 · 去哪儿
题意
面试官问“介绍一下 ConcurrentHashMap”,通常不是想听 API 列表,而是想确认你是否真正理解它为什么线程安全、为什么比 Hashtable 和 Collections.synchronizedMap() 并发性能更好,以及 JDK 1.7 到 JDK 1.8 的实现演进。
这道题可以拆成几个考察点:
ConcurrentHashMap的基本定位:线程安全的哈希表,允许多线程并发读写。- JDK 1.7 的
Segment + HashEntry分段锁结构。 - JDK 1.8 的
Node 数组 + 链表/红黑树结构,以及CAS + synchronized的并发控制。 - 扩容机制:为什么能多线程协助扩容,
ForwardingNode、sizeCtl起什么作用。 - 与
Hashtable、Collections.synchronizedMap()、HashMap的区别。 - 常见追问:为什么 key/value 不能为 null、
size()准不准、链表什么时候树化、get要不要加锁。
回答时建议先给总述,再展开 JDK 1.8 的核心流程,最后补充版本对比和易错点。
结论
ConcurrentHashMap 是 Java 并发包中提供的高并发哈希表实现,核心目标是:在多线程环境下保证线程安全,同时尽量缩小锁的范围,提高并发度。
关键结论可以概括为:
- JDK 1.7 采用
Segment + HashEntry结构,Segment继承ReentrantLock,通过分段锁降低锁竞争。并发度受Segment数量限制,默认是 16,初始化后Segment数量固定。 - JDK 1.8 放弃了
Segment分段锁,改为和HashMap类似的Node 数组 + 链表/红黑树结构。 - JDK 1.8 的并发控制是 CAS + synchronized:
- 桶为空时,先尝试 CAS 放入新节点,成功就直接返回,不进入锁。
- 桶非空或 CAS 失败时,使用
synchronized锁住该桶的头节点,再操作链表或红黑树。 - 锁的粒度从 JDK 1.7 的“一个 Segment”缩小到 JDK 1.8 的“一个桶”,并发能力更强。
- 扩容时 JDK 1.8 支持多线程协助迁移,通过
ForwardingNode标记已迁移的桶,通过sizeCtl控制初始化和扩容状态。 - 与
Hashtable和Collections.synchronizedMap()的全表同步不同,ConcurrentHashMap允许多个线程并发访问不同桶,因此并发性能更好。 ConcurrentHashMap不允许nullkey 和nullvalue,迭代器是弱一致性的,size()在并发场景下是一个估计值,不保证绝对精确。
一句话总结:JDK 1.7 靠分段锁降低竞争,JDK 1.8 靠 CAS 处理无竞争快路径、靠 synchronized 锁桶头节点处理冲突慢路径,再配合多线程协助扩容,从而兼顾线程安全与高并发性能。
要点展开
1. 基本定位:线程安全的 Map,但不是“所有操作都加一把大锁”
ConcurrentHashMap 是 java.util.concurrent 包下的线程安全哈希表。它适用于多线程共享读写 Map 的场景,比如本地缓存、并发统计、路由表、配置映射等。
它有几个需要先明确的特点:
- 不允许
nullkey 和nullvalue,源码中在put、putVal等入口会直接抛NullPointerException。 - 提供了一些原子复合操作,例如
putIfAbsent、computeIfAbsent、compute、merge、replace等。 - 迭代器是弱一致性的:迭代过程中不保证看到最新的全部修改,但通常不会抛出
ConcurrentModificationException。 size()、isEmpty()、containsValue()在并发修改下属于估计值,不保证某一瞬间绝对精确。
面试里经常拿它和 Hashtable、Collections.synchronizedMap() 对比,核心区别就是:后两者基本是全表锁,而 ConcurrentHashMap 把锁的粒度做细了。
2. JDK 1.7:Segment + HashEntry 的分段锁
JDK 1.7 及之前的 ConcurrentHashMap 内部采用 Segment + HashEntry 结构:
- 整个 Map 被分成多个
Segment,每个Segment持有一部分数据。 - 每个
Segment本质上是一个继承自ReentrantLock的锁对象。 - 每个
Segment内部又是一个HashEntry数组,数组中的元素以链表形式解决哈希冲突。 - 对某个 Segment 进行写操作时,只锁住这个 Segment,而不是锁整个 Map。
这样做的好处是:多个线程如果访问不同的 Segment,可以并行写入,从而提升并发度。
但 JDK 1.7 的实现也有局限:
Segment数量在初始化时确定,默认是 16,之后不会动态增加。也就是说,并发度上限受 Segment 数量限制。- 锁的粒度仍然偏粗:一个 Segment 下可能有很多桶,操作不同桶也可能竞争同一把 Segment 锁。
- 每个 Segment 都是一个锁对象,结构和实现相对复杂。
另外,JDK 1.7 中 get 操作通常是不加锁的,主要依靠 HashEntry 中 value 的 volatile 语义保证可见性。写操作加 Segment 锁,读操作尽量无锁,这是分段锁设计的一部分。
3. JDK 1.8:Node 数组 + 链表/红黑树
JDK 1.8 的 ConcurrentHashMap 在数据结构上向 HashMap 靠拢:
- 主体是
Node<K,V>[] table,也就是一个 Node 数组。 - 每个数组位置称为一个“桶”,桶内用链表解决冲突。
- 当链表过长时,会转换为红黑树,降低极端冲突下的查询复杂度。
- 红黑树节点由
TreeBin和TreeNode相关结构组织,TreeBin作为桶的头节点参与锁控制。
链表转红黑树有两个条件:
- 链表长度达到阈值,常见说法是长度超过 8;
- 当前 table 数组长度至少为 64。
如果数组长度还不够 64,ConcurrentHashMap 会优先选择扩容,而不是立刻树化。也就是说,“链表长度到 8 就一定变红黑树”是不准确的。
4. 核心并发控制:CAS 快路径 + synchronized 慢路径
JDK 1.8 不再使用 Segment 分段锁,而是采用 CAS + synchronized。以 put 为例,主干流程可以简化为:
// JDK 1.8 putVal 简化伪代码,只保留主干
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
for (Node<K,V>[] tab = table;;) {
if (tab == null || tab.length == 0) {
initTable();
continue;
}
int i = (tab.length - 1) & hash;
Node<K,V> f = tabAt(tab, i);
if (f == null) {
// 桶为空:CAS 直接放入新节点
if (casTabAt(tab, i, null, new Node<>(hash, key, value, null))) {
break;
}
} else if (f.hash == MOVED) {
// 当前桶是 ForwardingNode,说明正在扩容,帮助迁移
tab = helpTransfer(tab, f);
} else {
// 桶非空:锁住桶头节点
synchronized (f) {
// 在链表或红黑树中查找:
// 已存在则更新 value;
// 不存在则尾插新节点,并视情况触发树化。
}
break;
}
}
addCount(1L, binCount);
这段流程体现了两个关键点:
-
无竞争时走 CAS 快路径
如果目标桶是空的,直接 CAS 放入新节点。CAS 成功就结束,不需要加锁。大量线程访问不同空桶时,可以快速完成写入。 -
有竞争时走 synchronized 慢路径
如果桶非空,说明可能发生哈希冲突或并发写入,此时用synchronized锁住桶的头节点,再操作链表或红黑树。锁的范围只在这个桶,不是整个 table。
这里锁的对象通常是桶的头节点 f。如果桶里是链表,就锁链表头节点;如果是红黑树,就锁 TreeBin 头节点。这样不同桶之间互不影响,锁粒度比 JDK 1.7 的 Segment 更小。
get 操作则基本是无锁的:
- 根据 hash 定位桶;
- 如果头节点匹配,直接返回;
- 如果头节点是
ForwardingNode或树节点,调用对应find方法; - 否则沿着链表向后遍历。
读操作不加锁,主要依赖 Node 中 val 和 next 等字段的 volatile 语义保证可见性。
5. 为什么是 CAS + synchronized,而不是 ReentrantLock?
JDK 1.7 的 Segment 本身就是 ReentrantLock,JDK 1.8 却改成了 CAS + synchronized,原因可以从几个角度看:
- 快路径不需要锁:桶为空时 CAS 一次就完成,低冲突场景下几乎无锁开销。
- synchronized 在现代 JVM 中已经经过大量优化:轻量级锁、锁消除、自适应自旋等机制让它在多数场景下性能并不差。
- 实现更简单、内存开销更小:如果每个桶都配一个
ReentrantLock,对象数量和内存开销都比较大;而synchronized直接锁桶头节点,不需要额外维护锁对象。 - 锁粒度更细:只锁发生冲突的桶,而不是一个大范围 Segment。
所以更准确的说法是:CAS 负责无竞争场景的快速插入,synchronized 负责有竞争场景下的桶内互斥,二者配合,而不是“1.8 完全用 CAS 替代了锁”。
6. 扩容:ForwardingNode 与多线程协助迁移
JDK 1.8 的扩容也是高频考点。它不像 JDK 1.7 那样由单个线程在 Segment 内完成迁移,而是允许多个线程一起帮助扩容。
扩容过程中有几个关键角色:
sizeCtl:控制 table 初始化和扩容状态。不同取值表示不同含义,例如初始化中、扩容中、下次扩容阈值等。扩容线程会通过 CAS 修改sizeCtl来“注册”自己参与扩容。ForwardingNode:一个特殊的占位节点,表示该桶已经迁移或正在迁移。它的nextTable指向新 table。读线程遇到它时会去新 table 查找;写线程遇到它时会调用helpTransfer帮助扩容。transfer:负责把旧数组中的桶迁移到新数组。多个线程会分片领取迁移任务,加快扩容速度。
迁移单个桶时,通常会锁住旧桶头节点,然后把链表拆成低位链和高位链,分别放到新数组的 i 和 i + n 位置。这样既保证迁移过程中的线程安全,也尽量减少对其它桶的影响。
面试中不要简单说“扩容是一个线程做的”。更准确的描述是:**JDK 1.8 支持多线程协助扩容