HashMap 为什么线程不安全?
更新时间:2026-09-20。问题驱动短答页。HashMap 结构原理见 HashMap 底层原理与扩容/红黑树,线程安全替代见 ConcurrentHashMap 原理。
一句话回答
HashMap 从头到尾没有任何同步措施,多线程同时 put/扩容时会互相覆盖、读到不一致状态;JDK7 及以前甚至可能在扩容时让链表成环,get 陷入死循环。并发场景请直接用 ConcurrentHashMap。
一、不安全具体体现在哪
1. put 覆盖(丢失更新)
put 大致是「算 hash → 定位桶 → 空位插入或往后挂」。两个线程同时落在同一个空桶,都看到空、都写入,后写的把先写的覆盖;对同 key 改值也有读—改—写竞争。整个过程没有 CAS、没有锁。
2. 扩容期间的数据丢失与结构破坏
元素数超过 容量 × loadFactor 会 resize(翻倍并 rehash 迁移)。多个线程同时触发扩容时:
- JDK8 迁移按桶推进,缺乏同步会出现节点被重复/遗漏迁移;
- 可能一个线程刚扩容完、另一个还往旧表写,新数据丢失。
3. JDK7 头插法 → 链表成环 → get 100% CPU(经典)
JDK7 扩容用头插法且不保证顺序,两个线程并发迁移同一链表时,指针互相指向可能形成环形链表。此后 get 沿链表永远走不到 null,表现为 CPU 被打满的死循环。JDK8 改成尾插并把长链表树化成红黑树,不会再成环死循环,但仍然丢数据、仍不安全——这点常被误传为「JDK8 HashMap 就线程安全了」,并没有。
4. 读到中间状态 / 统计不准
size、modCount、桶里的链表/树结构都是无同步共享状态,可能读到扩容进行到一半的表,迭代时还可能抛 ConcurrentModificationException(fail-fast,本就不为并发设计)。
二、为什么不加个轻量保护
HashMap 的设计目标是单线程极致性能:不加锁、无内存屏障。要安全就得在每次读写加同步,那正是 Hashtable/Collections.synchronizedMap 的做法——整表一把锁,并发度极低。JDK 改用专门的数据结构在保证正确的同时尽量不牺牲吞吐。
三、并发应该用什么
| 方案 | 线程安全 | 并发度 | 建议 |
|---|---|---|---|
HashMap | ❌ | — | 只在单线程/方法内局部变量用 |
Hashtable | ✅ | 低(方法级 synchronized 整表锁) | 已过时,别用 |
Collections.synchronizedMap | ✅ | 低(包装器粗粒度锁) | 兼容旧代码,新代码不用 |
ConcurrentHashMap | ✅ | 高(JDK8 CAS + 桶级 synchronized) | 默认首选 |
ConcurrentSkipListMap | ✅ | 高,且键有序 | 需要按 key 排序/范围查时 |
ConcurrentHashMap JDK7 用分段锁(Segment),JDK8 弃用 Segment,改成「数组 + 链表/红黑树 + 空桶 CAS 插入、冲突桶 synchronized 锁单个桶头」,锁粒度更细;且不允许 null key/value(因为多线程下无法区分「不存在」与「值为 null」的歧义)。
四、常见坑
- 以为「只读不写就安全」:如果有别的线程在写、当前线程在读,没有安全发布/同步仍可能看到未构造完或扩容中的状态;初始化好后真正不再变更的 Map 用
Map.of()/Collections.unmodifiableMap发布; - 用
ConcurrentHashMap但做「check-then-act」复合操作仍不原子,要用它的computeIfAbsent/merge/putIfAbsent,见 HashMap entry 思路对照; - 把 ConcurrentHashMap 当
synchronized使:它保证单次操作线程安全,不保证多步业务序列原子。
总结
HashMap 不安全的根因是 put、resize、size 等共享操作完全没有同步:并发 put 会在同桶互相覆盖丢数据,扩容时可能丢更新或读到中间状态,JDK7 头插法扩容还会让链表成环导致 get 死循环,JDK8 改成尾插加红黑树消除了成环但依然丢数据、并不安全;并发默认改用 ConcurrentHashMap(JDK8 空桶 CAS、冲突锁单桶头,粒度细吞吐高),需要有序用 ConcurrentSkipListMap,避免整表一把锁的 Hashtable 和 synchronizedMap,复合操作要用 putIfAbsent/computeIfAbsent/merge 保证原子。