一、前言
在 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。