乐于分享
好东西不私藏

HashMap 为什么线程不安全?源码给你掰开讲

HashMap 为什么线程不安全?源码给你掰开讲

刚上完线,还没骑到家,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。最后新的数组里,数据要么不完整,要么位置算错。

你查到的数据,跟你放进去的根本不是一回事。


解决方案对比

方案
怎么用
优点
缺点
推荐指数
ConcurrentHashMapMap map = new ConcurrentHashMap<>()
分段锁/红黑树,性能好
需要熟悉API
⭐⭐⭐⭐⭐
Collections.synchronizedMapMap map = Collections.synchronizedMap(new HashMap())
简单粗暴
全表锁,性能差
⭐⭐
HashtableMap map = new Hashtable()
古老但稳定
全表锁,不支持null键

ConcurrentHashMap 才是亲儿子。JDK 1.8 之后改成了CAS + synchronized,锁粒度细到桶级别,性能吊打另外两个。


面试官问起来,怎么答?

面试官:HashMap线程不安全体现在哪?

(自信脸):

主要有三个问题:

第一,死循环(JDK 1.7头插法扩容导致,1.8已修复)。

第二,数据覆盖(put时两个线程同时判断桶为空,后写的覆盖前写的)。

第三,扩容数据错乱(多个线程同时resize,互相覆盖新数组)。

实际开发中,单线程用HashMap,多线程用ConcurrentHashMap

面试官点头,你过了。


一句话总结

HashMap 就像你家楼下的快递架,一个人用还行,两个人同时放——快递会丢,架子会塌。


下期预告:ConcurrentHashMap 是怎么做到线程安全的?CAS + synchronized 的组合拳,到底有多帅?点个关注,下期见真章。