HashMap 为什么线程不安全?
简化版
HashMap 没有任何同步措施,多线程并发写会出问题:JDK 7 用头插法,并发扩容时可能形成链表环,导致后续 get 死循环、CPU 飙到 100%;JDK 8 改成尾插法解决了死循环,但并发 put 仍可能丢数据(覆盖彼此的写入)、计数 size 错乱。所以并发场景要用 ConcurrentHashMap。
详细版
HashMap 的读写、扩容都没加锁,并发下有三类问题:
① JDK 7 并发扩容 → 链表死循环(最经典)
JDK 7 扩容时用头插法转移链表节点,转移过程会反转链表顺序。两个线程同时扩容时,可能让链表节点互相指向、形成环形链表。之后任何线程 get 到这个桶,遍历时就会在环里无限循环,CPU 100%,是很难排查的生产事故。
② JDK 8 并发 put → 数据覆盖
JDK 8 改用尾插法,消除了死循环。但并发 put 时若两个线程同时命中同一个空桶:
// HashMap.putVal 简化:判断桶为空就放入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null); // 线程 A、B 都判断为空,后写的覆盖先写的
两个线程都判断桶为空、各自写入,后写的覆盖先写的,其中一个 key 丢失。
③ size 计数错乱
++size 不是原子操作,并发下多个线程同时改,导致 size 值不准。
完整版教学
一、根源:复合操作 + 无锁
HashMap 线程不安全的根本原因是——它的核心操作(put、扩容、size++)都是多步复合操作,却没有任何同步。
以 put 为例,它要「算 hash → 定位桶 → 判断桶是否为空/key 是否存在 → 写入 → 判断是否扩容 → size++」一长串。多线程交错执行这些步骤,就会在各种时序下出错。无锁 + 复合操作 = 天然不安全。这不是某个 bug,而是它设计上就只面向单线程。
二、JDK 7 死循环细节:头插法的锅
JDK 7 扩容 transfer 时,遍历旧桶链表,把每个节点头插到新桶。头插会让链表顺序反转(A→B→C 变成 C→B→A)。
单线程没问题,但两个线程同时扩容同一个桶时:线程 1 刚读到「A、指向 B」就被挂起,线程 2 完成了整个反转(B→A),此时线程 1 恢复,还按自己看到的旧引用操作,就可能让 A 指向 B、B 又指向 A,形成环。之后遍历这个桶的 get(key) 永远走不到链表尾,死循环。
这是 JDK 7 时代非常著名的线上事故场景——某个接口 CPU 突然打满、线程 dump 显示卡在
HashMap.get,十有八九就是它。
三、JDK 8 为什么改了还是不安全
JDK 8 把头插改成尾插(保持链表原顺序),扩容时用高低位链拆分,不再反转,从而根治了死循环。但它只解决了死循环,没解决并发写的正确性——上面的「数据覆盖」和「size 错乱」依然存在。
所以要记清楚这个层次:JDK 8 修复的是死循环,不是线程安全。HashMap 在 JDK 8 依然线程不安全,只是不会再 CPU 100% 而已。
四、并发场景的正确选择
- 首选 ConcurrentHashMap:细粒度锁 + CAS,安全且高并发(详见「ConcurrentHashMap 如何保证线程安全」)。
- Collections.synchronizedMap / Hashtable:全表锁,安全但慢,一般不推荐。
- 只在单线程用 HashMap,或用外部锁自己保证同步。
记住:HashMap 只在单线程或明确加锁时用,并发一律 ConcurrentHashMap。
五、用线程交错看清丢数据
假设容量 16 的第 3 个桶为空,线程 A 要写 keyA,线程 B 要写 keyB,而且两个 key 恰好落入该桶。两线程都先读到 table[3] == null,随后各自创建节点;A 先写入,B 后写入同一个数组槽,最终 keyA 节点不再被 table 引用,形成典型 lost update。
时间 线程 A 线程 B
T1 读 table[3] → null
T2 读 table[3] → null
T3 table[3] = nodeA
T4 table[3] = nodeB
结果 nodeA 丢失,桶中只剩 nodeB
即使两个 key 落在不同桶,++size 仍是“读旧值、加一、写回”三步。若 A、B 都读到 size=10,分别计算 11 再写回,实际新增 2 个元素却只得到 size=11。volatile 最多让写入更快被看见,无法把这串读改写合并成原子事务。
| 方案 | 单方法安全 | 复合操作 | 并发性能 |
|---|---|---|---|
HashMap | 否 | 外部全程加锁才行 | 单线程最好 |
Collections.synchronizedMap | 是 | 仍需在包装对象上同步 | 全表串行 |
Hashtable | 是 | 组合逻辑仍需协调 | 遗留全表锁 |
ConcurrentHashMap | 是 | 使用 compute/merge 等原子 API | 桶级并发 |
| 不可变 Map | 发布后只读安全 | 不支持原地更新 | 读路径简单 |
六、常见误区与追问
- 误区:JDK 8 修复扩容环后 HashMap 就线程安全了。 它只消除了经典环问题,并发覆盖、计数错误和可见性问题仍存在。
- 误区:给 HashMap 引用加 volatile 就能安全并发写。 volatile 只作用于引用本身的可见性,不能保护内部多步结构更新。
- 误区:多个线程只调用 get 就一定有问题。 正确构造并安全发布后纯只读通常可行,危险来自与无同步写入并发或发布不安全。
- 追问:为什么
synchronizedMap遍历仍要手工加锁? 迭代由多次方法调用组成,必须在包装 Map 本身上锁住整个遍历周期。 - 追问:ConcurrentHashMap 能否直接替换所有 HashMap? 不能,它不允许 null,成本与迭代语义也不同;单线程或构造后只读仍可选 HashMap。
- 追问:
computeIfAbsent是否适合任何初始化? 映射函数应短小、可控,避免慢 I/O、递归更新和不可重复副作用。
记忆钩子:线程不安全不是只指“会不会死循环”,任何一次交错能让结构、值或计数不符合串行结果,都属于正确性破坏。
“先构建、后只读”必须配合安全发布。线程 A 创建并填充 HashMap 后,如果只是把引用写进普通共享字段,线程 B 未必建立 happens-before 关系;把引用保存在 final 字段并正确完成构造、通过 volatile 发布,或在同一锁边界交接,才能保证初始化写入可见。
final class Registry {
private final Map<String, Handler> handlers;
Registry(Map<String, Handler> source) {
this.handlers = Map.copyOf(source); // 构造时冻结快照
}
Handler find(String key) { return handlers.get(key); }
}
不可变快照把并发模型从“多个线程原地改一张表”改成“构造新表后一次替换引用”。它适合配置和路由整体更新,但不适合每次只改一个 key 的超大 Map,因为每次全量复制同样有 O(n) 成本。
如果选择外部锁,所有读写路径必须遵守同一锁协议;只给某个写方法加锁,而其他代码绕过锁直接 get/put,仍然没有线程安全。锁的正确性来自共同约定,不来自某个方法上孤立的 synchronized 关键字。
七、加强记忆
HashMap 的 put、扩容和 size 更新都是无同步的多步操作,线程交错会造成节点覆盖、计数丢失和结构不可见。JDK 7 头插迁移还可能反转链表并形成环,JDK 8 的尾插与高低链拆分只修复这一历史问题,没有增加并发保护。共享可变映射优先 ConcurrentHashMap,并用原子复合 API;若使用同步包装,遍历和检查后更新仍要覆盖整个操作边界。