Java随笔-JDK7 HashMap头插法为何能导致死循环?

一、JDK7 HashMap 的扩容机制

当 HashMap 中的元素数量超过 容量 × 负载因子(默认 16 × 0.75 = 12)时,会触发 resize() 进行扩容:

java 复制代码
// JDK7 扩容核心逻辑(简化)
void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    Entry[] newTable = new Entry[newCapacity];  // 创建新数组,长度翻倍
    transfer(newTable);                          // 将旧数组数据迁移到新数组
    table = newTable;
}

transfer() 方法:头插法迁移

java 复制代码
void transfer(Entry[] newTable) {
    Entry[] src = table;
    int newCapacity = newTable.length;
    for (int j = 0; j < src.length; j++) {  // 遍历旧数组每个桶
        Entry<K,V> e = src[j];
        if (e != null) {
            src[j] = null;  // 释放旧引用
            do {
                Entry<K,V> next = e.next;  // 保存下一个节点
                
                // 重新计算在新数组中的位置(长度翻倍,hash 重新散列)
                int i = indexFor(e.hash, newCapacity);
                
                // ========== 头插法核心 ==========
                e.next = newTable[i];  // 新节点的 next 指向新数组当前桶的头节点
                newTable[i] = e;       // 新数组桶的头指针指向当前节点
                
                e = next;  // 处理下一个节点
            } while (e != null);
        }
    }
}

头插法的效果:每次插入新节点时,都把它放到链表头部,原来的头节点变成它的 next。

二、单线程下头插法没问题

假设某个桶的链表是:A → B → C(A 是头节点)

扩容后迁移过程:

复制代码
第1轮:e=A, next=B
  newTable[i] = null
  A.next = null
  newTable[i] = A
  结果:newTable[i] = A

第2轮:e=B, next=C
  newTable[i] = A
  B.next = A
  newTable[i] = B
  结果:newTable[i] = B → A

第3轮:e=C, next=null
  newTable[i] = B → A
  C.next = B → A
  newTable[i] = C
  结果:newTable[i] = C → B → A

单线程下,链表顺序被反转(A→B→C 变成 C→B→A),但结构正确,不会丢失数据。

三、并发扩容为什么导致死循环

场景假设

两个线程 T1 和 T2 同时执行扩容,某个桶的链表为 A → B(只有两个节点,简化说明)。

关键前提 :T1 和 T2 都进入了 transfer() 方法,都创建了自己的 newTable,但操作的是同一个旧数组 src

执行时序(死循环形成过程)

复制代码
时刻1:T1 执行到 e=A, next=B,然后 T1 被挂起(时间片用完)

时刻2:T2 执行完整的 transfer()
  - 把 A 头插:newTable[i] = A
  - 把 B 头插:B.next = A, newTable[i] = B
  - 结果:newTable[i] = B → A → null
  
  然后 T2 完成 resize(),table 指向新数组
  但注意:旧数组 src 还在,A 和 B 的 next 指针被修改了!

时刻3:T1 被唤醒,继续执行(T1 的局部变量:e=A, next=B)
  
  T1 执行:
    e=A, next=B
    i = indexFor(A.hash, newCapacity)
    A.next = newTable[i]   ← newTable[i] 当前是 null(T1 自己的新数组)
    newTable[i] = A        ← newTable[i] = A
    
    e = next = B           ← 关键!此时 B 的 next 已经被 T2 改成 A 了!
  
  下一轮:
    e=B, next = B.next = A  ← 因为 T2 把 B.next 改成了 A
    B.next = newTable[i] = A
    newTable[i] = B         ← newTable[i] = B → A
    
    e = next = A
  
  再下一轮:
    e=A, next = A.next = null(T1 新数组里 A.next 是 null)
    A.next = newTable[i] = B → A
    newTable[i] = A         ← newTable[i] = A → B → A → B → A ...
    
    e = next = null,循环结束?
    
    不对!因为 A.next = B,而 B.next = A...

死循环的形成

T1 完成后,newTablei 的结构变成了:

复制代码
A.next = B
B.next = A

形成闭环:A ↔ B

当其他线程(或 T1 自己后续)调用 get() 遍历这个桶时:

java 复制代码
// HashMap.get() 遍历链表
for (Entry<K,V> e = table[index]; e != null; e = e.next) {
    if (e.key.equals(key)) return e.value;  // 死循环!e 在 A 和 B 之间无限跳转
}

e = e.next 永远在 A → B → A → B 之间循环,CPU 飙到 100%,形成死循环。

四、图解死循环形成

复制代码
初始状态:旧数组某个桶
┌─────┐    ┌─────┐
│  A  │───→│  B  │───→ null
└─────┘    └─────┘

T2 先完成扩容(头插法):
┌─────┐    ┌─────┐
│  B  │───→│  A  │───→ null
└─────┘    └─────┘
   ↑ 注意:B.next 被改成 A,A.next 还是 null(在 T2 的新数组里)

T1 被唤醒,继续用旧引用 e=A, next=B 执行:
Step 1: A 头插到 T1 的新数组
  newTable[i] = A → null

Step 2: e=B, 但 B.next 已经是 A(被 T2 改的)
  B 头插到 T1 的新数组
  newTable[i] = B → A → null

Step 3: e=A(因为 B.next=A)
  A 头插到 T1 的新数组
  A.next = newTable[i] = B
  newTable[i] = A
  结果:A → B → A → B ... 闭环!

┌─────┐    ┌─────┐
│  A  │←──→│  B  │
└─────┘    └─────┘
  ↑_________↓

五、JDK8 为什么改尾插法

java 复制代码
// JDK8 的迁移逻辑(尾插法)
do {
    Node<K,V> next = e.next;
    int i = (e.hash & oldCap) == 0 ? 0 : oldCap;  // 判断位置:原位置或原位置+oldCap
    
    if (e.next == null)        // 尾插法:直接挂到链表尾部
        newTab[i] = e;
    else
        // ... 遍历到尾部插入
} while (e != null);

JDK8 采用尾插法 + 高低位拆分(不再重新计算 hash,而是用 hash & oldCap 判断位置):

  • 尾插法保持链表原有顺序,不会反转链表
  • 即使并发扩容,最多是数据丢失 或读取不一致不会形成闭环死循环

六、JDK8 仍然线程不安全

虽然尾插法避免了死循环,但 HashMap 本身没有任何同步机制,并发下仍然有问题:

问题 说明
数据丢失 两个线程同时 put,后执行的覆盖先执行的
size 不准确 ++size 非原子操作,实际元素数可能小于 size() 返回值
读取脏数据 一个线程扩容,另一个线程读取,可能读到旧数组数据或 null

并发安全替代方案:

  • Collections.synchronizedMap(new HashMap<>()):全表加锁,性能差
  • ConcurrentHashMap(推荐):分段锁(JDK7)或 CAS + synchronized(JDK8),并发度高

七、总结

JDK7 HashMap 使用头插法是为了实现简单、插入 O(1)。但并发扩容时,如果两个线程同时迁移同一个链表,头插法会反转链表指针,加上执行时序交错,容易形成 A→B→A 的闭环,导致后续 get() 遍历死循环。JDK8 改用尾插法保持链表顺序,避免了死循环,但 HashMap 本身仍无同步机制,并发场景必须用 ConcurrentHashMap。

相关推荐
Interview Aid1126 小时前
TikTok OA 四题分享|半小时内 AC,题目基本都是实现题
java·开发语言·算法·面试·职场和发展
mldong7 小时前
换了工作流引擎,前端一行代码没改
java·架构
CoderIsArt14 小时前
C#中UI 线程与 Dispatcher
开发语言·ui·c#
微尘寒风14 小时前
【Git】的安装和使用
java·git
y = xⁿ15 小时前
DeepSeek Harness 学习日记:关于Agent接口,工具调用的底层实现
android·java·学习
侧耳倾听11115 小时前
jwt使用简介
java·jwt
顶点多余16 小时前
那些在算法中适合巩固的知识点---1
java·前端·算法
AI人工智能+电脑小能手16 小时前
大白话说Java设计模式-46-责任链模式(业务实战篇)
java·设计模式·责任链模式·风控校验·servlet filter·spring interceptor·链式处理
jay神16 小时前
【计算机毕业设计】基于SpringBoot的程序教学辅助系统
java·前端·vue.js·spring boot·后端·毕业设计·课程设计
AI情绪识别开源16 小时前
检信 ALLEMOTION OS 加密打包可执行程序 — 全面测试报告版本: v1.3功能测试 / 性能测试 /
开发语言·数据结构·人工智能·功能测试