“HashMap是线程不安全的,并发环境下用ConcurrentHashMap。”这句话几乎每个Java程序员都背得滚瓜烂熟。
但面试官如果追问一句:“具体哪里不安全?能说说是哪个操作、什么条件下触发的不安全吗?”很多人的答案就开始变得模糊了——“呃……好像是put的时候会死循环?”“不对吧,好像JDK 8修复了死循环?”“那就是数据会丢?”
说实话,背八股文谁都会。但如果你真的打开HashMap的源码,一行一行地看,你会发现那些“线程不安全”的结论,其实就明明白白地写在代码里。
先说结论:死循环在JDK 8已经修复了
“HashMap在并发put时会死循环”——这个说法在JDK 7时代确实成立。原因是JDK 7在扩容时采用头插法,将旧链表的元素逐个转移到新数组时,链表顺序会被反转。多线程并发扩容时,两个线程同时操作同一个链表,就可能形成环形链表。当get()遍历到环上时,永远走不到头,CPU直接飙到100%。
JDK 8改用了尾插法,在扩容转移时保持链表原有顺序不变,环形链表的场景被彻底避免了。所以如果再拿“死循环”说事,说明你的知识点还停留在JDK 7时代。
真正核心的问题:数据覆盖
死循环修复了,但HashMap在并发下仍然是“不安全的”。打开JDK 8的源码,看putVal()方法的核心片段:
if ((p = tab[i = (n 1) & hash]) == null) tab[i] = newNode(hash, key, value, null);
这行代码的意思是:如果数组当前位置为空,就把新节点放进去。看起来没问题对吧?但问题在于,这不是一个原子操作。
线程A和线程B同时执行到这一步,都判断出tab[i] == null,然后各自创建新节点并写入数组。后写入的会直接覆盖先写入的。线程A put进去的key-value对就这样凭空消失了。
这不是理论推演,而是真实会发生的情况。在压力测试中,两个线程同时put不同的key到同一个空槽位,丢失数据的概率相当高。
数据覆盖不止这一处
再看putVal()中处理哈希冲突的逻辑:
if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; }这里同样存在覆盖问题。两个线程同时put同一个key,都获取到了同一个e节点,然后各自更新e.value。后更新的覆盖先更新的,没有任何同步保护。前一个写入的值被悄无声息地覆盖了。
size也永远算不准
HashMap用size字段记录键值对数量,每次put或remove都会执行++size或--size。同样,这不是原子操作。
两个线程同时put,同时执行++size,如果初始size=0,两个线程各自读到0,计算后各自写回1——丢了两次插入,size却是1。更离谱的是,如果用size()方法拿到的值偏小,还可能导致扩容时机的误判——本来应该扩容了,因为size偏小没触发,导致链表越来越长,查询性能急剧下降。
扩容时同样不安全
JDK 8的扩容方法resize()里,有一个关键的transfer过程,将老数组的元素迁移到新数组。这个过程涉及对Node.next指针的读写。如果两个线程同时触发扩容,A线程修改了链表指针指向新数组,B线程还拿着旧数组的引用继续操作,节点的next指针就可能被写乱——倒不一定是死循环,但遍历时丢失节点、get不到值是家常便饭。
那ConcurrentHashMap是怎么解决的?
对比着看就更清楚了:
数据覆盖问题:ConcurrentHashMap用CAS+synchronized,在tabAt和casTabAt中保证写入的原子性
size不准问题:用LongAdder思想维护baseCount+CounterCell数组,并发累加时分散到多个计数器上
扩容问题:多线程协同扩容,每个线程负责一段区间,用sizeCtl状态变量做全局协调
总结
回到面试场景。当面试官问“HashMap为什么线程不安全”,与其背一句“因为并发put会导致死循环和数据丢失”,不如这样回答:
“HashMap线程不安全主要集中在三个层面。第一,put操作中的数组槽位写入和链表节点更新不是原子的,会导致数据覆盖;第二,size字段的累加不是原子的,会导致集合大小不准确,进而影响扩容判断;第三,扩容过程中多个线程同时操作节点引用,可能导致节点丢失或链表结构异常。其中JDK 7的头插法环形链表问题在JDK 8已经通过尾插法修复,但数据覆盖和size不准的问题依然存在。在并发场景下,应该使用ConcurrentHashMap。”
然后你可以掏出手机,打开JDK源码,把putVal()里那几行关键代码指给他看。
背结论能过面试,读源码才能让人信服。