一、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。