← 返回题目列表

HashMap 为什么线程不安全?

高频 中等 第 9 / 30 题 更新于 2026/07/25
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;若使用同步包装,遍历和检查后更新仍要覆盖整个操作边界。