HashMap 用在并发场景中会出现什么问题?
HashMap 用在并发场景中会出现什么问题?
zhangchangsheng面试考察点
- 线程安全意识:面试官不仅仅是想知道 “HashMap 不安全” 这个结论,更是想知道你是否理解具体的并发问题类型(死循环、数据丢失、脏读等),以及这些问题在不同 JDK 版本中的差异。
- 源码理解深度:考察你是否了解 JDK 1.7 头插法导致的死循环根因,以及 JDK 1.8 尾插法为什么 “只是” 数据丢失而不是死循环。
- 解决方案掌握:是否知道
ConcurrentHashMap、Collections.synchronizedMap()等替代方案,以及它们的适用场景。
核心答案
HashMap 是非线程安全的,在并发场景下会出现以下问题: **** **** **** **** ****
| 问题类型 | JDK 1.7 | JDK 1.8 | 严重程度 |
|---|---|---|---|
| 死循环(CPU 100%) | ✅ 会发生 | ❌ 已修复 | ⚠️⚠️⚠️ 致命 |
| 数据丢失 | ✅ 会发生 | ✅ 会发生 | ⚠️⚠️ 严重 |
| 数据覆盖 | ✅ 会发生 | ✅ 会发生 | ⚠️⚠️ 严重 |
| 脏读/不可见 | ✅ 会发生 | ✅ 会发生 | ⚠️ 一般 |
| size 不准确 | ✅ 会发生 | ✅ 会发生 | ⚠️ 一般 |
一句话总结:JDK 1.7 并发扩容会死循环,JDK 1.8 会数据丢失,任何版本都不要在并发场景使用 HashMap,请用 ConcurrentHashMap。
深度解析
一、JDK 1.7 死循环问题(经典面试题)
这是 HashMap 最臭名昭著的并发问题:
flowchart TB
S0["初始状态:桶[3] → A → B → null"] --> T1["线程 1 执行到一半<br/>记录了 e=A, next=B 后挂起"]
T1 --> T2["线程 2 完成扩容<br/>头插法导致顺序反转:新桶[3] → B → A → null"]
T2 --> T3["线程 1 恢复执行"]
T3 --> S1["Step 1:把 A 插入新表头部<br/>新桶[3] → A → B → ?"]
S1 --> S2["Step 2:处理 B(B.next = A)<br/>新桶[3] → B → A"]
S2 --> S3["Step 3:处理 A(A.next = B)<br/>A → B → A → B → ..."]
S3 --> R["结果:A ⇌ B 形成环形链表"]
R --> F["get() 或 put() 遍历该桶时<br/>进入死循环 → CPU 100% → 服务假死"]
上图展示了 JDK 1.7 死循环的形成过程。关键点理解:
- 头插法是根源:JDK 1.7 的
transfer()方法使用头插法迁移元素,导致链表顺序反转 - 并发竞态条件:两个线程同时扩容,一个记录了旧指针后被挂起,另一个完成了迁移
- 指针错乱:线程 1 恢复时,基于旧的
next指针操作,但实际链表已经被线程 2 改变 - 形成环:A.next 指向 B,B.next 指向 A,形成环形链表
死循环的触发:当后续操作(get()、put()、remove())需要遍历这个桶时,会陷入while (e != null)的死循环,CPU 飙升至 100%,服务无法响应。
二、JDK 1.8 数据覆盖问题
JDK 1.8 改用尾插法,解决了死循环,但仍有并发问题:
flowchart TB
A["假设:桶[5] = null<br/>线程 1 和线程 2 同时向桶[5]插入数据"] --> S1["1. 线程 1:计算 hash,定位到桶[5]"]
S1 --> S2["2. 线程 2:计算 hash,定位到桶[5]"]
S2 --> S3["3. 线程 1:判断桶[5] == null,成立"]
S3 --> S4["4. 线程 2:判断桶[5] == null,也成立<br/>都认为桶是空的"]
S4 --> S5["5. 线程 1:newNode(\"A\"),写入桶[5]"]
S5 --> S6["6. 线程 2:newNode(\"B\"),写入桶[5]<br/>覆盖了 A"]
S6 --> R["结果:A 被覆盖丢失"]
R -.-> C["putVal 源码:<br/>if ((p = tab[i = (n - 1) & hash]) == null)<br/> tab[i] = newNode(hash, key, value, null);<br/>判断和写入两步不是原子的,中间可能被其他线程插队"]
上图展示了 JDK 1.8 的数据覆盖问题。关键点:
- 非原子操作:
判断桶是否为空和写入新节点是两个独立步骤,不是原子操作 - 竞态窗口:两个线程都判断桶为空后,后写入的线程会覆盖先写入的数据
- 数据丢失:被覆盖的数据永久丢失,且不会有任何异常提示
JDK 1.8 其他并发问题:
| 并发问题 | 具体原因 | 结果 |
|---|---|---|
| 1. 扩容期间数据丢失 | 线程 1 正在扩容还没完成;线程 2 此时 put 数据,可能写入旧数组,而线程 1 最终用新数组覆盖了 table | 线程 2 写入的数据丢失 |
| 2. size 计数不准确 | size++ 不是原子操作;JDK 1.8 虽然用了 CAS,但仍不保证准确 | 多线程同时 put,size 可能比实际少 |
| 3. 可见性问题 | HashMap 的 table 没有 volatile 修饰;线程 A 修改的数据,线程 B 可能读不到 | 数据不一致,产生脏读 |
三、JDK 1.7 vs JDK 1.8 并发问题对比
| 对比项 | JDK 1.7 | JDK 1.8 |
|---|---|---|
| 链表插入方式 | 头插法 | 尾插法 |
| 扩容时链表顺序 | 反转 | 保持不变 |
| 死循环问题 | 会发生 | 已修复 |
| 数据覆盖问题 | 会发生 | 会发生 |
| 问题严重程度 | 致命(CPU 100%) | 严重(数据丢失) |
| 线程安全性 | 不安全 | 不安全 |
核心区别:JDK 1.7 头插法 + 并发扩容会形成环形链表导致死循环;JDK 1.8 尾插法保持顺序、不会形成环,但仍有数据竞态问题。
上图对比了两个版本的核心差异。理解要点:
- JDK 1.7 更危险:死循环会导致整个服务不可用,是最致命的问题
- JDK 1.8 不安全:虽然不会死循环,但数据丢失同样不可接受
- 共同点:两个版本都不是线程安全的,都不应该在并发场景使用
四、正确的替代方案
| 方案 | 实现原理 | 性能 | 适用场景 |
|---|---|---|---|
| ConcurrentHashMap | CAS + synchronized(JDK 1.8) | ⭐⭐⭐⭐⭐ | 首选方案,高并发 |
| Collections.synchronizedMap() | 全表 synchronized | ⭐⭐ | 低并发,简单场景 |
| Hashtable | 全表 synchronized | ⭐ | 遗留代码,不推荐 |
// ✅ 推荐方案 1:ConcurrentHashMap(首选) |
五、ConcurrentHashMap 为什么安全?
flowchart TB
A["JDK 1.8 实现:CAS + synchronized 保证线程安全"] --> W["写操作"]
A --> R["读操作:完全无锁<br/>volatile 保证可见性"]
W --> C{"桶为空?"}
C -->|"是"| CAS["CAS 操作插入"]
C -->|"否"| S["synchronized 锁桶头节点"]
S -.-> G["只锁单个桶(桶级粒度)<br/>而不是整个表"]
R -.-> V["Node 源码:<br/>volatile V val;<br/>volatile Node<K,V> next;<br/>用 volatile 修饰保证可见性"]
面试高频追问
- 为什么 JDK 1.8 改用尾插法?
- 保持链表顺序不变,避免并发扩容时形成环形链表
- 但这只是解决了死循环,并没有解决线程安全问题
- ConcurrentHashMap 和 Hashtable 的区别?
Hashtable用 synchronized 锁整个表,性能差ConcurrentHashMap只锁单个桶,并发度高Hashtable不允许 null 键值,ConcurrentHashMap也不允许
- Collections.synchronizedMap 和 ConcurrentHashMap 怎么选?
- 低并发场景:两者都可以
- 高并发场景:必须用
ConcurrentHashMap,性能差距明显
常见面试变体
- “HashMap 为什么不是线程安全的?”
- “JDK 1.7 HashMap 死循环是怎么形成的?”
- “ConcurrentHashMap 如何保证线程安全?”
- “HashMap 和 Hashtable 的区别?”
记忆口诀
JDK 1.7:头插扩容会反转,并发形成环形链,CPU 飙升服务挂。 JDK 1.8:尾插顺序不变了,死循环虽修复,数据覆盖仍存在。 解决方案:并发就用 ConcurrentHashMap,锁粒度细性能高。
总结
HashMap 在并发场景下,JDK 1.7 会因头插法导致死循环(CPU 100%),JDK 1.8 虽用尾插法避免了死循环,但仍存在数据覆盖/丢失问题。任何版本的 HashMap 都不是线程安全的,并发场景必须使用 ConcurrentHashMap,它通过 CAS + 桶级 synchronized 实现高并发安全。
声明:本文转载自小哈的博客 《HashMap 用在并发场景中会出现什么问题?》,版权归原文作者所有。转载请保留本声明与原文链接。
评论
匿名评论隐私政策
WalineTwikoo
