HashMap 在 JDK 1.7 中为什么会出现死循环

一句话结论:

多线程并发环境下 JDK1.7 的 HashMap 扩容时,由于链表转移过程中不是线程安全的,可能会形成环形链表结构,从而导致遍历时无限循环,占满 CPU。


📌 一、背景知识:HashMap 扩容机制(JDK 1.7)

在 HashMap 中,数组默认长度为 16,负载因子为 0.75。插入数据超过容量时会触发扩容(resize),比如插入第 13 个元素时:

threshold = capacity * loadFactor = 16 * 0.75 = 12

扩容做了什么?

  1. 数组容量扩大为原来的两倍

  2. 将原数组中的元素重新分配位置(Rehash)

  3. 转移链表节点到新数组的对应桶中


🧩 二、问题出在:链表转移时可能顺序被打乱

在 JDK 1.7 中,链表是头插法(先插入的元素在后面,新的元素在前面),举例:

Entry<K, V> newEntry = new Entry<>(k, v, oldEntry);

多个线程同时执行扩容时,会同时修改同一个桶对应的链表结构。


🔥 三、并发条件下形成“闭环链表”的可能过程(关键)

假设两个线程 Thread A 和 Thread B 同时进行 resize() 操作。

示例场景:

原来一个桶的链表如下(顺序为 e1 → e2 → e3):

Thread A 复制时:
e1.next = e2
e2.next = e3
e3.next = null

Thread A 把链表放入新数组的时候,做的是头插法,插入顺序被颠倒:

newBucket = e3 → e2 → e1

但此时 Thread B 也在操作这段链表,而两线程交叉执行,就有可能导致:

e1.next = e2
e2.next = e1(成环!)

链表变成:

e1 → e2 → e1 → e2 → e1 → ...(死循环)


🧠 四、为什么这个问题只发生在 JDK 1.7?

特性JDK 1.7JDK 1.8
链表插入方式头插法尾插法
resize 机制多线程可同时触发resize 加了 synchronized
并发 resize 安全性❌ 不安全✅ 安全(同步+新链表结构)

因此,在 JDK 1.8 中已经用 synchronized 保证 resize 线程安全,不会出现链表成环。


🎯 五、面试回答模板(可直接背诵)

在 JDK 1.7 中,HashMap 的扩容采用的是头插法,多个线程并发执行 resize 操作时,如果同时迁移同一个桶的链表,会因为链表插入顺序错乱,造成链表形成闭环,从而在遍历时产生死循环。这种问题在 JDK 1.8 中已通过加锁和链表尾插法修复,避免了并发 resize 的不一致问题。


✅ 五、结论归纳

问题说明
触发时机多线程并发 put 触发 resize
死循环原因链表迁移过程中,头插法打乱链表顺序,导致链表成环
版本限制仅发生在 JDK 1.7 或之前
修复方法JDK 1.8 之后使用尾插法 + synchronized 加锁

🧩六、JDK 1.7 vs JDK 1.8 的 ConcurrentHashMap 实现原理对比

版本底层结构锁机制特点
JDK 1.7Segment数组 + HashEntry数组分段锁(Segment 继承 ReentrantLock)每段加锁,提高并发
JDK 1.8数组 + 链表 + 红黑树CAS + synchronized不再使用 Segment,精度更细,性能更高

 

🚀 七、面试高频问题与答题模板

Q1:为什么不用 HashMap?

答:HashMap 在多线程环境下可能出现数据不一致、数据丢失、甚至链表死循环问题(如 JDK1.7 扩容时),不适合并发场景。


Q2:ConcurrentHashMap 如何实现线程安全?

在 JDK1.7 中使用了 Segment 分段锁机制,通过分段数组的粒度来提高并发性;
在 JDK1.8 中取消了 Segment,使用了CAS + synchronized + volatile 的混合机制,对单个桶节点加锁或无锁,避免整体锁,提高了并发性能。


Q3:ConcurrentHashMap 会不会出现死循环?

答:不会。JDK1.8 的链表插入使用尾插法,并且加锁控制了 put 和扩容过程,避免了成环死循环的问题。

🧠 面试说法(你可以背下来)

HashMap 是线程不安全的,特别是在高并发情况下会出现数据丢失甚至死循环的问题。JDK1.8 的 ConcurrentHashMap 用 CAS + synchronized 解决了并发写入冲突问题,get 是无锁的,put 时只锁住当前桶。扩容时还支持多线程协作迁移,避免全表阻塞,非常适合多线程读写共享的场景。

### HashMapJDK1.7JDK1.8 之间的实现差异 #### 数据结构 在 JDK1.7 中,`HashMap` 的底层数据结构是由数组加链表组成的。当发生哈希冲突时,所有的键值对会被存放在同一个桶中形成一条单向链表[^1]。 而在 JDK1.8 中,除了保留原有的数组加链表的数据结构外,还引入了红黑树的概念。如果某个桶中的链表长度超过了一个阈值(默认为 8),并且当前 `HashMap` 的大小超过了一定界限,则该链表会转换成红黑树,从而减少查找时间复杂度从 O(n) 到平均情况下的 O(log n)[^2]。 #### 初始化过程 对于初始化部分,在 JDK1.7 中,`table` 数组是在声明时被初始化为空数组,并且首次插入元素之前调用 `inflateTable()` 方法完成实际分配工作[^3]。然而到了 JDK1.8 版本里,“懒加载”的概念得到了应用——即只有当真正需要存储第一个键值对时才会触发真正的初始化操作;另外值得注意的是,默认情况下初始容量调整为了最接近指定容量的一个 2 的幂次方数值[^4]。 #### 扩容机制 关于扩容方面也有明显改变: - **JDK1.7**: 使用头插法来进行迁移旧有节点到新位置的过程当中可能出现循环链表的情况,进而导致死循环问题的发生概率增加。而且它的判断依据较为严格,需满足两个前提条件才能执行扩容动作:一是达到负载因子限制;二是目标索引处已有其他映射项存在。 - **JDK1.8**: 改进了这一点采用了尾插法避免上述风险的同时简化逻辑只依赖单一条件即可触发改动 —— 即每当新增后的总数量超出临界点便会立即启动扩展流程。此外它利用位运算技巧快速定位每一个条目应该迁移到的新地址[(e.hash & oldCap)==0][^3]。 #### Hash 计算方式 两代产品间另一个重要差别体现在如何计算散列码之上: - **JDK1.7** 定义了一种相对复杂的扰动函数用来进一步打乱原始hashCode分布特性以期获得更加均匀的结果集[^4]: ```java static final int hash(int h){ h ^= (h >>> 20) ^ (h >>>12); return h^(h >>> 7) ^ (h >>> 4); } ``` - **JDK1.8** 对此进行了优化精简版本如下所示不仅减少了不必要的移位次数同时也兼顾效率与效果平衡考虑特别针对null关键字做了特殊处理直接返回零值作为对应hashcode: ```java static final int hash(Object key){ int h; return (key == null) ? 0 : (h = key.hashCode())^(h >>> 16); } ``` #### Null 键的支持 无论是哪个版本都允许最多只有一个Null Key 存在于整个Map实例之中不过具体表现形式略有出入: - 在早期设计(JDK1.7)里面如果遇到这样的特殊情况则专门开辟首个槽位留给它们单独使用而不参与常规路径匹配流程. - 后续更新至最新标准后(NKD1.8),尽管依旧保持相同行为模式但内部实现细节有所调整现在即使NULL也可以像普通对象那样经历完整的HASHING步骤只不过最终得出固定常量ZERO而已因此理论上讲二者并无本质区别仅限表达手法上的转变罢了[^3]. ```python # 示例代码展示两种不同版本下 NULL KEY 插入情形对比模拟演示片段 map_17.put(None,"value") # JDK1.7 方式可能直接放置 index=0 处理 map_18.put(None,"another value") # JDK1.8 经过 HASH 函数作用同样指向 index=0 结果一致 ``` ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值