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。

相关推荐
FBI HackerHarry浩1 小时前
Pandas 库中用于分类变量独热编码(One-Hot Encoding)的函数 pd.get_dummies() 函数
开发语言·人工智能·python·pandas
名字还没想好☜1 小时前
Go 的 sync.Cond 实战:用条件变量做等待/通知,比忙轮询省 CPU
开发语言·数据库·后端·golang·go
李可以量化2 小时前
Tornado 从了解到精通(一)下:异步与非阻塞 IO 核心原理
java·数据库·tornado
yurenpai(27届找实习中)2 小时前
从销售订单推送 MES 看懂微服务调用(二):DTO 转换与 OpenFeign 调用
java·微服务·架构·服务调用
运维行者_2 小时前
企业带宽监控工具实战:网络流量分析与异常排查的5个关键能力
运维·服务器·开发语言·网络·分布式·后端·php
Patrick在香港2 小时前
Python爬虫框架实战:优雅抓取香港政府公开API数据的通用方案
java·开发语言·爬虫·python·ai编程·数据可视化·可用性测试
hey_sml2 小时前
MySQL常用操作速记:从字符集到游标全解析
java·开发语言·mysql
广东程序猿小展2 小时前
《007初露锋芒》风灵月影13项修改器下载 007初露锋芒修改器使用方法
开发语言·javascript·ecmascript
掉鱼的猫2 小时前
Solon AOT & Native:三段式编译,从 Java 到原生可执行文件
java·云原生