蔚来测开一面:HashMap从1.7开始到1.8的过程,既然都解决不了并发安全问题,为什么还要进一步解决环形链表的问题?
文章目录
- 问题的根源:JDK 1.7 的设计缺陷
- 为什么必须解决这个问题?
- 1\. 故障等级完全不同 💣
- 2\. JDK 1.8 的解决方案:一石二鸟 🦅
- 3\. 为“不小心”的开发者提供一层保障 🛡️
- 结论
这是一个非常好的问题,它直击了技术演进的核心: 即使不能解决所有问题,也要优先解决最致命的问题。
简单来说,解决环形链表问题,并不是为了实现线程安全,而是为了消除一个在并发场景下会导致服务器CPU 100%直至宕机的“定时炸弹”。
这是一个关于**故障严重性(Failure Severity)**的权衡问题。
问题的根源:JDK 1.7 的设计缺陷
在 JDK 1.7 中,HashMap
扩容(resize)时转移数据的 transfer
方法使用了头插法。
- 头插法:在将旧数组的元素转移到新数组时,新来的元素总被放在链表的头部。
- 问题所在:在单线程下,头插法会使链表顺序反转,这没问题。但在多线程并发扩容时,两个线程可能同时操作同一个链表。一个线程执行一半被挂起,另一个线程完成了扩容导致链表反转。当第一个线程恢复执行时,它会基于一个已经改变的链表继续操作,这会导致链表节点的
next
指针互相指向,最终形成一个环形链表。
这个环形链表一旦形成,后续对该位置的 get()
操作就会陷入无限循环,导致CPU占用率飙升到100%,整个应用或服务器都会被拖垮。
为什么必须解决这个问题?
现在回到你的核心问题:既然HashMap
本来就不是线程安全的,并发使用时数据丢失、不一致等问题都可能发生,为什么还要专门修复环形链表这个bug?
1. 故障等级完全不同 💣
HashMap
在并发下可能遇到的问题可以分为两类:
- 数据不一致 (Data Inconsistency): 比如两个线程同时
put
,一个线程的数据覆盖了另一个,导致数据丢失。这属于数据问题,虽然也很糟糕,但通常不会让整个应用程序崩溃。 - 致命的系统崩溃 (Fatal System Crash): 环形链表导致的无限循环,会耗尽CPU资源,引起拒绝服务(DoS)。这是一个系统级的灾难性故障。
打个比方:
数据不一致就像是两个售票员卖了同一张电影票,会导致顾客(数据)冲突,需要业务逻辑去处理。
而环形链表就像是售票系统的后台代码进入了死循环,整个售票系统都瘫痪了,谁也买不了票。
显然,消除一个能让服务器宕机的Bug,其优先级远高于处理一般的数据不一致问题。
2. JDK 1.8 的解决方案:一石二鸟 🦅
JDK 1.8 对 HashMap
进行了重大重构,主要做了两件事,顺便解决了环形链表问题:
-
引入红黑树 (Red-Black Tree): 这是1.8最大的性能优化。当链表长度超过一定阈值(默认为8)时,链表会转化为红黑树,将该位置的查找时间复杂度从 O(n) 优化到 O(log n)。这是重构的主要动机之一。
-
修改扩容算法为“尾插法”: 在数据转移时,保持链表元素的原有顺序,将元素依次插入到新链表的尾部。因为顺序保持不变,就不会出现1.7中指针反转交错形成环路的情况。这个修改顺手就根除了环形链表这个“定时炸弹”。
所以,JDK 团队在进行性能优化的同时,也修复了这个已知的、非常严重的设计缺陷。
3. 为“不小心”的开发者提供一层保障 🛡️
虽然官方文档明确指出 HashMap
非线程安全,但现实中总有开发者会误用或者在一些自认为安全的场景下不慎造成并发。
与其留着一个会导致服务器崩溃的“地雷”,不如让它在被误用时表现得“温和”一些。JDK 1.8 之后,即使你在并发场景下误用了 HashMap
,最可能发生的是数据丢失,而不再是整个应用的崩溃。这是一种更“安全失败”(Fail-Safe)的设计哲学。
结论
总而言之,从1.7到1.8的演进,解决环形链表问题并非为了让 HashMap
变得线程安全,而是:
- 拆除了一个会导致系统崩溃的“核弹级”Bug。
- 作为性能优化(引入红黑树)过程中的一个必然修复。
- 降低了误用
HashMap
时带来的风险,使框架更健壮。
面试官问这个问题,就是想考察你是否理解技术决策背后的权衡和深层原因,而不仅仅是背诵“1.7是头插法,1.8是尾插法和红黑树”。
正确的并发选择:在需要保证线程安全的场景下,我们应该始终使用 ConcurrentHashMap
。