[面试题] 为什么说HashMap不是并发安全的?
📖 版块 A:知识讲解版 (温故知新)
HashMap 在并发环境下不安全,主要体现在以下几个核心维度:
- 多线程 put 导致数据覆盖:在
put操作时,如果两个线程计算出的哈希桶索引相同,它们可能会同时判断该索引位置为空,随后先后写入。后写入的线程会覆盖先写入的线程的数据,导致数据丢失。 - JDK 1.7 中的死循环(死链)问题:在 JDK 1.7 中,HashMap 扩容时采用的是“头插法”。在多线程环境下,两个线程同时触发扩容,可能会导致链表成环。一旦后续调用
get方法查询一个不存在的 Key,且该 Key 正好映射到这个环形链表上,程序就会陷入死循环,导致 CPU 飙升至 100%。 - JDK 1.8 中的数据丢失与覆盖:虽然 JDK 1.8 将扩容时的“头插法”改为了“尾插法”,避免了死循环问题,但其源码中的
putVal方法并没有加锁。在多线程环境下,由于size++操作不是原子的,或者在树化(Treeify)过程中多个线程同时操作,依然会导致严重的数据覆盖或数据不一致问题。 - fail-fast(快速失败)机制:HashMap 内部维护了一个
modCount变量。如果在遍历(Iterator)过程中,有其他线程修改了 Map 的结构(如 add 或 remove),会抛出ConcurrentModificationException异常。
🎤 版块 B:面试实战版 (高手逐字稿)
Q:为什么说 HashMap 是并发不安全的?
💬 高手逐字稿:
关于 HashMap 的线程安全问题,我们不能一概而论,需要分阶段来看。它的不安全性主要体现在三个方面:
首先,是最直观的数据丢失。在 put 操作时,HashMap 的源码里并没有任何同步机制。如果两个线程同时执行 put,并且计算出的哈希槽位是一样的,它们可能都会检测到这个位置是空的,然后几乎同时进行写入。结果就是后写入的线程会把前一个线程的数据覆盖掉。此外,Map 里的 size 计数器也是一个普通的整型变量,size++ 本身不是原子操作,多线程下会导致最终统计的元素个数不准确。
其次,是比较著名的 JDK 1.7 扩容死循环问题。在 1.7 版本中,扩容触发 rehash 时使用的是“头插法”。如果两个线程同时扩容,线程 A 在处理链表节点时被挂起,而线程 B 完成了扩容并将链表顺序翻转了,等线程 A 恢复执行时,它可能会错误的将原本的后继节点指向前驱节点,从而形成一个环形链表。一旦后续程序调用 get 去触发这个槽位的遍历,就会导致 CPU 瞬间飙升到 100%,造成生产事故。
再者,是 JDK 1.8 的后续演进。虽然 JDK 1.8 改用了“尾插法”并保持了元素的相对顺序,从根本上解决了死循环的问题,但它依然不是并发安全的。例如,在多个线程同时进行红黑树化(treeifyBin)或者在数组并发扩容时,依然会出现节点丢失或指针指向错误的情况。
💡 高手自问自答(细节补位):
这里有一个容易���忽略的细节:既然 JDK 1.8 解决了死循环,那我们是不是可以在不关心 size 准确性的情况下,在并发场景盲目使用它呢?
答案依然是否定的。除了数据丢失,HashMap 的 fail-fast 机制也是一个大坑。当一个线程正在用 Iterator 遍历 Map,而另一个线程修改了结构,modCount 就会变化,导致遍历线程直接抛出 ConcurrentModificationException。所以,在并发场景下,如果需要保证安全,我们要么使用传统的 Hashtable(虽然它性能很差),要么使用 Collections.synchronizedMap 包装类,但最主流、最高效的选择还是使用 ConcurrentHashMap。