ConcurrentHashMap 是如何保证线程安全的?
2026年01月23日
一则或许对你有用的小广告
欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论
- 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于
Spring AI + Spring Boot3.x + JDK 21..., 点击查看; - 《从零手撸:仿小红书(微服务架构)》 已完结,基于
Spring Cloud Alibaba + Spring Boot3.x + JDK 17..., 点击查看项目介绍; 演示链接: http://116.62.199.48:7070/; - 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/
面试考察点
面试官提出这个问题,通常意在考察以下几个层面的能力:
- 对并发编程基础的理解:面试者是否清楚线程安全的本质,以及实现线程安全的核心手段(如锁、CAS、不可变性)。
- 对
ConcurrentHashMap核心设计思想的掌握:不仅仅是知道它 “是线程安全的”,更要理解它如何在高并发环境下,通过精巧的设计在保证线程安全的同时,兼顾高性能。这比简单地回答 “用了锁” 要深入得多。 - 对 JDK 版本演进的关注:是否能清晰阐述
ConcurrentHashMap在 JDK 1.7 和 JDK 1.8 及之后版本中实现的重大差异,这反映了面试者跟进技术发展的能力。 - 源码级理解能力:能否深入到关键数据结构(如 Node、TreeNode)和核心操作(如
putVal、helpTransfer)的层面进行解释,展示出扎实的内功。 - 结合实际场景的思考:是否能正确评价其优缺点,并在实际开发中做出恰当的使用选择(例如,理解其弱一致性的迭代器)。
核心答案
ConcurrentHashMap 保证线程安全的核心思想是:在保证数据一致性的前提下,最大限度地减少锁的竞争,提升并发性能。 其具体实现随着 JDK 版本发生了重大演进:
- JDK 1.7:采用 分段锁(Segment) 机制。整个哈希表由多个
Segment组成,每个Segment独立加锁。写操作只锁住对应的Segment,不同Segment的写操作可以并发进行。 - JDK 1.8 及之后:摒弃了分段锁,采用了
CAS(Compare-And-Swap)乐观锁 +synchronized悲观锁 的精细粒度锁方案。将锁的粒度缩小到单个哈希桶(Node数组的每个下标位置)的头节点。只有在发生哈希冲突时,才会对链表的头节点或红黑树的根节点使用synchronized加锁。
深度解析
JDK 1.8 的实现是现代 Java 并发设计的典范,下面我们进行深入剖析。
原理与机制
- 数据结构:底层结构与
HashMap(JDK 1.8+)类似,是 “Node 数组 + 链表 / 红黑树”。关键节点Node的val和next字段都用volatile修饰,保证了线程间的可见性。 - 核心安全策略:CAS + synchronized
- 初始化与扩容(
initTable,transfer):使用CAS操作来竞争地设置数组的初始大小或标记扩容状态,避免了不必要的同步开销。例如,多个线程可能同时发现需要初始化,但只有一个线程能通过CAS成功设置初始化标识。 - 插入元素(
putVal):- 第一步:计算哈希定位到桶。如果桶为空(
null),则尝试使用CAS将新节点放入。此操作是无锁的,性能极高。 - 第二步:如果桶不为空(说明发生了哈希冲突),则使用
synchronized锁住这个桶的头节点(链表头或树根)。然后在锁内进行链表遍历插入或树节点插入。
- 第一步:计算哈希定位到桶。如果桶为空(
- 读取元素(
get):全程无锁。由于Node的字段是volatile的,get操作总能读到最新写入的值。这是它高并发读性能的基石。 - 扩容(协助扩容):这是一个精妙的设计。当有线程进行写操作时,如果发现当前哈希表正在扩容,它不会阻塞等待,而是会主动
helpTransfer,协助其他线程一起完成数据迁移工作。这种 “共同奋斗” 的机制大大提高了扩容效率。
- 初始化与扩容(
代码示例
以下是 JDK 1.8 putVal 方法关键步骤的简化伪代码,展示了其线程安全逻辑:
final V putVal(K key, V value, boolean onlyIfAbsent) {
// ... 参数校验,计算 hash ...
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
// 情况1: 表为空,CAS初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable(); // 内部使用 CAS 控制并发初始化
// 情况2: 目标桶为空,CAS插入新节点
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
break; // CAS成功,插入完成,跳出循环
// CAS失败,说明有其他线程抢先插入了,进入下一轮循环重试
}
// 情况3: 检测到正在扩容,当前线程协助扩容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
// 情况4: 目标桶非空且未在扩容,synchronized锁住头节点进行操作
else {
V oldVal = null;
synchronized (f) { // 锁粒度仅为单个桶的头节点
// 再次确认头节点未变(防止在加锁前瞬间被其他线程修改)
if (tabAt(tab, i) == f) {
if (fh >= 0) { // 链表
// ... 遍历链表,插入或更新 ...
} else if (f instanceof TreeBin) { // 红黑树
// ... 操作红黑树 ...
}
}
}
// 锁释放后,检查是否需要将链表转换为红黑树
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
// ... 返回旧值 ...
}
}
}
// ... 更新元素计数,检查扩容 ...
}
对比分析与最佳实践
-
与
Hashtable、Collections.synchronizedMap对比:Hashtable等使用对象级锁(synchronized修饰方法),任何写操作都会锁住整个表,并发性能极差。ConcurrentHashMap的锁粒度小得多(JDK 1.7 是段,JDK 1.8 是单个桶),并发写冲突的概率大大降低,实现了高并发下的高吞吐量。
-
最佳实践与注意事项:
- 适用场景:它是高并发读写的首选。对于读多写少或写多但冲突不严重的场景,性能优势极其明显。
- 弱一致性:它的迭代器、
size()、isEmpty()等方法返回的是近似值或快照,不保证强一致性(反映所有最新更新),但保证了单次调用的性能。这是 CAP 理论中偏向 AP(可用性、分区容错性)的一种权衡。 - 无锁读:
get()操作完全无锁,这是其设计的一大亮点。
-
常见误区:
- 误区一:认为
ConcurrentHashMap的所有操作都是无锁的。实际上,写冲突时的synchronized锁是存在的,只是粒度很细。 - 误区二:认为它的迭代器是 “失败安全” 的(
Fail-Safe)。严格来说,它是弱一致性的,迭代过程中可能反映部分更新,但不会抛出ConcurrentModificationException。 - 误区三:在任何并发场景下都无脑选用。如果业务需要强一致性的原子复合操作(如 “若没有则添加”),仍可能需要额外的外部同步,或使用其提供的原子方法(如
putIfAbsent、compute等)。
- 误区一:认为
总结
ConcurrentHashMap 通过 CAS 无锁化尝试 + synchronized 精细化锁 的组合拳,辅以 volatile 可见性保证 和 协助扩容机制,在数据结构层面实现了线程安全与高并发性能的卓越平衡,是 Java 并发容器中的标杆实现。理解其设计演进(从分段锁到 CAS+synchronized)和核心原理,是衡量 Java 并发编程功底的重要标尺。