最近在技术社区看到一个高频面试题:"HashMap 1.7为什么会在多线程环境下出现死循环?"这个问题看似简单,却涉及Java集合框架的底层实现和并发编程的核心痛点。今天我们就用工程师的视角,通过流程图解+代码分析的方式,彻底拆解这个经典问题。
HashMap作为Java中最常用的数据结构之一,在1.7版本采用数组+链表的实现方式。当发生哈希冲突时,新元素会以头插法的方式插入链表头部。这种设计在单线程环境下效率很高,但在并发场景下却暗藏杀机。
先看几个关键概念:
java复制// JDK 1.7中的transfer方法关键代码
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; // 保留下一节点引用
int i = indexFor(e.hash, newCapacity); // 重新计算索引
e.next = newTable[i]; // 头插法关键操作
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
假设我们有一个初始容量为2的HashMap,当前状态如下:
| 桶索引 | 链表结构 |
|---|---|
| 0 | null |
| 1 | A → B → null |
现在有两个线程同时执行put操作并触发扩容,我们来看并发冲突的具体过程:
Entry<K,V> next = e.next后被挂起此时内存状态:
code复制线程1:e = A, next = B
线程2:e = A, next = B
线程2完整执行transfer后,新表的桶1链表变为B→A→null:
| 步骤 | 操作 | newTable[1]状态 |
|---|---|---|
| 1 | e=A, next=B | null |
| 2 | e.next=newTable[1] | A→null |
| 3 | newTable[1]=e | A→null |
| 4 | e=B, next=null | A→null |
| 5 | e.next=newTable[1] | B→A→null |
| 6 | newTable[1]=e | B→A→null |
此时线程1恢复执行,但关键的是:
执行流程如下:
java复制e.next = newTable[i]; // A.next = B→A (形成环)
newTable[i] = e; // newTable[1] = A→B→A
e = next; // e = B
接下来的循环中:
java复制// 第二轮循环
Entry<K,V> next = e.next; // B.next = A
e.next = newTable[i]; // B.next = A→B
newTable[i] = e; // newTable[1] = B→A→B
e = next; // e = A
// 第三轮循环
Entry<K,V> next = e.next; // A.next = B
e.next = newTable[i]; // A.next = B→A
newTable[i] = e; // newTable[1] = A→B→A
e = next; // e = B
这样无限循环下去,最终形成A→B→A→B...的环形链表。
这个问题的根源在于头插法+多线程并发修改的组合:
解决方案对比:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 使用ConcurrentHashMap | 线程安全,性能较好 | 需要引入新的类 |
| 使用Collections.synchronizedMap | 简单直接 | 性能较差 |
| 升级到JDK1.8+ | 改用尾插法解决该问题 | 需要升级环境 |
实际开发中,ConcurrentHashMap是最推荐的解决方案,它在1.7版本采用分段锁,1.8改为CAS+synchronized,性能表现优异。
如果想亲眼见证这个死循环,可以编写以下测试代码:
java复制public class HashMapDeadLoop {
final static HashMap<Integer, Integer> map = new HashMap<>(2);
public static void main(String[] args) throws InterruptedException {
map.put(3, 3);
map.put(7, 7); // 这两个key会哈希冲突
new Thread(() -> {
map.put(15, 15);
}, "Thread-1").start();
new Thread(() -> {
map.put(11, 11);
}, "Thread-2").start();
}
}
运行后可能出现以下症状:
当面试官提出这个问题时,他们通常希望考察:
建议回答时采用"现象→原理→解决方案"的结构:
常见误区:
在准备这类问题时,最好的方式就是像我们今天这样: