HashMap为何线程不安全?put竞态与resize并发解析

📅 发布时间:2026/10/13 12:24:50
HashMap为何线程不安全?put竞态与resize并发解析
HashMap 是 Java 日常开发里出场率最高的集合之一也是面试题里被翻牌次数最多的老面孔。很多人一听到“HashMap 线程不安全”条件反射就是“并发下用 ConcurrentHashMap 就行”但如果被追问一句“为什么不安全到底哪里不安全”能讲清楚的人其实不多。这篇文章想换个角度用“看图说话”的方式把 HashMap 的存储结构、put 路径、resize 扩容过程逐层拆开看看线程不安全的三个典型触发点到底长什么样以及 JDK7 到 JDK8 之间那些改动究竟解决了什么、又留下了什么。如果你正在准备面试或者遇到过并发环境下 HashMap 数据丢失、CPU 飙高的诡异问题这篇应该能帮上忙。1. 先从底层数据结构说起HashMap 的“正常世界”长什么样很多人会用“数组 链表”一句话概括 HashMap 的底层结构这在 JDK8 之前是对的JDK8 之后还得再加上一句链表过长时会被树化成红黑树。但不管怎么演进它的核心骨架一直没有变一个 Node 数组加上挂在数组下标上的链表或红黑树。1.1 数组、链表、红黑树三者是怎么配合的先画一个最简化的 HashMap 存储示意图后面所有并发问题的解释都会基于它tableNode 数组 ┌────┬────┬────┬────┬────┬────┐ │ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ └────┴────┴────┴────┴────┴────┘ │ │ ▼ ▼ Node0 Node3 │ │ ▼ ▼ Node1 Node4每次 put 一个 key-valueHashMap 都会先对 key 做 hash 扰动再用(n - 1) hash算出它应该落到数组的哪个下标。如果这个下标当前是空的就直接放一个 Node 进去如果已经有 Node 了就沿着链表往后找找到 key 相同的就替换 value找不到就在链表末尾追加JDK7 是头插JDK8 是尾插。当某个桶的链表长度达到 8 且数组长度达到 64 时链表会转成红黑树把最坏情况下的查找时间从 O(n) 压到 O(log n)。从单线程的视角看这个结构没什么问题。但注意图中的数组、链表节点、内部的size计数、modCount修改次数全部是 HashMap 实例内部的共享字段。线程之间通过共享内存通信多个线程同时读没问题一旦同时写这些字段就会变成竞争资源。1.2 为什么“共享可变状态”是线程不安全的源头我经常用一个更生活化的类比来解释HashMap 就像一张挂在墙上的大白板每个人都可以往上面写东西。单线程的时候你是唯一的使用者想怎么写就怎么写不会有人跟你抢笔。多线程的时候就好比好几个人同时拿记号笔往白板上写字而且写之前都会先看一眼某个区域有没有字决定要不要写。问题就出在“看一眼”和“写下去”这两步之间。如果两个人同时看中同一个空白区域并且都认为“这里没人写”然后各自写上去后写的人就会把先写的人覆盖掉。白板上的数据就丢了。HashMap 的 put 操作里就有这样的“先检查后执行”模式而且不止一次。这种不安全的本质不是某个方法写错了而是整个 HashMap 类压根没有做内部同步。它的设计目标就是单线程下的高性能所有字段和方法都是为了“一个人快速读写”而优化。把这样的数据结构扔进多线程环境却不加外部保护出事只是迟早的问题。所以后面讨论的每一个具体 bug其实都是“共享可变状态 非原子复合操作”在不同场景下的变体。2. 线程不安全的第一现场put 的“检查-赋值”竞态HashMap 最常用的方法就是 put但 put 从来不是一个原子操作。它内部有多个步骤每一步都可能被打断。源码里看起来只是几行赋值在线程调度器眼里却是若干个独立指令在来回穿插。2.1 从 putVal 源码看非原子路径JDK8 的 put 最终会走到putVal方法核心逻辑大概是这样的final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { NodeK,V[] tab; NodeK,V p; int n, i; if ((tab table) null || (n tab.length) 0) n (tab resize()).length; if ((p tab[i (n - 1) hash]) null) // 第一次检查 tab[i] newNode(hash, key, value, null); // 赋值 else { // 遍历链表、树找到则替换找不到则插入 } modCount; if (size threshold) resize(); }这里最关键的是这两行if ((p tab[i (n - 1) hash]) null) tab[i] newNode(hash, key, value, null);第一行先去读tab[i]判断这个桶是不是空的第二行把新节点写进这个桶。两步之间的时间窗口非常短但在多线程环境下已经足够发生事故了。2.2 两个线程同时“看到”空桶会发生什么假设有两个线程 T1 和 T2 同时执行 put插入的是两个不同的 key但这两个 key 经过 hash 计算后落到了同一个数组下标 i并且当前tab[i]是 null。一个典型的时钟交错是这样的T1: 读取 tab[i] - null T2: 读取 tab[i] - null T1: tab[i] Node_A T2: tab[i] Node_B // Node_A 被覆盖T2 的写入把 T1 刚放进去的 Node_A 整个覆盖掉了。注意 это不是“两个线程写同一个 key 所以后写的覆盖先写的”那种预期行为而是两个原本完全不冲突的 key因为一个“都看到桶为空”的窗口最终丢掉了其中一个 key。丢失的数据在 HashMap 里完全不存在了size 统计却可能仍然是 2因为两次size都执行了于是 HashMap 内部的数据视图就彻底不一致了。这种问题极其隐蔽。因为两个 key 不一定每次都会竞争同一个空桶只有在哈希值落到同一个槽位、且该槽位当前为空、且两个线程几乎同时执行时才会触发。所以它不是必然发生而是概率性发生。但线程越多、put 越频繁发生概率就越大一旦发生就是数据丢失级别的故障。2.3 size 和 modCount 的更新为什么也会丢数据除了桶里的节点会被覆盖HashMap 内部还有两个普通 int 字段也在被并发修改size和modCount。size用来记录当前键值对数量modCount用来记录结构性修改次数JDK8 里遍历时会用它做 fail-fast 检测。size看起来是一条语句但编译成字节码后其实是多个指令读取当前值、把值加 1、写回字段。这个读取-修改-写入的过程不是原子的。两个线程同时执行size可能都会读到同一个旧值然后各自加 1 写回结果 size 只增加了 1而不是 2。举个例子初始 size 0 T1: read size (0) T2: read size (0) T1: write size (1) T2: write size (1) // 两次 put 只让 size 变成了 1这就是经典的丢失更新问题。modCount也一样而且 modCount 多出的问题在于如果它意外没有增加后面某个线程触发 fail-fast 检查时可能不会抛异常但如果它增加了而另一个正在遍历的线程已经迭代到一半就会抛出ConcurrentModificationException哪怕遍历期间根本没有发生真正的结构修改只是 modCount 在多线程下错乱。所以即便数据没有因为“覆盖空桶”而丢失size 作为一个共享计数器也会在并发下变得不准确。你调用map.size()想看看有多少数据得到的可能是一个比实际值小得多的数字甚至是错误的值。单线程下 HashMap 的这些字段都是准确可靠的并发下它们一个比一个不靠谱。3. 线程不安全的第二现场resize 扩容的并发灾难如果说 put 的竞态只是“小打小闹”地丢数据那 resize 扩容引发的并发问题就是重磅炸弹。JDK7 时代最著名的死循环、CPU 100% 问题就是发生在扩容过程中。JDK8 虽然修掉了死循环但扩容的并发安全问题并没有彻底消失。3.1 扩容时 HashMap 是怎么“搬家”的HashMap 并不是无限存数据的。当size thresholdthreshold 容量 × 负载因子默认负载因子 0.75时它会把数组扩成原来的两倍。扩容不是简单地把数组变大而是要对所有已有元素重新计算下标然后把它们搬进新数组。为什么必须重新计算下标因为下标计算依赖数组长度 nindex (n - 1) hash。n 变了同一个 hash 算出来的下标自然就变了。比如 n 从 16 变成 32索引就从hash 15变成hash 31。元素散落的位置整体不一样了所以要逐个节点地“搬家”。单线程扩容时这个过程是完整、有序的。JDK7 的迁移逻辑大致是遍历旧数组的每个桶取出链表上的节点用头插法把它插入新数组对应位置。头插法意味着新迁移的节点会成为链表的头部因此同一个桶里的链表顺序在扩容后会发生反转。3.2 JDK7 头插法在并发下如何形成环形链表死循环这个问题的经典根源就出在头插法 并发转移上。举个例子假设旧数组某个桶里有一条链表A - B两个线程 T1 和 T2 同时触发扩容都准备把这条链表搬到新数组。JDK7 的 transfer 逻辑里有类似这样的操作void transfer(Entry[] newTable, boolean rehash) { int newCapacity newTable.length; for (EntryK,V e : table) { while(null ! e) { EntryK,V next e.next; // 第一步记录下一个节点 int i indexFor(e.hash, newCapacity); e.next newTable[i]; // 第二步把当前节点插到新表头部 newTable[i] e; // 第三步更新新表头 e next; // 第四步指向下一个节点 } } }关键就在这个while循环。线程 T1 执行到一半处理完 A还没处理 B就被线程调度器切走了。此时 T1 的局部变量里保存着e Anext B。线程 T2 接管把整个链表完整地迁移到了新数组且由于头插法新数组里的链表变成了 B - A。等 T1 恢复执行时它的局部变量还是旧状态。它继续处理 AA.next 本来指向 null但 T2 已经改写了 A.next不对A.next 在旧数组里是 B但 T2 迁移时通过e.next newTable[i]改写了 A 的 next 指向。经过一番错乱的操作后A 的 next 可能指向 BB 的 next 又可能指回 A于是新数组里出现了一个回环A - B - A - B ... 环形链表就此形成。环形链表的过程用文字描述有点绕用示意图看更直接正常链表 A - B - null T2 迁移后B 在头部 B - A - null T1 继续插入 A因为 A.next 已经被改成 B再把 A 放到头部 A - B - A - B - ...一旦环形链表形成后续任何get操作在这个桶里遍历时都会沿着环永远转下去永远找不到 null 结束条件于是线程卡死CPU 100%。这个问题在大并发、频繁扩容的场景下极难排查因为你不容易想到是数据结构本身变成了环。现在很多线上项目早就切到 JDK8但如果你维护过老系统或许见过这种“服务器 CPU 突然飙满jstack 一看线程卡在 HashMap.get”的经典画面。3.3 JDK8 尾插法改进了什么又留下了什么JDK8 对 resize 做了一个方向性的改动把头插法改成了尾插法。迁移链表时不再把节点插到新表头部而是按原来的顺序直接挂在新表尾部。这样一来链表的顺序不会反转之前那种两个线程互相调整头引用导致成环的条件就被破坏掉了。所以从 JDK8 开始HashMap 在并发扩容时不会再形成环形链表不再出现死循环。但绝不能因此觉得 JDK8 的 HashMap 就线程安全了。它只是把最剧烈的症状去掉了根本问题还在。JDK8 的 resize 会遍历旧数组把每个桶里的节点迁移到新数组。这个迁移过程并没有加锁。如果线程 A 正在迁移某个桶线程 B 也执行 put且同样需要扩容两个线程就可能基于同一个旧数组各自生成新的数组。最后谁先table newTable谁后赋值取决于调度顺序。后赋值的会把先赋值的整个新数组覆盖掉之前线程辛苦迁移的数据、其他线程 put 进去的新数据都可能从这个 HashMap 的视野里消失。另外table 字段本身也不是 volatile 的这意味着一个线程写入 table 后另一个线程未必能立刻看到最新的数组引用。可见性缺失在并发编程中是个隐蔽的大坑。即使没有发生覆盖某一个线程读到的 table 可能还是旧数组它往旧数组里写入的数据在另一个线程眼里相当于写到了“平行世界”最后表现在业务上就是数据神秘丢失、脏读、size 诡异。所以一个相对准确的结论是JDK7 的 HashMap 并发问题包含了数据覆盖、数据丢失、环形链表死循环JDK8 修掉了死循环但数据覆盖、数据丢失、size 不准、扩容互相覆盖的问题依然存在。HashMap 的不安全是结构性的不是改一改插入方向就能解决的。4. 常见的并发症状与排查实录理论讲多了容易飘实际踩坑才是最深刻的。下面这几个现象是我觉得最有代表性的如果你在项目里遇到类似问题可以先往 HashMap 并发这里怀疑。4.1 现象一业务数据莫名减少size 还不准这是最常见的并发症状。多线程往同一个 HashMap 里写数据最后发现 map 里的 key 数量远小于应该写入的数量或者某个 key 明明 put 过get 却是 null。我做过一个很简单的复现实验开 20 个线程每个线程往同一个 HashMap 里 put 一万个不同的 key用CountDownLatch等所有线程结束然后打印 size。单线程跑的时候 size 总是正确的多线程跑的时候十次里有七八次 size 会小于预期有时候少几百有时候少几千。反复执行几次每次少的值还不一样表现非常随机。这个随机性本身就说明了问题它不是某个固定逻辑写错了而是操作系统线程调度的交错时机不同导致丢失窗口出现的位置不同。如果你在本地单测里没复现不代表线上没问题只是并发压力不够、窗口没被撞上。4.2 现象二JDK7 环境下 CPU 飙到 100%JStack 看到线程卡在 HashMap如果你是维护老项目的且运行环境是 JDK7那么“运行一段时间后某个节点的 CPU 突然飙满重启才好”的事件十有八九和 HashMap 并发扩容死循环有关。排查方式一般是这样用jstack抓线程栈会发现某个业务线程一直停留在java.util.HashMap.get或java.util.HashMap.put的调用栈上状态是 RUNNABLE但就是执行不完。这里的“卡住”不是锁等待那是BLOCKED状态是线程真的在环形链表上无限遍历CPU 被它占到 100%。如果现场停不下来可以连续抓几次线程栈看那个线程是否永远在同一行代码附近。如果是基本可以断定数据结构已经坏了。JDK8 后不再有死循环但如果哪个线程栈卡在resize相关调用上或者直接抛ConcurrentModificationException也得往并发写 HashMap 上面想。4.3 排查建议先确认隔离再看数据流碰到这类问题我建议按这个顺序排查先确认是否真的是并发环境看这段 HashMap 的作用域会不会被多个线程同时写入如果写入只在一个线程里但读取在多个线程里也可能因为可见性问题读到旧值。再看是否有外部同步调用方有没有在 HashMap 外面加锁有没有用Collections.synchronizedMap包一层如果没有大概率就是裸 HashMap 并发。再看数据流是先get再put还是单纯put先 get 再 put 这种“复合操作”即使容器本身线程安全也可能有逻辑竞态更别说裸 HashMap。最后做压测复现多写几个线程加大 put 的并发量把 key 数量放大。一旦复现直接换容器再跑一次路况立竿见影。注意不要用HashTable或者synchronizedMap做“性能对比”时轻易下结论它们确实线程安全但并发瓶颈可能转移到锁上。压测时要看吞吐量、延迟分布和 CPU 负载不要只看 size 对不对。4.4 一个“看起来没毛病”的隐患读多写少的场景还有一类场景特别容易踩雷HashMap 在初始化阶段被单线程填充之后只读不写。很多框架代码里都有这种“静态 map 当作配置表”的用法。如果是“没有线程写”的纯读场景HashMap 是安全的因为读读并发不产生竞态。但一旦你隐隐约约觉得“反正没人写我就没加锁”某天某个线程偷偷往里 put 了一次整个共享结构就可能被污染。所以保险起见这种不可变配置型 map 最好在初始化后包装成不可变 map或者用ConcurrentHashMap。5. 工程上怎么解决替代方案和选型建议面试被问“HashMap 线程不安全你会怎么做”标准答案当然是 ConcurrentHashMap。但工程选型远比面试答案复杂因为安全性永远是配合性能、可维护性一起权衡的。5.1 HashTable 和 synchronizedMap 为什么“能用但慢”HashTable是早期 Java 类库提供的线程安全 Map。它的做法最简单粗暴给put、get、remove等公开方法都加上synchronized关键字也就是锁住整个 HashTable 实例。Collections.synchronizedMap本质上也是包装一层全方法锁和 HashTable 的并发粒度没有本质区别。这种做法的优点是实现简单、逻辑不容易出错缺点是并发能力极差。因为所有线程不管是读还是写都要竞争同一把全局锁。读操作被写操作阻塞写操作被读操作阻塞吞吐量随线程数增加反而下降。在“读多写少”的缓存场景里全局锁会让 HashMap 原本引以为傲的 O(1) 查找速度彻底失去意义。5.2 ConcurrentHashMap 是怎么把锁粒度降下来的JDK7 的 ConcurrentHashMap 把数据分成多个 Segment 段每个 Segment 是一把独立的锁。不同线程操作不同 Segment 时完全不会互相阻塞只有操作同一个 Segment 才会竞争。JDK8 干脆取消了 Segment改用 CAS 配合 synchronized 对单个桶或单个节点加锁锁粒度变得更细。JDK8 的写入流程很有意思如果桶为空直接用 CAS 把新节点放进数组这一步不需要加锁如果桶不为空就用 synchronized 锁住这个桶的头节点然后做链表或红黑树的插入。相比 HashTable 全表锁这种“桶级锁”让不同桶的并发写入互不干扰多个线程可以同时 put 到数组的不同位置。这也是为什么 ConcurrentHashMap 在并发写入场景下吞吐量明显优于 HashTable 的核心原因。需要理解的是ConcurrentHashMap 不是靠“神奇的算法”变安全的它要么把复合操作拆成原子化的单次 CAS要么用细粒度锁把竞争范围压缩到最小。它本质上是把 HashMap 的单线程安全和多线程安全性做了工程折中。5.3 什么时候该用普通 HashMap什么时候必须换我自己的经验可以整理成下面这张简单的决策表场景推荐容器理由单线程环境追求极致读写性能HashMap无锁开销JDK8 起性能很好多线程同时写key 动态变化ConcurrentHashMap桶级锁兼顾安全与并发性能多线程同时写但需要 get 然后 put 的复合操作ConcurrentHashMap 自旋/外部同步需要把“检查-操作”变成原子单独容器锁不够初始化后只读的配置 mapHashMap 不可变包装或 ConcurrentHashMap纯读安全但不可变包装能防止未来被误写key 有顺序要求TreeMap / ConcurrentSkipListMapHashMap 本身无序不要硬用第 3 行特别值得多说一句。有些同学听说 ConcurrentHashMap 线程安全就把所有并发 map 逻辑都往里塞然后写这样的代码if (!map.containsKey(key)) { map.put(key, value); }这段逻辑在并发下仍然不安全。两个线程先同时检查 key 不存在然后都执行 put结果后写的覆盖了先写的。这不是 ConcurrentHashMap 的问题而是你把自己的复合操作当成了原子操作。真要保证这类“先查后写”的原子性可以用putIfAbsent(key, value)这类方法或者外部加锁。5.4 如果只是想临时压测怎么快速替换有一种低成本的做法是启动参数加-Djava.util.HashMap.useAltHashing不这不解决问题。真正快的替换是全局搜 HashMap 的声明看它会不会被多线程写。如果会把类型改成 ConcurrentHashMap然后重新编译运行。因为 ConcurrentHashMap 并没有继承 HashMap它是独立的 Map 实现但接口基本都是 Map业务代码通常不需要大改。对于“初始化填充后只读”的 map我更喜欢用Collections.unmodifiableMap(new HashMap(...))包一层。这样将来某个同事想把 map 当缓存随手放进多线程环境时会立刻抛异常而不是静默丢数据相当于把错误提前暴露在测试阶段。最后聊聊我在实际调试中的体会HashMap 线程不安全这个问题网上文章很多但真正让我把它彻底记住的是有一次在压测环境里亲眼看到 size 数据莫名变少。当时第一反应是业务代码有 bug查了很久都没找到最后把 HashMap 换成 ConcurrentHashMap问题立刻消失。那一刻我才明白很多“玄学”故障底层都是一些非常基础的数据结构知识。如果你也在排查类似问题我想给两个小建议。第一个别迷信“我平时 put 都是单线程的”这种想法要检查实际运行时是否有隐藏线程在操作静态工具、定时任务、异步回调都可能是暗流。第二个面试时不要只背“HashMap 不安全ConcurrentHashMap 安全”这个结论尝试把 put 的检查-赋值、resize 的并发迁移、size 的丢失更新这三条链路串起来讲。能讲清楚“为什么不安全”比知道“用什么替代”更能拉开差距。