JDK1.7HashMap多线程扩容为什么会死循环示例详解

 更新时间:2026年06月02日 09:51:10   作者:Han.miracle  
循环链表是JDK 1.7中HashMap的一个致命错误,可能导致内存泄漏和性能下降,这篇文章主要介绍了JDK1.7HashMap多线程扩容为什么会死循环的相关资料,文中通过代码介绍的非常详细,需要的朋友可以参考下

一、前言

在 Java 中,HashMap 是非常常用的数据结构。它底层主要由:

数组 + 链表

组成。

在 JDK 1.8 之后,HashMap 又加入了红黑树结构:

数组 + 链表 + 红黑树

但是在 JDK 1.7 中,HashMap 有一个经典问题:多线程环境下同时扩容,可能导致链表形成环,从而出现死循环。

这个问题的核心原因是:

JDK 1.7 HashMap 扩容时使用头插法;
头插法会修改节点的 next 指针;
多个线程同时扩容时,会操作同一批节点对象;
最终可能导致 A.next = B,B.next = A,形成环。

二、HashMap 扩容是不是在原数组上改?

不是。

HashMap 扩容不是把原来的数组直接变大。因为 Java 数组长度是固定的,一旦创建之后,长度不能改变。

比如原来是:

Entry<K,V>[] table = new Entry[16];

这个数组长度就是 16,不能原地变成 32。

所以 HashMap 扩容时会:

1. 创建一个更大的新数组
2. 遍历旧数组中的节点
3. 把旧节点重新挂到新数组中
4. 最后让 table 指向新数组

也就是:

oldTable 长度 16
扩容后创建 newTable 长度 32
最后 table = newTable

但是这里有一个非常重要的点:

数组是新的,但是节点对象不是新的。

也就是说,扩容不是重新创建节点副本,而是把旧数组里的节点对象拿出来,重新挂到新数组里。

例如旧数组中有:

oldTable[3] -> 节点1 -> 节点2 -> null

扩容后不是变成:

newTable[3] -&gt; 新节点1 -&gt; 新节点2 -&gt; null

而是:

newTable[3] -> 原来的节点1 / 原来的节点2

节点对象是复用的。

三、为什么放到新数组里还要修改 next?

因为数组里面每个位置只能存一个头节点。

如果多个元素落到同一个桶里,就必须靠链表连接起来。

比如:

newTable[5] -&gt; 节点1 -&gt; 节点2 -&gt; null

这里真正维护链表关系的是:

节点1.next = 节点2
节点2.next = null

所以扩容迁移时,一定会重新整理节点之间的 next 指针。

这也是问题产生的根源。

四、JDK 1.7 的头插法是什么?

JDK 1.7 HashMap 扩容迁移时使用的是头插法

核心代码可以简化理解为:

Entry&lt;K,V&gt; next = e.next;      // 先保存旧链表中的下一个节点

int i = indexFor(e.hash, newCapacity); // 计算新数组下标

e.next = newTable[i];          // 当前节点指向新桶原来的头节点
newTable[i] = e;               // 当前节点成为新桶的新头节点

e = next;                      // 继续处理下一个旧节点

最重要的是这句:

e.next = newTable[i];

这句话会修改当前节点的 next 指针。

五、单线程下头插法为什么没问题?

假设旧链表是:

节点1 -> 节点2 -> null

扩容时使用头插法。

一开始新数组桶为空:

newTable[i] = null

先迁移节点1:

节点1.next = newTable[i];
newTable[i] = 节点1;

因为 newTable[i] 是 null,所以:

节点1.next = null

新链表变成:

newTable[i] -> 节点1 -> null

然后迁移节点2:

节点2.next = newTable[i];
newTable[i] = 节点2;

此时 newTable[i] 是节点1,所以等价于:

节点2.next = 节点1

最终新链表变成:

newTable[i] -> 节点2 -> 节点1 -> null

可以看到,原来的链表:

节点1 -> 节点2 -> null

被反转成了:

节点2 -> 节点1 -> null

单线程下这没问题,只是顺序反了,但是链表最后仍然指向 null

六、多线程扩容为什么会出问题?

问题出在:两个线程同时扩容同一个 HashMap。

假设旧数组某个桶中有两个节点:

oldTable[3] -> 节点1 -> 节点2 -> null

现在两个线程同时触发扩容:

线程A:创建 newTableA
线程B:创建 newTableB

注意:

newTableA 和 newTableB 是两个不同的新数组。

但是:

节点1 和 节点2 是同一批旧节点对象。

也就是说,两个线程操作的是同一个节点1和同一个节点2。

七、详细模拟多线程扩容过程

第一步:线程A开始扩容

线程A准备迁移节点1。

它先执行:

e = 节点1;
next = e.next;

此时:

e = 节点1
next = 节点2

也就是说,线程A已经记住了节点1后面是节点2。

但是这时候,线程A突然被 CPU 暂停了。

当前旧链表还是:

节点1 -> 节点2 -> null

第二步:线程B开始并完成扩容

线程B也开始处理同一条旧链表:

节点1 -> 节点2 -> null

线程B先迁移节点1

线程B的新数组桶为空:

newTableB[i] = null

执行头插法:

节点1.next = newTableB[i];
newTableB[i] = 节点1;

因为 newTableB[i] 是 null,所以:

节点1.next = null

线程B的新链表变成:

newTableB[i] -> 节点1 -> null

线程B再迁移节点2

此时:

newTableB[i] = 节点1

线程B迁移节点2:

节点2.next = newTableB[i];
newTableB[i] = 节点2;

因为 newTableB[i] 是节点1,所以等价于:

节点2.next = 节点1

于是线程B的新链表变成:

newTableB[i] -> 节点2 -> 节点1 -> null

此时真实节点关系已经变成:

节点2.next = 节点1
节点1.next = null

也就是:

节点2 -> 节点1 -> null

注意,这里修改的是节点对象自己的 next,不是只修改线程B的新数组。

八、线程A恢复执行,问题出现

线程A之前暂停时保存的是:

e = 节点1
next = 节点2

现在线程A恢复执行。

它继续迁移节点1。

线程A自己的新桶为空:

newTableA[i] = null

执行头插法:

节点1.next = newTableA[i];
newTableA[i] = 节点1;

因为 newTableA[i] 是 null,所以:

节点1.next = null

线程A的新链表现在是:

newTableA[i] -&gt; 节点1 -&gt; null

然后线程A执行:

e = next;

因为线程A之前保存的 next 是节点2,所以现在:

e = 节点2

九、线程A处理节点2

线程A处理节点2时,先取:

next = 节点2.next;

但是节点2的 next 已经被线程B改过了。

线程B之前执行过:

节点2.next = 节点1

所以线程A现在拿到的是:

next = 节点1

然后线程A把节点2头插到自己的新数组中:

节点2.next = newTableA[i];
newTableA[i] = 节点2;

此时:

newTableA[i] = 节点1

所以等价于:

节点2.next = 节点1

线程A的新链表变成:

newTableA[i] -> 节点2 -> 节点1 -> null

然后线程A执行:

e = next;

而刚才:

next = 节点1

所以线程A又回到了节点1。

十、线程A再次处理节点1,形成环

此时线程A的新桶头节点是节点2:

newTableA[i] = 节点2

线程A再次处理节点1,执行头插法:

节点1.next = newTableA[i];
newTableA[i] = 节点1;

因为 newTableA[i] 是节点2,所以等价于:

节点1.next = 节点2

但是前面已经有:

节点2.next = 节点1

于是链表变成:

节点1 -&gt; 节点2 -&gt; 节点1 -&gt; 节点2 -&gt; ...

环形链表形成了。

十一、为什么形成环后会死循环?

HashMap 查询元素时,会沿着链表一直往后找。

类似逻辑:

while (e != null) {
    if (e.key.equals(key)) {
        return e.value;
    }
    e = e.next;
}

正常链表最后会走到:

null

比如:

节点1 -> 节点2 -> null

但是如果链表形成了环:

节点1 -> 节点2 -> 节点1 -> 节点2 -> ...

那么 e 永远不会变成 null

程序就会一直循环,CPU 占用可能飙高,看起来像程序卡死。

这就是 JDK 1.7 HashMap 多线程扩容死循环问题。

十二、关键问题:为什么各自扩容还会互相影响?

因为:

线程A有自己的 newTableA
线程B有自己的 newTableB

但是:

newTableA 和 newTableB 里面放的是同一批旧节点对象的地址

不是复制节点。

所以线程A和线程B虽然数组不同,但是它们修改的是同一个节点对象里的 next 字段。

可以把节点理解成这个类:

class Entry<K,V> {
    K key;
    V value;
    Entry<K,V> next;
}

数组只是保存节点地址:

Entry&lt;K,V&gt;[] table;

扩容时这句代码:

e.next = newTable[i];

修改的是节点对象内部的 next

所以即使没有修改旧数组 oldTable[i],也会改变旧节点之间的链表关系。

十三、JDK 1.8 是怎么改进的?

JDK 1.8 对 HashMap 做了几个重要优化。

1. 扩容时不再使用 JDK 1.7 那种头插法

JDK 1.8 扩容时会把原桶中的链表拆成两条链表:

lo 链表:留在原位置
hi 链表:移动到 原位置 + oldCap

判断方式是:

if ((e.hash &amp; oldCap) == 0) {
    // 留在原位置
} else {
    // 移动到 原位置 + oldCap
}

2. JDK 1.8 使用尾插法,保持链表顺序

JDK 1.7 头插法会反转链表:

节点1 -> 节点2

迁移后可能变成:

节点2 -> 节点1

而 JDK 1.8 使用尾插法,尽量保持原来的顺序。

这样就避免了 JDK 1.7 头插法反转链表时带来的典型成环问题。

3. JDK 1.8 加入红黑树

JDK 1.8 中,如果一个桶里的链表太长,并且数组长度达到一定条件,链表会转成红黑树。

这样可以避免链表过长导致查询效率下降。

JDK 1.7:

数组 + 链表

JDK 1.8:

数组 + 链表 + 红黑树

十四、但是 JDK 1.8 的 HashMap 线程安全吗?

不安全。

虽然 JDK 1.8 优化了扩容逻辑,避免了 JDK 1.7 中典型的头插法死循环问题,但是 HashMap 本身依然不是线程安全的。

多线程环境下,如果多个线程同时读写 HashMap,仍然可能出现:

数据覆盖
数据丢失
size 不准确
结构异常

所以多线程环境下不要使用普通 HashMap。

应该使用:

ConcurrentHashMap

十五、面试总结版

如果面试官问:

JDK 1.7 HashMap 为什么多线程扩容会死循环?

可以这样回答:

JDK 1.7 的 HashMap 在扩容时会创建一个新的数组,然后把旧数组中的节点迁移到新数组中。数组是新的,但节点对象是旧的,迁移时会复用这些节点,并修改节点的 next 指针。

JDK 1.7 扩容迁移链表时使用头插法。头插法会把链表顺序反转。单线程下没有问题,但是在多线程同时扩容时,多个线程会操作同一批节点对象。如果线程A暂停,线程B完成扩容并把链表反转,线程A恢复后继续使用之前保存的节点引用,就可能把节点之间的 next 改成互相指向,比如 节点1.next = 节点2节点2.next = 节点1。这样链表就形成了环。

当后续执行 get() 操作时,HashMap 会沿着链表不断查找,如果链表形成环,就永远走不到 null,最终导致死循环,CPU 飙高。

JDK 1.8 之后,HashMap 扩容改用了尾插法和高低位链表拆分,避免了 JDK 1.7 头插法导致的典型成环问题。但 HashMap 仍然不是线程安全的,多线程环境下应该使用 ConcurrentHashMap

十六、一句话总结

JDK 1.7 HashMap 多线程扩容死循环的本质是:新数组是各线程自己的,但节点对象是共享的;头插法迁移会修改节点的 next 指针,多个线程交叉修改后可能形成环形链表,导致查询时永远走不到 null。

到此这篇关于JDK1.7HashMap多线程扩容为什么会死循环的文章就介绍到这了,更多相关JDK HashMap多线程扩容死循环内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • java同步之volatile解析

    java同步之volatile解析

    volatile可以说是Java虚拟机提供的最轻量级的同步机制了,了解volatile的语义对理解多线程的特性具有很重要的意义,下面小编带大家一起学习一下
    2019-05-05
  • AsyncHttpClient exception异常源码流程解析

    AsyncHttpClient exception异常源码流程解析

    这篇文章主要为大家介绍了AsyncHttpClient的exception源码流程解析,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2023-12-12
  • Spring中循环依赖问题的解决机制及详细流程

    Spring中循环依赖问题的解决机制及详细流程

    本文给大家介绍Spring中循环依赖问题的解决机制总结,本文通过实例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友参考下吧
    2025-08-08
  • Java深入了解数据结构之栈与队列的详解

    Java深入了解数据结构之栈与队列的详解

    这篇文章主要介绍了Java数据结构中的栈与队列,在Java的时候,对于栈与队列的应用需要熟练的掌握,这样才能够确保Java学习时候能够有扎实的基础能力。本文小编就来详细说说Java中的栈与队列,需要的朋友可以参考一下
    2022-01-01
  • Mockito+PowerMock+Junit单元测试用途解析

    Mockito+PowerMock+Junit单元测试用途解析

    本文介绍单元测试在开发和DevOps中的规范要求,详解Mockito和PowerMock的使用,包括解耦依赖、模拟行为、验证调用及参数匹配,同时说明SpringBoot测试注解(如@MockBean)的用法,并提及IDEA插件Squaretest的自动化测试生成功能,感兴趣的朋友一起看看吧
    2025-06-06
  • Eclipse连接Mysql数据库操作总结

    Eclipse连接Mysql数据库操作总结

    这篇文章主要介绍了Eclipse连接Mysql数据库操作总结的相关资料,非常不错,具有参考借鉴价值,需要的朋友可以参考下
    2016-08-08
  • 一文带你了解微服务架构中的"发件箱模式"

    一文带你了解微服务架构中的"发件箱模式"

    微服务架构如今非常的流行,这个架构下可能经常会遇到“双写”的场景。本文就和大家分享一个“发件箱模式”, 感兴趣的小伙伴可以了解一下
    2023-01-01
  • Springboot实现Excel批量导入数据并保存到本地

    Springboot实现Excel批量导入数据并保存到本地

    这篇文章主要为大家详细介绍了Springboot实现Excel批量导入数据并将文件保存到本地效果的方法,文中的示例代讲解详细,需要的可以参考一下
    2022-09-09
  • 浅谈为什么要使用mybatis的@param

    浅谈为什么要使用mybatis的@param

    这篇文章主要介绍了浅谈为什么要使用mybatis的@param,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2019-10-10
  • 解析springboot集成AOP实现日志输出的方法

    解析springboot集成AOP实现日志输出的方法

    如果这需要在每一个controller层去写的话代码过于重复,于是就使用AOP定义切面 对其接口调用前后进行拦截日志输出。接下来通过本文给大家介绍springboot集成AOP实现日志输出,需要的朋友可以参考下
    2021-11-11

最新评论