ConcurrentHashMap 是如何保证线程安全的?
ConcurrentHashMap 是如何保证线程安全的?
zhangchangsheng面试考察点
- 版本差异理解:面试官不仅仅是想知道 “用了锁” 这个结论,更是想知道你是否了解 JDK 1.7(分段锁)和 JDK 1.8(CAS + synchronized)两种截然不同的实现方式。
- 并发原理深度:考察你是否理解 CAS 无锁操作、synchronized 锁升级、volatile 可见性等底层机制,以及它们如何协同工作。
- 对比分析能力:能否说清楚 ConcurrentHashMap 与 Hashtable、
Collections.synchronizedMap()的区别,以及为什么性能更好。
核心答案
ConcurrentHashMap 在 JDK 1.8 中通过 “CAS + synchronized + volatile” 三者协同保证线程安全: **** **** ****
| 机制 | 作用 | 应用场景 |
|---|---|---|
| CAS | 无锁更新 | 空桶插入、size 计数 |
| synchronized | 锁桶头节点 | 哈希冲突时的插入/更新/删除 |
| volatile | 保证可见性 | Node 的 val 和 next 字段 |
一句话总结:JDK 1.8 用 CAS 尝试无锁插入,失败则 synchronized 锁单个桶,配合 volatile 保证可见性,实现高并发安全。
深度解析
一、JDK 1.7 vs JDK 1.8 实现对比
| 维度 | JDK 1.7:分段锁 | JDK 1.8:CAS + synchronized |
|---|---|---|
| 锁实现 | Segment + ReentrantLock,Segment 内持有 HashEntry[] | CAS + synchronized,锁桶头节点(Node) |
| 锁粒度 | 一个 Segment(默认 16 个)保护多个桶 | 单个桶(Node),只锁被访问的桶头节点 |
| 并发度 | 最多 16 个线程同时写(等于 Segment 数量) | 理论上等于桶数量(极高) |
演进方向:更细粒度锁 → 更高并发性能,性能显著提升。
上图对比了两个版本的核心差异。关键理解:
- JDK 1.7 分段锁:将数据分成多个 Segment(默认 16 个),每个 Segment 是一个小的 HashMap,用
ReentrantLock保护。并发度受限于 Segment 数量。 - JDK 1.8 桶级锁:放弃 Segment,直接对每个桶的头节点加锁。并发度等于桶数量,远高于分段锁。
- 为什么改? 分段锁的 Segment 数量固定,无法动态扩展;桶级锁更细粒度,并发度更高。
二、JDK 1.8 put 操作流程
flowchart TB
A["1. 计算 hash"] --> B{"2. 桶为空?"}
B -->|"是"| C["CAS 尝试插入<br/>成功 → 返回<br/>失败 → 自旋重试"]
B -->|"否"| D{"3. 桶正在扩容?"}
D -->|"是"| E["帮忙扩容(多线程协同)"]
D -->|"否"| F["4. synchronized 锁桶头节点<br/>遍历链表/红黑树<br/>找到则更新 value,没找到则插入新节点"]
F --> G["5. 检查是否树化<br/>链表长度 ≥ 8 → 转红黑树"]
上图展示了 put() 方法的核心流程。关键点:
- 步骤 2 - CAS 无锁插入:如果桶为空,用 CAS 操作直接插入,无需加锁,性能最优
- 步骤 3 - 协助扩容:如果检测到正在扩容,当前线程会帮忙迁移数据
- 步骤 4 - synchronized 锁桶头:只有桶不为空时才加锁,且只锁当前桶的头节点
- 锁粒度:不同桶的操作可以完全并发,互不影响
三、三大安全机制详解
| 机制 | 使用场景 | 原理 | 优势 |
|---|---|---|---|
| CAS(无锁操作) | 空桶插入、size 计数更新 | 当前值 == 期望值时,原子地更新为新值(由 CPU 保证),否则返回失败并重试 | 无锁,不阻塞线程,性能高 |
| synchronized 锁桶头节点 | 哈希冲突时的插入、更新、删除 | synchronized(node) 锁桶的第一个节点,遍历链表/红黑树执行插入或更新 | 锁粒度细(只锁一个桶);JVM 优化(偏向锁 → 轻量级锁 → 重量级锁升级);低竞争时性能接近无锁 |
| volatile 保证可见性 | Node 的 val 和 next 字段 | 一个线程修改 val/next,其他线程立即可见;禁止指令重排序 | 读操作无需加锁,直接读取即可 |
上图详细解释了三大安全机制。关键理解:
- CAS:用于无竞争场景,比如空桶插入,不需要阻塞等待
- synchronized:用于有竞争场景,但只锁单个桶,不影响其他桶的操作
- volatile:保证读操作的可见性,读操作完全无锁
四、核心源码解析
// ConcurrentHashMap.putVal() 核心逻辑(简化版) |
五、与 Hashtable、Collections.synchronizedMap 对比
| 对比项 | Hashtable | Collections.synchronizedMap | ConcurrentHashMap |
|---|---|---|---|
| 锁粒度 | 整个表 | 整个表 | 单个桶 |
| 锁类型 | synchronized | synchronized | CAS + synchronized |
| 读操作 | 加锁 | 加锁 | 无锁(volatile) |
| 写操作 | 阻塞其他所有操作 | 阻塞其他所有操作 | 只阻塞同桶操作 |
| 并发度 | 1(串行) | 1(串行) | 桶数量(极高) |
| null 键值 | ❌ 不允许 | ✅ 允许(1 个 null key) | ❌ 不允许 |
| 性能 | ⭐ | ⭐ | ⭐⭐⭐⭐⭐ |
flowchart LR
subgraph HT["Hashtable / synchronizedMap:全局锁"]
T1["Thread1: put()"] --> L["全局锁"]
T2["Thread2: get()"] --> L
T3["Thread3: put()"] --> L
L --> S["串行执行<br/>3 个线程必须排队,吞吐量 = 1"]
end
subgraph CHM["ConcurrentHashMap:锁单个桶"]
C1["Thread1: put(桶0)"] --> K1["锁桶0"]
C2["Thread2: get(桶5)"] --> K2["无锁"]
C3["Thread3: put(桶9)"] --> K3["锁桶9"]
K1 --> P["并行执行<br/>3 个线程同时执行,吞吐量 ≈ 桶数量"]
K2 --> P
K3 --> P
end
六、读操作为什么不需要加锁?
// get() 方法:完全无锁 |
为什么读不需要加锁?
- volatile 保证可见性:一个线程修改了
val或next,其他线程立即能看到最新值 - 不可变结构:Node 的
hash和key是final的,不会被修改 - 安全发布:新节点通过 CAS 或 synchronized 写入,保证构造完成后再发布
面试高频追问
- ConcurrentHashMap 能完全替代 Hashtable 吗?
- 几乎可以!但要注意:ConcurrentHashMap 不允许 null 键值,Hashtable 也不允许
- 如果代码依赖 null 键值,需要修改
- ConcurrentHashMap 的迭代器是 fail-fast 还是 fail-safe?
- fail-safe!迭代时不会抛
ConcurrentModificationException - 但可能读到 “弱一致性” 的数据(迭代期间的修改可能看不到)
- fail-safe!迭代时不会抛
- size() 方法如何保证准确性?
- JDK 1.8 使用
LongAdder思想:分散热点,多个计数器累加 - 返回的是一个 “近似值”,在并发环境下不完全准确
- JDK 1.8 使用
- 为什么用 synchronized 而不是 ReentrantLock?
- synchronized 在 JDK 1.6 后有大量优化(锁升级)
- 内存开销更小(不需要额外创建 Lock 对象)
- 在低竞争场景性能相当,高竞争场景更省内存
常见面试变体
- “ConcurrentHashMap 和 Hashtable 的区别?”
- “JDK 1.7 和 1.8 的 ConcurrentHashMap 有什么不同?”
- “ConcurrentHashMap 的 get() 需要加锁吗?为什么?”
- “ConcurrentHashMap 如何统计 size?”
记忆口诀
JDK 1.8 三大机制:CAS 先试无锁插,失败 synchronized 锁桶头,volatile 保可见读无锁。 锁粒度:只锁桶头不锁表,不同桶并发跑。 对比:Hashtable 全表锁性能差,ConcurrentHashMap 桶级锁并发高。
总结
ConcurrentHashMap 在 JDK 1.8 通过 CAS + synchronized + volatile 三者协同保证线程安全:CAS 用于空桶无锁插入,synchronized 用于哈希冲突时锁单个桶头节点,volatile 保证 Node 的 val 和 next 可见性。与 Hashtable 的全表锁相比,ConcurrentHashMap 的 桶级锁 实现了极高的并发度,读操作完全无锁。记住:锁粒度细、读无锁、写只锁桶。
声明:本文转载自小哈的博客 《ConcurrentHashMap 是如何保证线程安全的?》,版权归原文作者所有。转载请保留本声明与原文链接。
评论
匿名评论隐私政策
WalineTwikoo
