HashMap 并发死循环问题深度解析
一、问题的核心:JDK 1.7 的链表情形
1. 1.7 HashMap 扩容机制回顾
java
// JDK 1.7 HashMap 扩容关键代码 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; // ⚠️ 关键:暂存下一个节点 if (rehash) { e.hash = null == e.key ? 0 : hash(e.key); } int i = indexFor(e.hash, newCapacity); // 计算新位置 // ⚠️ 头插法:新节点插入链表头部 e.next = newTable[i]; // 1. 当前节点指向新表对应位置的第一个节点 newTable[i] = e; // 2. 将当前节点设为新表对应位置的头节点 e = next; // 继续处理下一个节点 } } }二、死循环发生的详细过程
场景设定
假设:
初始 HashMap:容量为 2,扩容阈值为 1.5
已有节点:
A -> B -> null(A 在 table[0],A.next = B)两个线程 T1、T2 同时执行
put操作触发扩容
步骤拆解:头插法的致命缺陷
初始状态
text
旧表 table[0]: A → B → null
两个线程都开始扩容,各自创建新数组newTable(容量为4)。
线程 T1 执行到一半被挂起
java
// T1 执行状态: Entry<K,V> next = e.next; // e = A, next = B e.next = newTable[i]; // A.next = null(新表该位置为空) newTable[i] = e; // newTable[0] = A e = next; // e = B // T1 在此处被挂起!此时T1的视角: // newTable[0]: A → null // 本地变量:e = B, next = B(实际上B.next仍指向null)
线程 T2 完整执行完扩容
T2 不受干扰地完成了整个扩容过程:
text
// T2 完整执行后的结果: 新表 newTable[0]: B → A → null // 注意:因为头插法,顺序变为 B→A
此时内存中实际链表结构:
text
B.next = A A.next = null
T1 恢复执行(灾难开始)
篇幅限制下面就只能给大家展示小册部分内容了。整理了一份核心面试笔记包括了:Java面试、Spring、JVM、MyBatis、Redis、MySQL、并发编程、微服务、Linux、Springboot、SpringCloud、MQ、Kafc
需要全套面试笔记及答案
【点击此处即可/免费获取】
T1 恢复时,它不知道链表已被 T2 修改,仍基于自己挂起前的状态继续:
java
// T1 恢复时状态: // e = B(从挂起处继续) // 但此时 B.next 已被 T2 改为 A(而不是 T1 之前看到的 null) Entry<K,V> next = e.next; // e = B, next = A(❗关键变化!) e.next = newTable[i]; // B.next = newTable[0] = A // 现在:B.next = A newTable[i] = e; // newTable[0] = B e = next; // e = A // 第一轮循环结束,此时: // newTable[0]: B → A(A又是B.next,形成 B↔A 互指)
继续下一轮循环:
java
// 第二轮循环开始:e = A Entry<K,V> next = e.next; // e = A, next = null(A.next是null) e.next = newTable[i]; // A.next = newTable[0] = B // 现在:A.next = B newTable[i] = e; // newTable[0] = A e = next; // e = null // 此时链表结构: // newTable[0]: A → B → A → B → ... 无限循环 // B.next = A, A.next = B
最终形成的环形链表
text
┌─────┐ │ ↓ newTable[0]: A → B ↑ │ └─────┘
查询时:get(key)若命中 table[0] 位置,将陷入A→B→A→B...的死循环。
三、可视化死循环形成过程
时间线演示
text
时间点 | 线程T1 | 线程T2 | 实际链表结构 ------|-------------------|-------------------|------------------- t0 | 开始扩容 | 开始扩容 | A→B→null t1 | 处理A: A→null | | A→null, B独立 t2 | 挂起(e=B) | 继续执行 | - t3 | | 处理A: A→null | T2的newTable: A→null t4 | | 处理B: B→A→null | T2的newTable: B→A→null t5 | 恢复(e=B) | 完成 | 实际: B→A→null t6 | next=A(B.next=A) | | - t7 | B.next=newTable[0]| | B.next=A t8 | newTable[0]=B | | newTable: B↔A互指 t9 | e=A | | - t10 | next=null(A.next) | | - t11 | A.next=B | | 形成环: A→B→A...
关键问题根源
头插法反转顺序:新旧链表顺序相反
共享状态无保护:两个线程操作同一链表
中间状态暴露:
e和next的临时变量被另一个线程修改
四、JDK 1.8 真的解决了吗?
1. 1.8 的重大改进
java
// JDK 1.8 HashMap 扩容关键代码(简化) final Node<K,V>[] resize() { // ... 创建新数组 if (oldTab != null) { for (int j = 0; j < oldCap; ++j) { Node<K,V> e; if ((e = oldTab[j]) != null) { oldTab[j] = null; // ⭐ 关键1:清空旧桶,避免多个线程操作同一链表 if (e.next == null) // 单节点直接迁移 newTab[e.hash & (newCap - 1)] = e; else if (e instanceof TreeNode) // 红黑树处理 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); else { // ⭐ 关键2:保持顺序的尾插法 Node<K,V> loHead = null, loTail = null; // 低位链表头尾 Node<K,V> hiHead = null, hiTail = null; // 高位链表头尾 do { Node<K,V> next = e.next; // 判断节点在新表的位置(原位置或原位置+oldCap) if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; // ⭐ 尾插法 loTail = e; } else { if (hiTail == null) hiHead = e; else hiTail.next = e; // ⭐ 尾插法 hiTail = e; } } while ((e = next) != null); // 将链表放入新表 if (loTail != null) { loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }2. 1.8 如何"解决"死循环
不是完全免疫,而是大幅降低概率:
改进点1:尾插法保持顺序
java
// 1.7 头插法(导致反转): e.next = newTable[i]; // 新节点指向头 newTable[i] = e; // 新节点成为头 // 1.8 尾插法(保持顺序): if (tail == null) head = e; // 第一个节点 else tail.next = e; // 追加到尾部 tail = e; // 更新尾指针
重要:尾插法使得多线程操作时,链表不会反转顺序,从根本上避免了 1.7 那种因顺序反转导致的环形链表。
改进点2:更细粒度的状态管理
java
oldTab[j] = null; // ⭐ 关键:立即清空旧桶
这减少了多个线程同时操作同一链表的窗口期。
3. 但 1.8 仍存在的并发问题
问题1:数据丢失(未解决)
java
// 两个线程同时执行 put,可能丢失数据 Thread1: 检查table[i]==null,准备插入 Thread2: 检查table[i]==null,准备插入 // 两者都认为自己是第一个,后插入的会覆盖先插入的
问题2:size 计算不准确
java
// size 的更新不是原子的 final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { // ... if (++size > threshold) // ⚠️ 多线程同时执行会出问题 resize(); // ... }问题3:红黑树并发问题
java
// 链表转红黑树时,多个线程可能创建多个红黑树 if (binCount >= TREEIFY_THRESHOLD - 1) treeifyBin(tab, hash); // 多个线程可能重复树化
4. 并发问题复现代码(1.8)
java
public class HashMapConcurrentIssue { public static void main(String[] args) throws InterruptedException { Map<String, Integer> map = new HashMap<>(); // 两个线程同时put不同的key,但hash冲突 Thread t1 = new Thread(() -> { for (int i = 0; i < 10000; i++) { map.put("key" + i, i); } }); Thread t2 = new Thread(() -> { for (int i = 10000; i < 20000; i++) { map.put("key" + i, i); } }); t1.start(); t2.start(); t1.join(); t2.join(); // 结果大概率不是20000,说明有数据丢失 System.out.println("Size: " + map.size()); // 可能输出 19987 等 } }五、HashMap 1.7 vs 1.8 并发安全性对比
| 方面 | JDK 1.7 | JDK 1.8 | 说明 |
|---|---|---|---|
| 死循环 | ✅ 肯定发生 | ⚠️ 几乎不可能 | 1.8尾插法避免反转 |
| 数据丢失 | ✅ 存在 | ✅ 存在 | 并发put仍会覆盖 |
| size准确 | ❌ 不准 | ❌ 不准 | 计数器非原子 |
| 红黑树安全 | - | ⚠️ 可能重复树化 | 链表转树非原子 |
| get阻塞 | ✅ 会(死循环) | ⚠️ 可能读到中间状态 | 虽无死循环,但数据可能不一致 |
六、HashMap 并发问题解决方案
方案1:使用 ConcurrentHashMap(首选)
java
// 完全线程安全的HashMap替代方案 Map<String, Integer> safeMap = new ConcurrentHashMap<>(); // ConcurrentHashMap 1.8 实现原理: // 1. 分段锁 → CAS + synchronized(1.8优化) // 2. 扩容时:多线程协助迁移 // 3. size:使用 CounterCell(类似LongAdder)
方案2:使用 Collections.synchronizedMap
java
// 包装类,所有方法加synchronized Map<String, Integer> syncMap = Collections.synchronizedMap(new HashMap<>()); // 优点:简单 // 缺点:性能差(全局锁)
方案3:使用读写锁
java
public class ReadWriteMap<K, V> { private final Map<K, V> map = new HashMap<>(); private final ReadWriteLock lock = new ReentrantReadWriteLock(); public V put(K key, V value) { lock.writeLock().lock(); try { return map.put(key, value); } finally { lock.writeLock().unlock(); } } public V get(K key) { lock.readLock().lock(); try { return map.get(key); } finally { lock.readLock().unlock(); } } }七、为什么 HashMap 不设计为线程安全?
设计哲学:权衡的艺术
java
// HashMap 的设计选择 public class DesignRationale { /* 1. 性能优先:95%的使用场景是单线程 synchronized HashMap 会让单线程性能下降 10-20% 2. 分离关注点:并发需求用 ConcurrentHashMap // 单线程:HashMap(更快) Map<String, Object> singleThreadMap = new HashMap<>(); // 多线程:ConcurrentHashMap(安全) Map<String, Object> concurrentMap = new ConcurrentHashMap<>(); 3. 历史兼容:早期设计如此,改变会影响太多代码 */ }篇幅限制下面就只能给大家展示小册部分内容了。整理了一份核心面试笔记包括了:Java面试、Spring、JVM、MyBatis、Redis、MySQL、并发编程、微服务、Linux、Springboot、SpringCloud、MQ、Kafc
需要全套面试笔记及答案
【点击此处即可/免费获取】
实际工程建议
java
public class BestPractice { // ✅ 正确的使用方式 public void correctUsage() { // 场景1:方法内局部变量(线程安全) public void process() { Map<String, Object> localMap = new HashMap<>(); // ✅ // 仅当前线程使用 } // 场景2:只读的共享变量 private static final Map<String, String> CONSTANT_MAP; static { Map<String, String> temp = new HashMap<>(); temp.put("key", "value"); CONSTANT_MAP = Collections.unmodifiableMap(temp); // ✅ } // 场景3:需要线程安全的共享变量 private final Map<String, Object> sharedMap = new ConcurrentHashMap<>(); // ✅ } // ❌ 错误的使用方式 public void wrongUsage() { // 错误:多线程共享非线程安全集合 public static Map<String, Object> globalMap = new HashMap<>(); // ❌ // 错误:认为1.8的HashMap是线程安全的 // "1.8只是避免了死循环,并没有解决所有并发问题!" } }八、总结
JDK 1.7 死循环:确实存在,由头插法+链表反转在多线程下导致环形链表
JDK 1.8 "解决":
✅解决了死循环问题:改用尾插法,链表顺序不变
❌未解决数据完整性问题:仍有数据丢失、size不准等问题
⚠️未解决线程安全问题:HashMap 依然不是线程安全的// 单线程/线程封闭:HashMap(性能优)
// 多线程共享:ConcurrentHashMap(线程安全) // 简单包装:Collections.synchronizedMap(性能差)
核心结论:无论 JDK 1.7 还是 1.8,HashMap 都不是线程安全的。1.8 只是修复了最危险的死循环 bug,但并发下的数据一致性问题依然存在。在多线程环境中,应始终使用ConcurrentHashMap。