🧩 第一幕:舞台搭建(HashMap基础结构)
想象一个巨大的储物柜(Entry数组),每个柜门后挂着一条珍珠项链(链表结构)。每个珍珠(Entry节点)都刻着:
static class Entry<K,V> {
final K key;
V value;
Entry<K,V> next; // 下一颗珍珠
int hash; // 柜门编号
}
当我们存入新珍珠时,如果发现项链太长(链表长度超过阈值),储物柜就会扩建为原来的两倍大(resize),然后把所有珍珠重新挂到新柜门后(rehash)。
💣 第二幕:致命舞步(多线程扩容)
经典Java7的transfer方法(注意这个魔鬼代码):
void transfer(Entry[] newTable) {
for (Entry<K,V> e : table) { // 遍历旧储物柜
while(null != e) {
Entry<K,V> next = e.next; // 记住下一颗珍珠
int i = indexFor(e.hash, newTable.length); // 计算新柜门号
e.next = newTable[i]; // 头插法!新珍珠挂在最前面
newTable[i] = e; // 更新柜门后的项链
e = next; // 处理下一颗珍珠
}
}
}
🕺💃 第三幕:双线程死亡之舞(图解)
假设旧数组大小2,新数组大小4,链表A→B→null
线程1和线程2同时开始扩容:
-
线程1执行到
Entry<K,V> next = e.next;(此时e=A, next=B) ⚠️ 突然被调度器暂停! -
线程2完整执行完扩容,新数组中的链表变成B→A→null(头插法导致反转)
-
线程1恢复执行,但它的内存快照还停留在旧世界:
- e=A, next=B(但此时B.next已经变成A!)
-
线程1继续操作:
e.next = newTable[i]; // 把A.next指向新数组位置(此时是null) newTable[i] = e; // 新位置存入A e = B // 处理下一个节点 -
第二轮循环处理B:
Entry<K,V> next = B.next; // 此时B.next是A! e.next = newTable[i]; // B.next指向已存入的A newTable[i] = B // 存入B e = A // 下一轮处理A -
第三轮处理A:
Entry<K,V> next = A.next; // A.next是null e.next = B // A.next指向B(形成B↔A循环!) newTable[i] = A
此时新数组中的链表变成A→B→A→B→A...,一个完美的死亡循环诞生了!
🔍 第四幕:案发现场重现(为何会死循环?)
当其他线程调用get()方法时:
- 遍历链表查找key
- 进入A→B→A→B→A...的无限循环
- CPU占用率飙升到100%
- 系统仿佛被施了定身咒
🛡️ 第五幕:防御指南
- 使用ConcurrentHashMap:真正的线程安全战士
- Collections.synchronizedMap:给HashMap穿上锁子甲
- 升级Java8+:改用尾插法+红黑树,但依然非线程安全!
- 终极奥义:严格避免在多线程环境直接使用HashMap
🎓 技术总结
问题根源在于:
- 头插法导致链表反转
- 多线程可见性不同步
- 执行过程非原子性
- 环形引用的多米诺骨牌效应
记住:HashMap就像玫瑰,单线程时娇艳欲滴,多线程时暗藏尖刺。下期我们继续解剖ConcurrentHashMap的魔法防御体系,敬请期待!