HashMap 用在并发场景中会出现什么问题?
2026年01月22日
一则或许对你有用的小广告
欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论
- 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于
Spring AI + Spring Boot3.x + JDK 21..., 点击查看; - 《从零手撸:仿小红书(微服务架构)》 已完结,基于
Spring Cloud Alibaba + Spring Boot3.x + JDK 17..., 点击查看项目介绍; 演示链接: http://116.62.199.48:7070/; - 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/
面试考察点
面试官问出这个问题,主要希望考察:
- 对基础数据结构线程安全性的理解:你是否清楚
HashMap在设计上并非线程安全,以及 “非线程安全” 在并发场景下的具体表现。 - 对并发问题根因的探究能力:不仅仅是知道会出问题,更希望你能深入到源码层面,解释问题产生的机制,例如 JDK 7 和 JDK 8 中问题的异同。
- 解决实际问题的工程思维:面对并发场景,你如何选择合适的替代方案(如
ConcurrentHashMap),并了解其背后的设计原理与权衡。 - 排错与调试经验:是否能联想到此类问题在生产环境中可能导致的现象(如 CPU 飙升、数据错乱),这体现了你的实际项目经验。
核心答案
HashMap 在并发场景下直接使用会导致数据不一致和程序死循环(主要在 JDK 7 中)等问题。因为它内部的状态(如数组、链表、红黑树)在被多线程同时修改时,缺乏同步保护,从而导致链表/树的结构被破坏。绝不能在多线程写操作(包括修改)的场景下直接使用非同步的 HashMap。
深度解析
原理/机制
HashMap 的线程不安全主要体现在 “结构性修改” 上,即导致其内部数组 table 或链表/树结构发生变化的操作,主要包括 put、resize (扩容) 等。
-
数据丢失或覆盖
- 场景:两个线程同时执行
put,且计算出的桶(bucket)位置相同。 - 原理:假设线程 A 和线程 B 都准备插入数据。它们可能同时检测到某个桶为空,然后都新建节点并尝试放入。后执行完毕的线程会覆盖前一个线程的节点,导致前一个线程插入的数据丢失。
- 代码模拟:
// 线程A: map.put(key1, value1); // 假设key1哈希后定位到桶i,桶i初始为空 // 线程B: map.put(key2, value2); // 假设key2与key1哈希冲突,也定位到桶i // 在HashMap的putVal方法中,大致流程如下: if ((p = tab[i = (n - 1) & hash]) == null) // 步骤1:检查桶是否为空 tab[i] = newNode(hash, key, value, null); // 步骤2:如果为空,新建节点 // 并发时,可能发生: // 线程A执行步骤1,发现桶i为空。 // 线程B也执行步骤1,同样发现桶i为空(因为A还未执行步骤2)。 // 线程A执行步骤2,将节点N1放入桶i。 // 线程B执行步骤2,将节点N2放入桶i,覆盖了N1。结果:key1-value1丢失。
- 场景:两个线程同时执行
-
JDK 7 中的扩容死循环(经典问题)
- 场景:多个线程同时触发
HashMap扩容(resize)。 - 原理:JDK 7 的扩容采用 “头插法” 将旧链表节点转移到新数组。并发时,两个线程操作同一个链表进行反转,可能导致链表节点之间形成 环状链接(Circular Linked List)。
- 后果:后续任何对该链表的遍历操作(如
get、put),都会因为进入死循环而耗尽 CPU 资源。 - 机制简析:
// JDK 7 resize() 关键片段 (简化示意) void transfer(Entry[] newTable) { for (Entry<K,V> e : table) { // 遍历旧数组 while(null != e) { Entry<K,V> next = e.next; // 记录原链表下一个节点 <- 线程A、B都可能在此处记录自己的next int i = indexFor(e.hash, newTable.length); e.next = newTable[i]; // **头插法:将当前节点指向新桶的头节点** newTable[i] = e; // **将当前节点设为新桶的新头节点** e = next; // 移动到原链表的下一个节点 } } }并发环状链表形成过程(极度简化):假设原链表为
A -> B -> null,两个线程同时扩容。- 线程 A 执行完
e = next后被挂起,此时它持有:e = B,next = null(来自A视角)。 - 线程 B 成功完成整个扩容,新链表变为
B -> A -> null(因为头插法会反转链表)。 - 线程 A 恢复执行,它仍然持有
e = B,next = null(但其B.next已被线程 B 修改为指向A)。接下来:- 它将
B头插到新桶,并尝试将e指向next(即null)。 - 但
e = next之前,会先判断while(null != e),此时e是B,不为空,所以继续循环。 - 它再次计算
next = e.next,此时e是B,而B.next是A(线程B修改后的结果),所以next = A。 - 它又将
B头插一次(此时链表已乱),然后将e指向A。 - 下一轮循环,对
A操作,A.next指向了B(之前线程 A 自己头插时设置的),形成了A <-> B的环。从此,遍历此链表将永无止境。
- 它将
- 线程 A 执行完
- 场景:多个线程同时触发
-
JDK 8 及之后的改进与遗留问题
- 改进:JDK 8 将链表扩容时的 “头插法” 改为 “尾插法”,避免了链表反转,从而根治了由扩容引起的死循环问题。
- 遗留问题:但 数据不一致(丢失更新、覆盖)的问题依然存在。此外,JDK 8 引入了红黑树,并发下对树结构的错误修改同样会导致数据错乱或遍历异常。
对比分析与最佳实践
-
如何安全地在并发环境下使用 Map?
ConcurrentHashMap(首选):专为高并发设计。在 JDK 7 中采用 分段锁,JDK 8 及之后改为synchronized锁桶(链表头/树根节点) + CAS 实现更细粒度的同步,性能远超Hashtable。Collections.synchronizedMap(new HashMap(...)):返回一个所有方法都用synchronized修饰的包装类。它是全表锁,并发争抢激烈时性能较差,适用于并发度不高的场景。Hashtable(已过时):所有方法都用synchronized修饰,是全表锁,性能最差,不推荐使用。
-
最佳实践:
- 读写分离场景:如果并发场景是多读少写,且数据一致性要求不是实时强一致,可以考虑使用
ConcurrentHashMap。 - 高并发写场景:必须使用
ConcurrentHashMap。 - 绝对禁止:在任何可能涉及多个线程进行写操作(包括修改、删除)的场景下,直接使用
HashMap。
- 读写分离场景:如果并发场景是多读少写,且数据一致性要求不是实时强一致,可以考虑使用
常见误区
- “我用
HashMap只做并发读,所以是安全的。” —— 错误。如果HashMap在构建完成后(初始化、所有put操作)才被多线程读取,并且此后结构不再改变,那确实是安全的。但通常很难保证这一点,且一旦有线程尝试写操作,风险极大。 - “JDK 8 的
HashMap已经线程安全了。” —— 大错特错。JDK 8 只是修复了由特定扩容方式引起的死循环,其非同步的本质未变,数据竞争导致的各种不一致问题依然存在。
总结
HashMap 的线程不安全根源在于其未受保护的结构性修改操作,在并发写场景下必然导致数据错误,在 JDK 7 中甚至会引发致命死循环;解决之道是根据并发度选择 ConcurrentHashMap 或同步包装类,而 ConcurrentHashMap 是当今高并发场景下的绝对标准答案。