
HashMap扩容时链表怎么重组,为什么JDK 8不再死循环
JDK 8 彻底改写了 resize() 中链表迁移逻辑,把头插法换成尾插法,并引入红黑树和“高低位拆分”策略——核心不是“避免多线程”,而是让扩容过程天然具备**顺序一致性**,即使并发调用 resize() 也不会形成环形链表。
常见错误现象:put() 卡住、CPU 100%、线程 dump 显示 HashMap.get() 在无限遍历链表。这在 JDK 7 是典型死循环,JDK 8 后基本消失,但不等于线程安全——只是“不崩”,结果仍可能错乱。
- JDK 7 用头插:旧链表 A→B→C 扩容后可能变成 C→B→A→C(环)
- JDK 8 用尾插 + 拆分:每个节点只追加到新桶的末尾,且根据 hash 的高位比特决定进
loHead还是hiHead,全程无交叉指针修改 - 拆分后两个子链表天然有序,重挂到新数组时不会逆转或交叉
“高低位拆分”具体怎么算,hash & oldCap 是啥意思
这不是随便位运算,而是利用扩容后容量必为 2 的幂次这一特性:新桶索引 = 原索引 或 原索引 + oldCap。判断依据就是 hash 值的“新增那一位”是否为 1——即 hash & oldCap 是否非零。
例如 oldCap = 16(10000₂),扩容到 32,新增的是第 5 位(从 0 开始数)。若 hash & 16 != 0,说明该位是 1,新索引 = 原索引 + 16;否则不变。
立即学习“Java免费学习笔记(深入)”;
-
loHead存放hash & oldCap == 0的节点(低位组) -
hiHead存放hash & oldCap != 0的节点(高位组) - 两组内部保持原链表顺序,尾插保证无环
- 这个判断比 JDK 7 的
hash % newCap快得多,且可预测分布
为什么并发 put 还可能出错,但不再死循环
因为 JDK 8 的 resize() 不再依赖“整个旧链表必须稳定”,而是逐节点摘下、分类、尾插——即使另一个线程同时修改了某个节点的 next,当前线程也只是把它当作一个独立节点处理,不会去遍历它后续的链接关系。
但后果依然严重:数据丢失、重复、size 错误。比如线程 A 正在拆分链表,线程 B 插入新节点并触发另一次 resize,两个 resize 并行执行,部分节点可能被漏迁或双迁。
- 死循环根除 ≠ 线程安全:
ConcurrentHashMap才是正确选择 - 单线程下
resize()性能更好:无同步开销、局部性好、分支预测友好 - 如果真要并发写 HashMap,哪怕 JDK 8,也会遇到
ConcurrentModificationException或静默脏数据
想验证是否还有环,怎么快速检测
别等线上崩了才查。可以在测试中模拟并发 put 后,对每个桶调用简易环检测:快慢指针法跑一遍 Node.next 链,看是否相遇。
注意:JDK 8 的 Node 是单向链表,且 next 永远指向更“新”的节点(尾插保证),所以只要没被外部并发破坏结构,就不可能成环——但你没法靠代码“证明没被破坏”,只能靠杜绝并发写。
- 检测代码只需几行:
slow = fast = first;然后fast = fast.next.next;循环比对 - 生产环境别加这种检测:影响性能,且掩盖了根本问题——用错容器
- 真正关键点:所有共享的
HashMap实例,必须明确加锁或换ConcurrentHashMap
最容易被忽略的,是那些看似只读、实则隐式写入的场景——比如 computeIfAbsent(),它在 key 不存在时会插入,照样触发 resize。只要有一次并发写,安全假设就全失效。










