一、概述
HashMap 是 Java 中最常用的散列表实现,但它 并不是线程安全的。在多线程环境下,如果多个线程同时操作 HashMap(特别是执行 put() 操作),可能会出现如下问题:
- 数据被覆盖(覆盖写)
- 数据丢失(丢键值对)
- 链表形成死循环(JDK 1.7 中 resize)
二、覆盖写是怎么发生的?
背景:
在 put(K key, V value) 时,HashMap 会:
- 计算键的 hash 值
- 定位到数组的某个桶(bucket)
- 遍历该桶链表,看是否存在该 key
- 如果不存在,就创建新节点插入链表头部
问题发生:
假设两个线程 T1 和 T2 同时调用 put(k1, v1),都计算出相同的 hash 值(即冲突),定位到同一个桶。
-
T1检查当前桶链表,没有发现k1,准备插入新节点 A -
T2也同时判断没有k1,准备插入新节点 B
最终结果可能是:
-
T1插入 A,接着T2插入 B(或反之) - 导致链表中只有一个
k1对应的节点,另一个被覆盖
🟡 本质原因:两个线程并发执行 put 操作,没有加锁,导致链表结构不一致,后写的覆盖了先写的。
三、丢键值对是怎么发生的?
场景:
两个线程 T1 和 T2,都向不同桶中写入数据:
-
T1向桶 A 写入 key1 -
T2向桶 B 写入 key2
但由于 HashMap 的底层数组是共享的,并且扩容和插入都依赖非原子操作,因此会发生以下竞态问题:
可能情况:
-
T1将 key1 插入桶 A,但在执行过程中,T2正在扩容并拷贝旧数据到新数组。 -
T1完成插入后,但写入的是旧数组。 -
T2扩容完成并用新数组替换旧数组,此时旧数组中的key1没有被迁移过去。
🟥 最终结果:key1 被丢失,HashMap 中找不到 key1。
四、JDK 1.7 的死循环问题
在 JDK 1.7 中,HashMap 的扩容采用的是链表头插法,当多个线程并发触发 resize(),可能发生链表环形连接,导致死循环:
while (e != null) {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
上述代码在无同步控制下可能导致某节点的 next 指向自身或前面节点,形成 环形链表,遍历时永远无法终止,CPU 飙升。
五、总结:为何线程不安全?
| 操作 | 问题描述 |
|---|---|
| put() 并发 | 导致覆盖写、数据丢失 |
| resize() 并发 | 导致链表错乱、死循环 |
| 缺乏同步机制 | 没有加锁、没有原子操作保护 |
六、如何解决?
- ✅ 使用
ConcurrentHashMap(JDK 8 后基于 CAS + synchronized,线程安全) - ✅ 用
Collections.synchronizedMap()包装 HashMap - ❌ 不建议在多线程中直接使用 HashMap
七、面试总结话术
HashMap 是线程不安全的,原因包括:put 操作非原子,可能出现覆盖写或数据丢失;在并发扩容时可能出现链表死循环(JDK 1.7)。这些问题的根本原因是缺乏同步机制。在并发场景下建议使用 ConcurrentHashMap 替代。