Java HashMap线程不安全的原因【源码&详细图解 rehash 并发操作出现的死循环】
HashMap在多线程下的线程不安全问题主要体现在数据丢失、死循环、size值错误等方面。本质原因在于其内部实现缺乏同步机制,且数据迁移(Rehash)过程中链表指针操作在多线程竞争下会产生问题。下面将结合关键源码(基于Java 8)和图解进行详细分析:
核心问题:并发修改导致内部结构破坏
1. 插入时数据覆盖(Lost Update)
- 原因: 多个线程同时调用
put()操作同一个桶位置(相同hash),且该位置为空。 - 源码分析(简化版putVal):
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 计算桶下标 (关键点1) i = (n - 1) & hash; p = tab[i]; // 获取头节点 if (p == null) { // 🚨 线程不安全点:多个线程可能同时判断 p == null 成立 // 🚨 导致后续的 tab[i] = newNode(...) 覆盖其他线程写入的值 tab[i] = newNode(hash, key, value, null); } else { // ...处理链表/红黑树冲突... } ++modCount; if (++size > threshold) // 🚨 线程不安全点(size计算不准确) resize(); return null; } - 情景: 线程A和线程B同时检查发现
tab[i] == null成立。A 执行tab[i] = newNode_A,紧接着B也执行tab[i] = newNode_B。结果是线程A插入的值被线程B覆盖丢失。
2. 链表结构破坏(死循环或数据丢失)— JDK7及之前的经典问题
- 核心原因: 在扩容(
resize())并迁移数据(transfer())的过程中,链表采用头插法。多线程操作会导致链表反转形成环形链表。 - JDK7的
transfer()方法源码(关键片段):void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; for (Entry<K, V> e : table) { // 遍历旧表 while (null != e) { Entry<K, V> next = e.next; // 🚨 线程A暂停点1: 记住next if (rehash) e.hash = null == e.key ? 0 : hash(e.key); int i = indexFor(e.hash, newCapacity); // 计算新位置 // 🚨 关键头插法:将e放到新链表的头部 e.next = newTable[i]; // 🚨 线程不安全的核心操作 newTable[i] = e; // 🚨 更新头节点 e = next; // 🚨 线程A暂停点2: 处理下一个节点 } } } - 图解死循环形成过程:
- 初始状态: 旧表某个桶有链表
3->7->null。两个线程 A 和 B 同时进行扩容。 - 线程A执行:
e = 3,next = 7- 将
3.next指向newTable[i](初始为null),然后newTable[i] = 3。链表变为3->null。 e = 7(从 next 来),执行暂停(假设此时线程A时间片用完)。
- 线程B执行完毕:
- 线程B成功完成整个链表的迁移。由于头插法,新链表变为
7->3->null(newTable[i]指向7)。
- 线程A恢复执行:
e = 7,next = e.next。此时e.next(即7.next) 已被线程B修改为3(不再是原始null)!所以next = 3。- 执行头插:
7.next = newTable[i]。此时newTable[i]指向的是7(B迁移后的结果),即7.next = 7已经形成环! newTable[i] = 7。e = next = 3。- 处理
3:3.next = newTable[i] = 7(又把3插到7前面)。 newTable[i] = 3。e = 3.next = 7(因为上一步3.next = 7)。- 线程A再次拿到
e = 7,然后重复之前的步骤next = 7.next = 3-> 头插 ->newTable[i] = 7->e = 3… 无限循环。 - 后果: 任何尝试访问这个桶的
get()操作都会因为遍历环形链表而进入死循环!CPU利用率飙升。
- 初始状态: 旧表某个桶有链表
- JDK8的改进:
- 使用 尾插法 替代头插法进行链表迁移。避免了反转链表,大大降低了形成环的概率。
- 但不是线程安全! 尾插法仍可能导致部分数据丢失(例如两个线程同时迁移节点,一个线程的迁移结果被另一个覆盖)或链表断裂,只是不会形成环造成死循环。
3. size计数不准确
- 原因:
size变量只是一个普通的int类型,++size(size = size + 1) 操作不是原子操作。 - 源码 (
putVal()结尾):++size; // 🚨 线程不安全点:自增操作本身是非原子的 if (size > threshold) resize(); - 情景:
- 线程A读取
size(假设当前值为10)。 - 线程B也读取
size(值仍为10)。 - 线程A计算
size + 1 = 11,并将结果写回size(size变为11)。 - 线程B也计算
size + 1 = 11(基于它之前读取的10),并将结果写回size(size仍为11)。
- 线程A读取
- 后果: 明明插入了两个元素,
size只增加了1。size()方法返回错误值,resize()判断也可能出错。
4. 红黑树操作问题(JDK8+)
- HashMap在链表长度过长时会转换为红黑树(
treeifyBin())。 - 原因: 红黑树本身插入、删除、旋转平衡的操作非常复杂,涉及多个节点指针的修改(父、左、右、颜色等)。
- 后果: 多个线程同时修改红黑树结构(插入/删除),极大概率导致树结构被破坏,可能抛出
ClassCastException(在后续操作中误认为链表处理红黑树节点),或导致查找失败、死循环等。
图解:JDK7 HashMap 扩容时并发操作死循环
- 初始状态:
![初始状态]()
- 旧表:桶
i处的链表:Entry 3 -> Entry 7 -> null。 - 新表:准备扩容后的新数组,对应桶
i初始为空。
- 线程A开始迁移(暂停前):
-
e = Entry3,next = Entry7.
![在这里插入图片描述]()
-
执行头插法迁移
Entry3:Entry3.next = newTable[i](null)newTable[i] = Entry3=> 新表桶i:3 -> null
-
e = next = Entry7. (此时线程A被挂起)
![在这里插入图片描述]()
- 线程B完整迁移:
- 线程B没有中断,完整迁移了链表。
- 使用头插法:
- 迁移
Entry3:新表桶i:3 -> null - 迁移
Entry7:Entry7.next = newTable[i](目前指向3) =>7.next = 3newTable[i] = Entry7=> 新表桶i:7 -> 3 -> null(链表反转)
- 迁移
- 结果:新表结构稳定。
![在这里插入图片描述]()
- 线程A恢复执行:
-
e = Entry7(线程A挂起时的状态) -
next = e.next。此时关键:e(Entry7) 的next已经被线程B在迁移过程中修改为指向 Entry3 了! 所以next = Entry3.
![在这里插入图片描述]()
-
迁移
Entry7(头插法):Entry7.next = newTable[i]。此时newTable[i]是线程B迁移后指向 Entry7 的头节点!所以:
Entry7.next = 7(指向了自己!)。newTable[i] = Entry7=> 桶i的头变为7。
-
e = next = Entry3.
![在这里插入图片描述]()
-
迁移
Entry3(头插法):Entry3.next = newTable[i](7) =>3.next = 7.newTable[i] = Entry3=> 桶i的头变为3。
-
e = next = Entry3.next。Entry3.next已经被上面一步设置为7, 所以e = Entry7.
![在这里插入图片描述]()
-
关键点: 现在
e = Entry7,进入下一轮循环:next = e.next = Entry7.next。Entry7.next在上面被设置为7(自己), 所以next = 7 (Entry7).- 迁移
Entry7:Entry7.next = newTable[i](现在指向3) =>7.next = 3(这和线程B创建的结构一样,但不是终点)。newTable[i] = Entry7.
e = next = 7 (Entry7)… 循环回到当前e的状态!

- 形成死循环:
- 程序会不断尝试迁移
Entry7和Entry3,但因为指针相互指向,永远无法结束。任何遍历 (get()) 此桶的操作都将进入死循环。
总结:为什么 HashMap 线程不安全?
- 缺乏同步锁: 所有内部操作(
put、get、remove、resize)都没有使用synchronized或其他显式锁保护。 - 复合操作非原子性:
- 检查桶空 + 创建新节点 (
putVal中if (p == null)块) - 链表节点指针修改 (
next赋值,头插/尾插) size变量的自增 (++size)- 红黑树结构调整
- Rehash过程的致命性: 特别是 JDK7 的头插法在多线程
resize时极易导致环形链表和死循环。JDK8 的尾插法缓解了死循环问题,但数据丢失和结构破坏风险仍在。 - 内部状态维护易受并发干扰:
modCount、size、table数组、单个桶的头节点指针都可能被多个线程同时修改,导致状态不一致。
解决方案: 在多线程环境下,务必使用线程安全的 Map 实现:
ConcurrentHashMap(首选): 高并发性能优秀,使用分段锁(JDK7)或 CAS +synchronized(JDK8+)保证线程安全。Collections.synchronizedMap(Map<K,V> m): 返回一个用synchronized包装的Map,所有方法加锁,适合低并发或需要强一致性遍历的场景(性能通常低于ConcurrentHashMap)。
本文来自博客园,作者:NeoLshu,转载请注明原文链接:https://www.cnblogs.com/neolshu/p/19120767








浙公网安备 33010602011771号