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

一、前言

在 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] -> 新节点1 -> 新节点2 -> null

而是:

复制代码
newTable[3] -> 原来的节点1 / 原来的节点2

节点对象是复用的。

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

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

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

比如:

复制代码
newTable[5] -> 节点1 -> 节点2 -> null

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

复制代码
节点1.next = 节点2
节点2.next = null

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

这也是问题产生的根源。

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

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

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

复制代码
Entry<K,V> 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] -> 节点1 -> 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 -> 节点2 -> 节点1 -> 节点2 -> ...

环形链表形成了。

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

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<K,V>[] 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 & 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

相关推荐
程序员老乔12 小时前
01-项目架构设计与技术选型
java·人工智能
骄马之死13 小时前
ThreadLocal 核心原理
java·jvm·算法
超梦dasgg13 小时前
Gateway 鉴权场景:网关统一鉴权 + 业务应用决定放行规则
java·gateway
Advancer-13 小时前
点评plus---异步消费之后可靠的生成订单
java·spring·kafka
此生决int13 小时前
C++快速上手java备战期末考——运算符,输入输出和数组
java·c++·期末复习
bandaoyu13 小时前
【AMD】HDP(Host Data Path)是什么
java·后端·spring
蝈蝈噶蝈蝈噶13 小时前
poi-tl填充柱状图折线图无法指定y坐标轴导致重复数据
java·word
一 乐13 小时前
个人博客系统|基于Springboot的个人博客系统设计与实现(源码+数据库+文档)
java·数据库·spring boot·后端·论文·毕设·个人博客系统