ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

多线程环境下,HashMap 为什么会出现死循环?

2026/9/23 18:28:51 拓冰建站 浏览量
多线程环境下,HashMap 为什么会出现死循环? 一、从一个经典面试题说起在 Java 后端面试中有一个经久不衰的经典问题在多线程环境下HashMap 为什么会出现死循环很多候选人能脱口而出「因为 JDK 1.7 的 HashMap 在并发扩容时会形成环形链表」但继续追问下去往往就说不清楚了环形链表到底是怎么一步步形成的为什么 get 操作会卡死CPU 为什么会飙到 100%JDK 8 为什么又把这个问题解决了事实上这个问题背后牵涉的知识点远比一句结论复杂它把HashMap 的底层数据结构、扩容机制、头插法与尾插法的差异、JVM 内存模型与对象引用共享、线程安全问题以及ConcurrentHashMap 的演进全部串在了一起。真正把它讲透需要回到 JDK 1.7 的源码逐行分析 transfer 方法在多线程交织执行时的每一步状态变化。本文将以 JDK 1.7 的 HashMap 源码为切入点通过完整的推演过程还原死循环从无到有的每一个细节并进一步分析 JDK 8 的改进思路最后给出线程安全场景下的正确选择。全文约 2 万字建议静下心读完尤其是第四章的状态推演。提示本文提到的「死循环」特指 JDK 1.7 及更早版本 HashMap 在并发扩容时可能出现的问题。JDK 8 通过尾插法从结构上规避了环形链表但仍存在数据覆盖等并发安全问题后文会详细说明。二、HashMap 的底层结构为什么它默认不是线程安全的要理解死循环必须先理解 HashMap 的底层结构。HashMap 在 JDK 1.7 中由「数组 单向链表」组成整体结构如下2.1 数组 链表的基本形态HashMap 维护一个EntryK,V[] table数组数组的每一个位置被称为一个「桶bucket」。当向 HashMap 中放入一个键值对时会先根据 key 计算一个 hash 值再通过 hash 值定位到具体的桶下标如果多个 key 定位到了同一个桶就会以链表的形式挂在同一个桶上。// JDK 1.7 中 Entry 节点的核心字段 static class EntryK,V implements Map.EntryK,V { final K key; V value; EntryK,V next; // 指向链表中的下一个节点 int hash; }从结构上看数组负责快速定位链表负责解决哈希冲突。理想情况下每个桶上只挂一个节点查找时间复杂度接近 O(1)当哈希冲突严重时桶上的链表会越来越长查找退化为 O(n)。2.2 为什么数组长度被设计成 2 的幂HashMap 计算桶下标的代码非常简洁在 JDK 1.7 中通过indexFor方法完成static int indexFor(int h, int length) { // length 是 2 的幂等价于 h % length但位运算更快 return h (length - 1); }数组长度始终保持为 2 的幂例如默认初始容量是 16之后每次扩容都是原来的 2 倍。这样设计有两个好处第一h (length - 1)等价于取模运算h % length但位运算速度更快第二配合扰动函数可以让 hash 结果尽可能均匀地散列到各个桶上减少冲突。需要特别注意的是HashMap 默认初始容量虽然标注为 16但它并不是懒初始化的。在 JDK 1.7 中真正第一次 put 时才会通过inflateTable创建底层数组。理解这一点对后面分析扩容时机有帮助。2.3 哈希冲突与链表形成既然数组长度有限而 hash 值的范围很大不同 key 映射到同一个桶的情况几乎不可避免这就是哈希冲突。HashMap 采用「链地址法Separate Chaining」解决冲突新插入的节点直接挂入对应桶与桶上已有的节点组成链表。JDK 1.7 在插入新节点时采用头插法也就是把新节点放在链表的头部。这一点非常关键它是后来死循环问题的重要伏笔。头插法对应的源码在createEntry方法中void createEntry(int hash, K key, V value, int bucketIndex) { EntryK,V e table[bucketIndex]; // 取出桶中原来的头节点 table[bucketIndex] new Entry(hash, key, value, e); // 新节点成为新的头节点next 指向原头节点 size; }可以看到新节点的next指向原来的头节点然后新节点自己占据桶的位置。假设桶上原本是A - B插入 C 之后会变成C - A - B。头插法的优势是插入简单、不需要遍历链表尾部但它会反转链表的原始顺序这在并发扩容时会带来严重后果。2.4 为什么说 HashMap 不是线程安全的HashMap 的源码中没有任何synchronized关键字也没有使用 CAS 等并发控制手段。这意味着多个线程同时读写同一个 HashMap 时对共享变量的操作不具备原子性、可见性和有序性保障。更具体地说put 操作不是原子的一次 put 包含「计算 hash、定位桶、遍历链表、创建节点、修改表头、size 自增」等多个步骤多线程交叉执行时可能互相覆盖。size 字段自增不是原子的size本质上是一个「读、加一、写回」的过程多个线程同时执行会导致计数丢失。扩容过程会修改链表指针两个线程同时扩容时共享的节点对象可能被互相改写next指针形成环形链表。没有内存可见性保障一个线程对 table 的修改另一个线程不一定能及时看到最新的值可能出现读旧值的情况。正是因为这些缺陷死循环问题才有了发生的土壤。下面我们进入 JDK 1.7 的扩容机制看看 transfer 方法到底做了什么。三、JDK 1.7 的扩容机制resize 与 transfer死循环发生在扩容过程中因此必须把扩容机制讲透。JDK 1.7 的扩容由resize方法触发核心的数据迁移由transfer方法完成。3.1 什么时候触发扩容HashMap 中有两个关键参数容量capacity和加载因子loadFactor。当元素的个数size达到capacity * loadFactor时就会触发扩容。例如默认容量 16、默认加载因子 0.75那么阈值就是 12当插入第 13 个元素时开始扩容。JDK 1.7 判断扩容的代码位于addEntry方法中void addEntry(int hash, K key, V value, int bucketIndex) { if ((size threshold) (null ! table[bucketIndex])) { resize(2 * table.length); // 扩容为原来的 2 倍 hash (null ! key) ? hash(key) : 0; bucketIndex indexFor(hash, table.length); } createEntry(hash, key, value, bucketIndex); }注意这里有一个容易被忽略的细节这段代码的扩容判断和扩容执行本身不是线程安全的。多线程环境下两个线程可能同时通过size threshold判断然后各自执行resize各自维护一套新数组最终后完成的线程把自己的新数组赋值给全局table覆盖掉另一个线程的结果从而引发数据丢失。3.2 resize 与 transfer 源码逐行分析先看resize方法void resize(int newCapacity) { Entry[] oldTable table; // 记录旧数组 int oldCapacity oldTable.length; if (oldCapacity MAXIMUM_CAPACITY) { threshold Integer.MAX_VALUE; return; } Entry[] newTable new Entry[newCapacity]; // 创建更大的新数组 transfer(newTable, initHashSeedAsNeeded(newCapacity)); // 把旧数据迁移到新数组 table newTable; // 最后才把全局 table 指向新数组 threshold (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY 1); }扩容的核心逻辑是创建容量为原来 2 倍的新数组把旧数组中的所有节点重新散列并迁移过去最后把table字段指向新数组。其中数据迁移由transfer完成。再看 transfer 方法这是死循环的主角void transfer(Entry[] newTable, boolean rehash) { int newCapacity newTable.length; // 外层循环依次遍历旧数组的每一个桶 for (EntryK,V e : table) { // 内层循环遍历当前桶上的链表 while (null ! e) { EntryK,V next e.next; // 先保存当前节点的下一个节点 if (rehash) { e.hash null e.key ? 0 : hash(e.key); } int i indexFor(e.hash, newCapacity); // 计算在新数组中的下标 e.next newTable[i]; // 头插当前节点指向新桶的头节点 newTable[i] e; // 当前节点成为新桶的头节点 e next; // 继续处理原链表中的下一个节点 } } }这段代码可以拆成四个关键步骤保存后继节点EntryK,V next e.next;先把当前节点在原链表中的下一个节点保存到局部变量 next 中作为后续遍历的锚点。重新计算桶下标根据扩容后的容量计算当前节点应该落在新数组的哪个桶。头插到新桶e.next newTable[i]; newTable[i] e;把当前节点插到新桶链表的头部。移动游标e next;游标后移继续处理原链表的下一个节点。单线程下这段逻辑没有任何问题。假设旧数组长度为 2桶 1 上的链表是A - B - null迁移完成后新数组桶 1 上的链表会变成B - A - null。顺序被反转了但结构仍然是合法的单向链表。问题就出在多线程同时执行 transfer 时节点对象 A、B 在堆中是共享的每个线程都在修改它们的 next 指针但每个线程执行的进度和保存的局部变量各不相同。正是这种「共享对象 局部变量快照 指针互改」的组合最终拼出了一个环形链表。3.3 头插法的动机与隐患JDK 1.7 选择头插法是有历史原因的。早期开发者认为新插入的元素更可能被立刻访问类似 LRU 的朴素直觉头插法可以让新元素更快被找到同时在已知链表长度的情况下头插法的实现也最简单不需要维护尾指针。但头插法有一个致命特性它会在遍历旧链表的同时反转新链表的顺序。在单线程环境下这只是一个效率问题在多线程环境下这种「边遍历、边改写指针」的做法非常危险。因为节点是共享的一个线程刚把某个节点的 next 改成 A另一个线程就可能基于过期的认知继续改写最终让 next 指针绕成一个环。四、死循环如何形成并发扩容下的环形链表下面进入本文最核心的部分。我们将用一个最小化、可控的场景一步一步推演两个线程并发扩容时环形链表是如何诞生的。请务必跟上每一步的指针变化。4.1 场景铺垫假设有一个 HashMap初始容量为 2加载因子为 0.75此刻有两个 key分别记作 keyA 和 keyB的 hash 值经过indexFor计算后都落在旧数组的桶 1 上。旧数组桶 1 上的链表结构如下旧数组 table[1]A - B - null 其中 A.next BB.next null现在 size 达到阈值需要扩容。两个线程 T1 和 T2 几乎同时进入resize各自创建了自己的新数组 newTable容量 4并进入transfer。注意两个线程有自己的新数组和局部变量但它们操作的是同一批堆上的节点对象 A、B。4.2 初始状态两个线程刚开始 transfer 时的状态如下角色共享堆对象状态线程局部状态旧数组table[1] AA.next BB.next null—线程 T1—e Anext 尚未赋值线程 T2—e Anext 尚未赋值4.3 第一步线程 T1 挂起线程 T1 开始执行第一次循环读取e A执行next e.next于是next B。就在它即将执行e.next newTable[i]之前CPU 时间片用尽T1 被挂起。挂起点非常关键。此刻 T1 虽然还没有修改任何共享数据但它已经把A.next的值快照到了自己的局部变量next B中。T1 的局部状态是线程 T1 局部变量e Anext B 共享对象A.next BB.next null尚未被 T1 修改4.4 第二步线程 T2 完成扩容T2 拿到 CPU 后从头到尾完整执行了一遍 transfer把旧链表 A - B 迁移到了自己的新数组中。我们逐步看 T2 做了什么T2 第一轮循环e Anext B 计算 A 在新数组的下标假设仍为 1 A.next newTable[1]此时为 null所以 A.next null newTable[1] A e next B此时 T2 的新数组桶 1 为A - null共享对象状态变为A.next null。T2 第二轮循环e Bnext B.next null B.next newTable[1]此时为 A所以 B.next A newTable[1] B e null循环结束此时 T2 的新数组桶 1 为B - A - null。请注意此时共享堆对象的状态A.next nullB.next A。这正是为环形链表埋下的种子——B 的 next 已经反过来指向了 A。T2 的 transfer 结束后执行table newTable把全局 table 指向了自己的新数组。T2 的整个扩容完成。4.5 第三步线程 T1 恢复执行现在轮到 T1 继续运行。T1 从挂起点恢复它的局部变量仍然是e Anext B。T1 并不知道 T2 已经修改了B.next它继续执行第一次循环剩余的部分T1 第一轮循环恢复后 e Anext B局部变量 A.next newTable[1]T1 自己的新数组初始为 null所以 A.next null newTable[1] A e next B此时 T1 的新数组桶 1 为A - null共享对象 A.next 被 T1 改回 null与之前 T2 结束时的 A.next null 一致。接着 T1 进入第二轮循环T1 第二轮循环 e B next e.next B.next关键点出现了T1 读取B.next时读到的是共享堆对象的最新值。而 T2 在第二步已经把B.next改成了 A。所以 T1 读到的next A而不是 null。继续第二轮循环 next A B.next newTable[1]T1 的新数组当前为 A所以 B.next A newTable[1] B e next A现在共享对象状态是B.next AA.next null注意 A.next 还是 null环还没闭合T1 的新数组桶 1 为B - A - null游标 e 变成了 A。4.6 第四步环形链表的诞生T1 的游标 e A 不为 null于是进入第三轮循环T1 第三轮循环 e A next e.next A.next此时 A.next 是多少在 4.5 节中T1 第一轮循环已经把A.next设成了 null后面 T1 和 T2 都没有再改过 A.next所以A.next null即next null。继续第三轮循环 A.next newTable[1]T1 的新数组当前为 B所以 A.next B newTable[1] A e next null循环结束。最终 T1 的新数组桶 1 上的链表是A - B而 B.next 又指向 A。也就是说A.next B B.next A环形链表正式形成链表中没有任何节点的 next 为 null遍历进入 A - B - A - B 的无限循环。4.7 状态推演总结表为了帮助理解我们把整个过程汇总成一张状态表只看共享对象 A、B 的 next 指针变化阶段A.nextB.nextT1 局部变量T2 局部变量说明初始状态Bnull——旧链表 A - BT1 挂起BnulleA, nextB—T1 尚未修改共享对象T2 第一轮nullnulleA, nextBeA→BT2 把 A.next 改为 nullT2 第二轮nullAeA, nextBenull关键B.next 被 T2 改为 AT1 第一轮nullAeB, nextB(旧)—T1 恢复A.next 仍为 nullT1 第二轮nullAeA, nextA—T1 读到 B.nextA错把 A 当后继T1 第三轮BAenull—A.next 被改为 B环形成这张表清晰地展示了死循环的根源T2 修改了共享节点 B 的 next 指针而 T1 基于自己保存的过期局部变量继续执行最终把 A.next 也指回了 B形成 A↔B 的环。如果链表更长、线程更多环的形成过程会更复杂但本质完全一致。4.8 从 JMM 视角理解为什么会读错值有的读者可能会困惑T1 中间明明保存了next B为什么后面又要去读B.next原因在于局部变量保存的是节点的引用而不是节点内部next字段的值。当 T1 第二次循环执行next e.next时它真正读取的是堆中 B 对象的next字段而该字段是共享可变状态早已被 T2 改写。从 Java 内存模型JMM角度看每个线程有自己的工作内存主内存中的Entry对象的 next 字段是共享变量。e.next的读取可能读到过期的本地缓存也可能读到其他线程刚写回的最新值没有 volatile 或锁的保护编译器与处理器都可能对指令进行重排序进一步加剧不确定性。正因如此死循环的出现是概率性的取决于线程调度的精确时序。五、死循环如何被触发get 操作的遍历环形链表形成之后并不会立刻让程序崩溃真正的灾难发生在「有人去遍历这个环」的时候。HashMap 中最常见的遍历操作就是 get 查找。5.1 get 方法源码JDK 1.7 的 get 方法最终会调用getEntry其核心是一个典型的链表遍历final EntryK,V getEntry(Object key) { if (size 0) { return null; } int hash (key null) ? 0 : hash(key); for (EntryK,V e table[indexFor(hash, table.length)]; e ! null; e e.next) { Object k; if (e.hash hash ((k e.key) key || (key ! null key.equals(k)))) { return e; } } return null; }这段代码的循环终止条件只有一个e ! null。正常情况下链表最终会走到null结束循环但如果链表形成了环e就永远不会为 null。5.2 遍历环形链表当某个线程对一个包含环形链表的桶执行 get 时第一次迭代e A 第二次迭代e BA.next 第三次迭代e AB.next 第四次迭代e BA.next ……无限循环每次迭代 e 都在 A 和 B 之间来回切换永远不等于 nullfor 循环永远无法退出。如果查找的 key 恰好不存在于环上还会不断执行key.equals判断白白消耗 CPU。5.3 为什么 CPU 会飙到 100%这个死循环没有任何阻塞操作线程会以最大速度空转。在多核机器上一个线程空转就能吃满一个 CPU 核心如果使用线程池可能会蔓延出多个这样的任务最终表现为整个服务的 CPU 使用率飙到 100%。真实线上排查时往往会通过top观察 CPU 占用再用jstack查看线程堆栈典型特征如下http-nio-8080-exec-3 #40 daemon prio5 os_prio0 tid0x00007f9c3c00a800 nid0x1234 runnable [0x00007f9c20e5f000] java.lang.Thread.State: RUNNABLE at java.util.HashMap.getEntry(HashMap.java:465) at java.util.HashMap.get(HashMap.java:417) at com.example.CacheService.get(CacheService.java:32)RUNNABLE状态 栈顶长期停留在HashMap.getEntry是并发扩容死循环的典型特征。如果进一步用 jmap 或分析工具查看堆甚至可以看到某个桶的链表确实形成了环。5.4 死循环与死锁的本质区别很多初学者容易把「死循环」和「死锁」混为一谈其实二者有本质区别死锁Deadlock多个线程相互持有对方需要的资源并互相等待线程状态通常是BLOCKED或WAITING线程不消耗 CPU。死循环Infinite Loop单个线程由于循环退出条件永远不满足而持续空转线程状态是RUNNABLE会疯狂消耗 CPU。HashMap 并发扩容导致的是典型的死循环表现为 CPU 飙高而不是线程阻塞。理解这个区别对线上快速定位故障很有帮助。六、并发扩容还会带来哪些问题死循环是最严重、最出名的问题但绝不是唯一的问题。实际上多线程操作 HashMap 还可能遇到数据丢失、size 统计不准、扩容结果被覆盖等其他并发缺陷。6.1 数据丢失两个线程的 put 互相覆盖当两个线程同时向同一个桶插入元素时可能都判断该桶为空然后各自写入了自己的节点最终后写入的节点覆盖了先写入的节点导致一个元素凭空消失。下面是一段高度简化的伪代码用来示意这种竞态// 两个线程并发执行类似逻辑仅示意非真实源码 Thread t1: 判断 table[1] null // 为 true Thread t2: 判断 table[1] null // 为 true Thread t1: table[1] node1 Thread t2: table[1] node2 // 覆盖 node1 // 最终 node1 丢失即使不是向空桶插入两个线程在遍历链表后同时修改表头同样可能造成覆盖因为「检查并修改」不是原子操作。6.2 size 统计不准HashMap 的size字段在每次 put 成功后会执行size。但size不是原子操作它对应三条指令读取 size 到寄存器、寄存器加一、把结果写回。两个线程同时 put 时可能出现如下交错初始 size 10 T1 读 size 10 T2 读 size 10 T1 写回 11 T2 写回 11 // 两次 put 后 size 只增加了 1结果就是实际元素个数和 size 字段对不上后续可能影响扩容时机判断、迭代边界等。这也是为什么 HashMap 的 size 在多线程下不可信。6.3 扩容结果被覆盖前面 6.1 讲的是普通 put 的覆盖还有一种更隐蔽的覆盖发生在扩容阶段。两个线程同时触发 resize各自创建了新数组并完成迁移然后执行最后一步table newTable; // 每个线程都把全局 table 指向自己的新数组后执行这行代码的线程会覆盖先执行线程的结果。此时另一个线程已经迁移到旧 table 上的元素因为 table 被整体替换就相当于「凭空消失」了。这种丢失不是单个 key 的丢失而可能是整批数据的丢失。6.4 遍历时抛出 ConcurrentModificationException虽然 HashMap 的迭代器被称为 fail-fast 机制但那是在单线程「遍历时修改」的情况下通过 modCount 检查触发的。多线程环境下一个线程遍历、另一个线程修改结构同样可能触发ConcurrentModificationException。不过这种异常具有随机性有时会被触发有时则可能由于竞态而漏检最终表现为更隐蔽的数据问题。七、动手复现多线程 HashMap 死循环实验理论讲得再多不如亲手跑一遍。这一节我们给出一个可在 JDK 1.7 环境下复现死循环的实验。7.1 实验环境准备复现死循环必须使用JDK 1.7 或更早版本因为 JDK 8 已经用尾插法修复了环形链表问题。可以通过 Oracle 官网或 AdoptOpenJDK 下载旧版 JDK并确保编译和运行都使用 JDK 1.7。如果你没有 JDK 1.7也可以在 IDE 中下载一个兼容插件运行高版本但对 HashMap 行为差异要心中有数。本文代码中避免使用 lambda 表达式确保在 JDK 1.7 语法下可直接编译。7.2 复现代码import java.util.HashMap; public class HashMapInfiniteLoopDemo { // 初始容量设为 2加载因子 0.75让扩容更容易触发 private static HashMapInteger, Integer map new HashMapInteger, Integer(2, 0.75f); public static void main(String[] args) { // 先插入一批数据让 map 达到初始扩容阈值 for (int i 0; i 100000; i) { map.put(i, i); } Thread t1 new Thread(new Runnable() { public void run() { for (int i 0; i 100000; i) { map.put(i, i); } } }, Thread-1); Thread t2 new Thread(new Runnable() { public void run() { for (int i 0; i 100000; i) { map.put(i, i); } } }, Thread-2); t1.start(); t2.start(); // 主线程持续从 map 中读取制造对环形链表的遍历 while (true) { for (int i 0; i 100000; i) { map.get(i); } } } }7.3 实验结果观察在 JDK 1.7 下运行一段时间后程序很可能出现以下现象之一CPU 占用飙高某个线程长期占满一个核心使用top或任务管理器可以看到 CPU 使用率接近 100%。程序卡死但不退出main 线程或其他线程陷入 get 遍历无法继续执行。jstack 显示死循环栈线程长时间停留在HashMap.getEntry状态为RUNNABLE。需要强调的是死循环的出现是概率性的。它依赖两个线程在 transfer 中的精确交错时机所以并不是每次运行都必然复现。如果一次没有触发可以多运行几次或者适当调大数据量、增加线程数来提高触发概率。7.4 复现要点说明有几个实用技巧可以提高复现成功率初始容量设小容量越小阈值越低扩容越频繁发生两个线程同时扩容的概率越高。数据量足够大大量 put 会制造多次扩容增加并发扩容的交错机会。持续 get形成环之后必须有人遍历这个环才会表现出死循环主线程持续 get 是为了更快暴露问题。必要时用调试器或休眠在深入研究时可以在 transfer 的关键位置打日志或加短暂的 sleep帮助制造更可控的交错。提醒这类实验只建议在本地学习环境进行不要在生产环境或共享测试集群中随意运行避免把宿主机 CPU 打满影响他人。八、JDK 8 做了哪些改进JDK 8 对 HashMap 做了大量重构其中与死循环直接相关的是把头插法改为尾插法。这一改动从结构上消灭了环形链表。8.1 链表尾插法JDK 8 的扩容迁移不再「边遍历边把头节点换下来」而是先把原链表拆成低位链lo和高位链hi分别用 loHead/loTail、hiHead/hiTail 维护链头和链尾最后一次性挂到新数组上。核心代码如下NodeK,V loHead null, loTail null; NodeK,V hiHead null, hiTail null; NodeK,V next; do { next e.next; if ((e.hash oldCap) 0) { if (loTail null) loHead e; else loTail.next e; loTail e; } else { if (hiTail null) hiHead e; else hiTail.next e; hiTail e; } } while ((e next) ! null); if (loTail ! null) { loTail.next null; newTab[j] loHead; } if (hiTail ! null) { hiTail.next null; newTab[j oldCap] hiHead; }注意观察尾插法在遍历过程中是通过loTail.next e在链表尾部追加节点迁移完成后把尾节点的 next 置为 null。它不会把某个节点的 next 反过来指向前驱因此即使两个线程交错执行也不可能形成 A↔B 的环。8.2 JDK 8 的 hash 扰动函数优化除了插入方式的变化JDK 8 还对 hash 函数做了简化。JDK 1.7 的 hash 方法经过多次扰动让高位和低位都参与运算减少低位相同时的冲突JDK 8 则简化为static final int hash(Object key) { int h; // 高 16 位与低 16 位异或再配合 2 的幂次掩码散列 return (key null) ? 0 : (h key.hashCode()) ^ (h 16); }这种设计配合扩容时的「按 oldCap 高位判断分区」让节点只可能落在原位或原位加 oldCap 的位置搬迁逻辑更简洁也更利于并行化改进。8.3 树化机制JDK 8 还引入了红黑树。当一个桶上的链表长度超过阈值默认 8且数组总容量不小于 64 时链表会转换为红黑树把最坏查找复杂度从 O(n) 降低到 O(log n)。当红黑树中的节点被删除到阈值以下时又会退化回链表。树化主要是为了应对大量哈希冲突导致的性能退化与「死循环」问题本身没有直接因果但它和尾插法一起构成 JDK 8 HashMap 的重要演进。树化后的节点结构TreeNode同时维护了双向链表和红黑树结构迁移逻辑更复杂但依然遵循了不产生环的设计约束。8.4 JDK 8 还会不会有死循环结论是JDK 8 由于采用尾插法不会再出现 JDK 1.7 那种由并发扩容引发的环形链表死循环。这两个线程无论怎样交错链表结构都保持合法最多是覆盖或丢失不会进入无限遍历。但这里要防止一个常见误区JDK 8 并不会把 HashMap 变成线程安全的。多线程 put 依然可能导致数据丢失、覆盖和 size 不准。也就是说JDK 8 修好的是「最致命的死循环」而不是「线程不安全」这个根本属性。8.5 JDK 8 仍然存在的并发问题JDK 8 的 put 会先检查桶是否为空如果为空就创建一个新节点放入如果有元素则遍历链表。两个线程同时 put 到同一个空桶时仍可能发生覆盖// 示意两个线程同时判断桶为空然后各自放入节点 Thread t1: table[1] 为 null Thread t2: table[1] 为 null Thread t1: table[1] node1 Thread t2: table[1] node2 // node1 被覆盖数据丢失正因为这些原子性和可见性问题仍然存在Java 官方在文档和实践中都明确建议HashMap 只应在单线程环境中使用多线程环境应根据场景选择 ConcurrentHashMap 或其它线程安全容器。九、线程安全的替代方案既然 HashMap 不适合并发使用那么在多线程环境下我们应该选什么这一节对比三种常见的线程安全方案。9.1 ConcurrentHashMapConcurrentHashMap 是并发场景下的首选。它在 JDK 7 和 JDK 8 中采用了不同的实现JDK 7使用「分段锁Segment」思想把整个数组分成多个 Segment每个 Segment 独立加锁。写操作只锁住对应 Segment不同 Segment 之间的写操作可以并行提高并发度。JDK 8放弃了分段锁改用CAS synchronized。put 时如果目标桶为空用 CAS 直接写入如果非空则对桶的头节点加 synchronized 锁然后在锁保护下插入链表或红黑树。读操作通常无锁依靠 volatile 保证可见性。JDK 8 的 ConcurrentHashMap 还引入了CounterCell机制来高效统计元素个数类似于 LongAdder 的思路避免 size 统计成为并发瓶颈。它的读操作在绝大多数情况下不需要加锁非常适合「读多写少」的场景。9.2 HashtableHashtable 是 JDK 1.0 就存在的传统线程安全容器。它的实现非常简单粗暴几乎对所有读写方法都加了synchronized关键字相当于一把全局大锁。public synchronized V put(K key, V value) { // ... } public synchronized V get(Object key) { // ... }好处是线程安全、实现简单缺点是所有操作串行化并发性能非常差即使两个线程访问完全不同的桶也要竞争同一把锁。此外 Hashtable 不允许 null 键和 null 值使用上也不如 HashMap 灵活。现代 Java 开发中基本不再推荐使用 Hashtable。9.3 Collections.synchronizedMapCollections.synchronizedMap可以把任意 Map 包装成线程安全版本。它的原理与 Hashtable 类似也是在方法层面加锁只是加在一个独立的 mutex 对象上。MapString, String syncMap Collections.synchronizedMap(new HashMapString, String());这种方式可以快速获得一个线程安全的 Map但性能和扩展性同样受限于全局锁。更需要注意的是使用 synchronizedMap 进行复合操作时例如先判断再写入还必须手动对返回的 map 加 synchronized 块否则「检查 写」仍然不是原子的。9.4 三种方案对比方案线程安全机制并发度null 键值适用场景HashMap无高但线程不安全允许单线程ConcurrentHashMap分段锁 / CAS synchronized高不允许高并发读写首选Hashtable全局 synchronized低不允许遗留系统新代码不推荐Collections.synchronizedMap全局 mutex 锁低允许取决于底层 Map快速包装已有 Map低并发总的来说现代 Java 多线程环境中只要没有特殊历史包袱默认选择 ConcurrentHashMap是最稳妥的选择。十、面试中的高频追问理解完原理之后我们再看看这个知识点在面试中常见的延伸问题。这些问题往往能检验候选人是否真正读懂了源码而不是只会背结论。10.1 为什么 JDK 7 会死循环JDK 8 不会核心差异在于插入方式JDK 7 的头插法在迁移链表时会反转顺序并就地修改共享节点的 next 指针。两个线程交错执行时一个线程可能读到另一个线程修改后的 next最终让指针形成环。JDK 8 改为尾插法遍历原链表的同时只把节点追加到新链表尾部最后把尾节点 next 置 null不会修改任何节点的 next 使其指向前驱因此结构上不可能成环。10.2 头插法除了死循环还有什么问题头插法还会反转链表顺序可能导致迭代顺序不符合预期虽然 HashMap 本来就不保证顺序。在多线程场景下它更是把「遍历中修改共享指针」的危险放大到了极致。因此 JDK 8 的尾插法不仅修复了死循环也让迁移逻辑更符合直觉。10.3 初始容量为什么是 2 的幂因为下标计算使用hash (length - 1)只有当 length 是 2 的幂时这个表达式才等价于hash % length并且 mask 的低位全部为 1能让 hash 的低位充分参与定位散列更均匀。扩容时使用 2 倍扩容旧下标与oldCap的位运算就能一步区分节点该留在原位还是迁移到高位逻辑清晰且高效。10.4 加载因子为什么默认是 0.75加载因子是空间和时间的折中太小会导致频繁扩容浪费内存太大会让冲突增多链表过长降低查找效率。0.75 是在大量实验基础上选取的平衡点在时间和空间成本之间取得了较好的折中。10.5 ConcurrentHashMap 为什么比 Hashtable 高效Hashtable 用一把全局锁串行化所有操作任何时刻只有一个线程能访问容器。ConcurrentHashMap尤其 JDK 8把锁粒度细化到「桶」级别不同桶的写操作可以并行读操作大部分无锁CAS 避免了不必要的锁竞争size 统计也通过 CounterCell 分散热点。因此在高并发下拥有明显更高的吞吐量。10.6 多线程场景下能不能直接用 HashMap不能。即使 JDK 8 修复了死循环HashMap 在多线程下仍然有数据丢失、覆盖、size 不准等问题。对于多线程共享的数据结构应使用 ConcurrentHashMap如果只是线程封闭每个线程使用自己的 HashMap则完全没有问题。判断标准不是「会不会死循环」而是「数据结构是否会被多个线程同时访问」。10.7 如果已经用了 HashMap怎么快速做局部整改如果线上已有代码使用了 HashMap 且存在并发访问最简单的整改方式是替换为 ConcurrentHashMap并注意它不允许 null 键值。如果暂时不能替换应隔离共享状态让每个线程使用局部 HashMap或对共享 HashMap 的读写统一加锁。需要特别提醒的是加锁只能保证单个操作原子复合操作仍需额外的同步约束。十一、总结回顾全文我们可以把「多线程环境下 HashMap 为什么会出现死循环」这个问题拆解成一条清晰的因果链JDK 1.7 的 HashMap 由「数组 单向链表」组成本身没有任何并发控制。触发扩容时transfer 方法使用头插法逐个把旧链表节点迁到新数组过程中就地修改节点的 next 指针。两个线程同时扩容时共享同一批堆上的节点对象但各自维护独立的局部变量和新数组。一个线程先完成迁移修改了某个节点如 B的 next 指针指向其前驱如 A另一个线程随后基于过期的局部变量继续迁移把前驱的 next 也指回后继最终形成 A↔B 的环形链表。当后续的 get 操作遍历到这条环形链表时for (e ! null; e e.next)永远不会终止线程空转CPU 使用率飙高表现为死循环。除了死循环并发 HashMap 还可能导致数据丢失、size 统计不准、扩容结果被覆盖等问题。JDK 8 用尾插法和红黑树重构了 HashMap从结构上避免了环形链表但并没有让 HashMap 变成线程安全的类。真正的并发场景应该使用 ConcurrentHashMap。理解这个问题的意义不只是为了应付面试更是为了建立对「共享可变状态 并发修改」的敬畏。任何看起来稳定的数据结构一旦被多个线程同时修改都可能暴露出意想不到的问题。掌握底层原理才能在架构设计时做出正确的选择在故障排查时快速定位根因。希望这篇 2 万字的长文能帮你把 HashMap 的并发问题彻底吃透。