刚上完线,还没骑到家,ECC告警电话打来了。
“线上CPU飙到100%了!系统卡成PPT!”你慌里慌张骑回去,打开日志一看——HashMap 直接变成了环,无限循环了。
你一脸懵:不就是个put操作吗?HashMap还能把自己玩坏?
能,而且坏得很有技术含量。
先来个生活化类比
你把HashMap想象成一个小区快递架。
正常情况:
• 来一个快递,你根据货架号找个空位放上去 • 来两个冲突的,你就拉个绳子(链表)串起来 • 快递太多,你就把架子换大(扩容)
线程不安全是什么感觉?两个人同时放快递,手忙脚乱,直接搞塌了架子。
源码掰开揉碎讲
1. 死循环 —— 扩容时的头插法(JDK 1.7)
JDK 1.7 的 HashMap 用的是头插法,扩容时会把旧桶里的元素一个个摘下来,再插到新桶的头部。
看核心代码:
voidtransfer(Entry[] newTable, boolean rehash) {
intnewCapacity= newTable.length;
for (Entry<K,V> e : table) { // 遍历旧数组每个桶
while(null != e) {
Entry<K,V> next = e.next; // 记录下一个节点
inti= indexFor(e.hash, newCapacity); // 算新位置
e.next = newTable[i]; // 指向新桶的头
newTable[i] = e; // 把e放到新桶头部
e = next; // 处理下一个
}
}
}你品,你细品这个操作。
两个线程同时扩容,线程A刚把e.next赋值完,时间片到了;线程B接着干,把链表顺序给反转了。然后线程A醒过来,拿着旧引用继续操作——链表直接变成环。
下次get(key)走这个桶,while(e != null) 永不停歇,CPU直接拉满。
是不是觉得很反直觉?一个简单的链表操作,多线程下直接爆炸。
更绝的是,JDK 1.8 改成了尾插法,解决了死循环吗?
解决了,但又有新的问题。
2. 数据丢失 —— put 时的覆盖(JDK 1.8)
JDK 1.8 虽然改用了尾插法避免了死循环,但put操作依然不安全。
看关键代码:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab = (Node<K,V>[])table;
intn= (tab = resize()).length; // 先看要不要扩容
inti= (n - 1) & hash; // 算桶位置
if ((p = tab[i]) == null) // 如果桶是空的
tab[i] = newNode(hash, key, value, null); // 直接放进去
// ... 省略其他分支
}两个线程同时走到 tab[i] == null 这行,都判断是空的,然后各自创建了一个Node,先后赋值。
谁后赋值,谁就覆盖了前面的——你插的数据,丢了。
3. 扩容时的数据错乱
别急,还有更离谱的。两个线程同时扩容,都调用了 resize() 方法,互相覆盖彼此的 newTable。最后新的数组里,数据要么不完整,要么位置算错。
你查到的数据,跟你放进去的根本不是一回事。
解决方案对比
| ConcurrentHashMap | Map map = new ConcurrentHashMap<>() | |||
| Collections.synchronizedMap | Map map = Collections.synchronizedMap(new HashMap()) | |||
| Hashtable | Map map = new Hashtable() |
ConcurrentHashMap 才是亲儿子。JDK 1.8 之后改成了CAS + synchronized,锁粒度细到桶级别,性能吊打另外两个。
面试官问起来,怎么答?
面试官:HashMap线程不安全体现在哪?
你(自信脸):
主要有三个问题:
第一,死循环(JDK 1.7头插法扩容导致,1.8已修复)。
第二,数据覆盖(put时两个线程同时判断桶为空,后写的覆盖前写的)。
第三,扩容数据错乱(多个线程同时resize,互相覆盖新数组)。
实际开发中,单线程用HashMap,多线程用ConcurrentHashMap。
面试官点头,你过了。
一句话总结
HashMap 就像你家楼下的快递架,一个人用还行,两个人同时放——快递会丢,架子会塌。
下期预告:ConcurrentHashMap 是怎么做到线程安全的?CAS + synchronized 的组合拳,到底有多帅?点个关注,下期见真章。
夜雨聆风