HashMap 追求高效,但不保证多线程安全;ConcurrentHashMap 在尽量保持高并发性能的同时,保证并发访问安全。
而且这个题一定要分 JDK 1.7 和 JDK 1.8 来讲,因为实现变化很大:
JDK 1.7:
Segment 分段锁
JDK 1.8:
Node数组 + 链表/红黑树
+ CAS + synchronized一、先看 JDK 1.7:Segment 分段锁
JDK 7 的 ConcurrentHashMap 可以粗略理解:
ConcurrentHashMap
│
↓
Segment[] 数组
│
┌──────┼──────┬──────┐
↓ ↓ ↓ ↓
Seg0 Seg1 Seg2 Seg3
│ │ │ │
Hash Hash Hash Hash
Table Table Table Table每个 Segment 管理一部分数据。
而 Segment 本身类似:
static final class Segment<K,V>
extends ReentrantLock {
...
}也就是说:
每一个 Segment 都可以看成一把锁。
假设两个线程:
线程 A → 修改 Segment 1
线程 B → 修改 Segment 3因为不是同一个 Segment:
线程A ─→ Segment1 🔒
线程B ─→ Segment3 🔒可以同时操作。
只有:
线程A ─┐
├→ Segment1
线程B ─┘才会发生锁竞争。
这就是所谓的:
分段锁 Segment Lock。
相比于直接给整个 Map 加一把大锁:
整个 Map 🔒
↑ ↑
线程A 线程B并发度高很多。
二、JDK 1.8 为什么取消 Segment?
JDK 8 进一步把锁的粒度降低了。
底层结构和 JDK 8 HashMap 很像:
ConcurrentHashMap
table[]
│
├── [0] null
│
├── [1] Node → Node → Node
│
├── [2] null
│
├── [3] Node → Node
│
└── [4] TreeBin
↓
红黑树也就是:
数组 + 链表 + 红黑树
但是线程安全机制变成:
CAS + synchronized + volatile 等并发机制。
你现在重点学 JDK 8 就行。
三、JDK 8 ConcurrentHashMap 怎么保证线程安全?⭐⭐⭐⭐⭐
先记住一句:
空桶插入主要通过 CAS;桶内已有数据时,主要通过 synchronized 锁住桶头节点进行修改。
假设:
table
0 → null
1 → A → B
2 → null
3 → C → D → E现在:
线程1 put(X)
线程2 put(Y)
线程3 put(Z)四、情况一:桶是空的 → CAS
假设线程 A:
map.put("Java", 100);计算出来:
index = 2发现:
table[2] == null这时候没有必要:
加锁
↓
创建Node
↓
解锁ConcurrentHashMap 会尝试通过 CAS 把节点放进去。
概念上:
table[2]
期望值:null
新值:Node(Java,100)
↓ CAS
如果table[2]仍然是null
↓
插入成功五、CAS 是什么意思?
CAS:
Compare And Swap,比较并交换。
可以简单理解:
我认为这个位置现在还是 null
↓
实际检查一下
↓
┌─────┴─────┐
↓ ↓
确实null 已被别人修改
↓ ↓
写入Node CAS失败比如线程 A、B 同时发现:
table[2] = null都准备插入:
线程A:
null → Node A
线程B:
null → Node B如果 A 先 CAS 成功:
table[2] → Node AB 再执行 CAS:
期望:null
实际:Node A
↓
CAS失败所以不会出现:
A写进去
↓
B直接把A覆盖B 会重新进入相应处理流程。
因此:
空桶不需要 synchronized,CAS 就能完成安全插入。
六、情况二:桶不为空 → synchronized
假设:
table[3]
↓
A → B → C线程准备往这个桶插入 D。
由于现在要操作已有链表,单纯一个 CAS 就不好处理整个链表修改过程。
所以会:
synchronized (f) {
// 操作这个桶
}这里 f 可以理解成:
当前桶的头节点。
所以:
table[3]
↓
A 🔒 → B → C锁住 A,相当于控制对这个桶结构的并发修改。
七、是不是把整个 ConcurrentHashMap 锁住?
不是。
这是最重要的地方。
假设:
table
0 → A → B
1 → C → D
2 → E → F
3 → G → H线程 1 修改:
桶0锁:
A 🔒线程 2 修改:
桶3锁:
G 🔒于是:
线程1 → 桶0 🔒
线程2 → 桶3 🔒两个线程可以并行。
只有两个线程同时修改同一个桶:
线程1 ─┐
↓
A 🔒 → B → C
↑
线程2 ─┘才会发生明显锁竞争。
所以 JDK 8 相比 JDK 7:
JDK 7:
锁粒度 ≈ Segment
JDK 8:
锁粒度进一步细化到桶级别这也是 JDK 8 ConcurrentHashMap 高并发性能的重要原因。
八、读取 get() 需要加锁吗?
通常:
不需要。
例如:
map.get("Java");不会先:
synchronized (...)再读取。
ConcurrentHashMap 内部通过 volatile、安全发布等机制保证必要的可见性。
你现在可以先理解成:
写:
CAS / synchronized
↓
安全修改数据
读:
通常不加锁
↓
直接读取所以:
ConcurrentHashMap 不是“所有操作都加 synchronized”。
如果所有读写都加一把大锁,那并发性能就没那么好了。
九、把 put() 流程串起来 ⭐⭐⭐⭐⭐
面试非常重要。
假设:
map.put(key, value);可以先按这个简化流程理解:
put(key,value)
↓
计算 hash
↓
table 是否初始化?
↓否 ↓是
初始化
↓
找到对应桶位置
↓
桶是否为空?
↙ ↘
是 否
↓ ↓
CAS插入 判断特殊节点
↓ ↓
成功? 普通链表/树结构
↙ ↘ ↓
是 否 synchronized
↓ ↓ 锁桶头节点
完成 重试 ↓
再次确认桶没变
↓
链表/红黑树
插入/更新
↓
必要时树化实际源码还涉及初始化、扩容协助、计数等更复杂逻辑,但实习八股先把这个骨架吃透。
十、ConcurrentHashMap 怎么扩容?
这也是 JDK 8 很漂亮的设计。
普通 HashMap:
一个执行线程
↓
resize
↓
迁移所有桶ConcurrentHashMap 支持:
多个线程协助扩容。
例如旧数组:
table
0
1
2
3
4
5
6
7线程 A:
迁移 6、7线程 B 发现正在扩容:
我也来帮忙
↓
迁移 4、5线程 C:
迁移 2、3可以粗略理解:
旧 table
┌──────────┐
│ 0 1 │ ← Thread C
│ 2 3 │ ← Thread B
│ 4 5 │ ← Thread A
│ 6 7 │
└──────────┘
多个线程协作
↓
newTable实际任务划分不是简单固定成我这里画的每线程两个桶,这只是帮助你理解。
十一、扩容时别人来操作这个桶怎么办?
ConcurrentHashMap 会使用一种特殊节点:
ForwardingNode你可以理解成:
“这个桶的数据已经搬走了,请去新数组找。”
例如:
旧table[3]
原来:
A → B → C
迁移以后:
旧table[3]
↓
ForwardingNode
↓
“数据去 newTable 了”其他线程发现:
hash == MOVED就知道:
正在发生扩容。
写线程可能参与帮助迁移;读取也可以沿相应机制到新表继续查找。
所以它不像:
整个Map扩容
↓
所有线程全部停下来而是设计了并发迁移机制。
十二、为什么 ConcurrentHashMap 不允许 null?⭐⭐⭐⭐
这个也是经典追问。
HashMap:
map.put(null, value); // 可以
map.put(key, null); // 可以ConcurrentHashMap:
map.put(null, value); // ❌
map.put(key, null); // ❌其中一个重要原因是:
在并发环境下,如果:
V value = map.get(key);返回:
null如果允许存 null,你就难以仅凭这次 get 区分:
情况1:
这个 key 不存在
情况2:
这个 key 存在
但是 value == null在普通 Map 中还能结合 containsKey():
map.containsKey(key);但在并发 Map 中:
get(key)
↓
另一个线程修改Map
↓
containsKey(key)两个操作之间状态可能已经变化。
所以 ConcurrentHashMap 直接规定:
Key 和 Value 都不能为 null。
这样:
get(key) == null就可以明确表达当前读取语义下没有对应映射。
十四、把 HashMap 和 ConcurrentHashMap 对起来
你现在已经学过 HashMap,所以最好这样记:
Map
│
┌─────────┴─────────┐
↓ ↓
HashMap ConcurrentHashMap
│ │
线程不安全 线程安全
│ │
JDK8:数组 JDK8:数组
+链表+红黑树 +链表+红黑树
│
┌─────┴─────┐
↓ ↓
空桶 非空桶
↓ ↓
CAS synchronized
↓
锁桶头节点Q:ConcurrentHashMap 是怎么实现线程安全的?
ConcurrentHashMap 在 JDK 7 和 JDK 8 中实现不同。JDK 7 主要采用 Segment 分段锁,每个 Segment 继承 ReentrantLock,不同 Segment 可以并发操作,从而提高并发度。
JDK 8 取消了 Segment,底层采用 Node 数组、链表和红黑树,主要通过 CAS、synchronized 以及 volatile 等机制保证并发安全。put 时,如果目标桶为空,可以通过 CAS 尝试插入;如果桶中已经存在节点,则通常使用 synchronized 锁住桶头节点,再进行链表或者红黑树的修改,因此锁粒度比较细。读取操作通常不需要加锁。
另外 JDK 8 的 ConcurrentHashMap 在扩容时支持多个线程协助迁移,并通过 ForwardingNode 标识已经迁移的桶,从而提高并发扩容效率。
JDK 7:Segment 分段锁。
JDK 8:CAS + synchronized。
空桶 CAS,非空桶锁桶头。
读通常不加锁,扩容可以多线程协助。