一、前言
在前一篇文章中,我们拆解了 HashMap 的 put 和 get 方法源码,明确了其在单线程下的高效存取逻辑。但 HashMap 的设计初衷是面向 单线程场景 ,在多线程并发操作时,会出现数据错乱、死循环等严重问题。
本文将深入分析 HashMap 线程不安全的具体表现及底层原因,同时对比多种线程安全解决方案的优劣,帮你在并发场景下选择合适的键值对存储容器。
二、HashMap 线程不安全的具体表现
在多线程环境下,对 HashMap 同时进行 put 、 remove 、 resize 等操作时,会触发多种线程安全问题,典型表现有以下三类:
1. 数据覆盖问题(JDK 1.7/1.8 均存在)
这是最常见的线程安全问题,多线程同时执行 put 操作时,可能导致同一个 Key 对应的 Value 被错误覆盖,核心原因是 putVal 方法中缺乏原子性的 "判重 + 插入" 逻辑 。
问题复现场景
线程 A 和线程 B 同时向 HashMap 中插入 Key 为 "user1" 的键值对:
-
线程 A 执行到 putVal 方法的 "判断桶首元素是否匹配" 步骤,发现 Key 不存在,准备插入新节点;
-
此时 CPU 切换到线程 B,线程 B 完成了 "user1" 的插入操作,更新了 HashMap 的元素数量;
-
CPU 切回线程 A,线程 A 未感知线程 B 的插入操作,继续执行插入逻辑,最终覆盖线程 B 写入的 Value。
源码层面的缺陷
putVal 方法中 "判断 Key 存在" 和 "插入新节点" 是两个独立步骤,无锁保护,伪代码如下:
java
// 步骤1:判断Key是否存在(非原子)
if (e == null) {
// 步骤2:插入新节点(非原子)
p.next = newNode(hash, key, value, null);
}
多线程并发时,步骤 1 和步骤 2 之间可能被其他线程打断,导致重复插入和数据覆盖。
2. 扩容死循环问题(仅 JDK 1.7 存在)
JDK 1.7 的 HashMap 在多线程扩容时,会因头插法 + 并发操作导致链表形成环形结构,后续调用 get 方法时会触发死循环,造成 CPU 100% 占用。
死循环产生的核心流程
JDK 1.7 扩容时采用头插法迁移链表元素(新节点插入链表头部),并发场景下会破坏链表的指针指向:
-
线程 A 执行 resize 方法,开始迁移某桶中的链表元素,已处理部分节点但未完成;
-
线程 B 抢占 CPU 执行 resize ,完成了该桶链表的迁移,且因头插法反转了链表顺序;
-
线程 A 恢复执行后,基于已失效的指针继续操作,最终导致链表节点的 next 指针相互引用,形成环形链表;
-
当后续线程调用 get 方法查询该桶元素时,会陷入无限循环遍历环形链表。
关键源码缺陷(JDK 1.7)
JDK 1.7 transfer 扩容方法中的头插法逻辑(简化版):
java
void transfer(Entry[] newTable) {
Entry[] src = table;
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null;
do {
Entry<K,V> next = e.next;
// 计算新索引
int i = indexFor(e.hash, newCapacity);
// 头插法:新节点指向当前桶的首元素
e.next = newTable[i];
// 桶首元素更新为当前节点
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
该逻辑中 e.next = newTable[i] 和 newTable[i] = e 的操作无锁保护,并发时会导致指针错乱。
3. 元素丢失问题(JDK 1.7/1.8 均存在)
多线程并发执行 put 操作且触发扩容时,可能导致部分元素丢失,核心原因是 扩容过程中元素迁移的非原子性 。
例如线程 A 在扩容迁移元素时,线程 B 插入的新元素可能因数组引用切换( table 指向新数组)而无法被感知,最终新元素既不在原数组也不在新数组,造成永久丢失。
**三、**HashMap 线程不安全的底层根源
从设计层面看,HashMap 线程不安全的核心原因是未做任何并发安全的同步设计,具体可归结为三点:
-
**成员变量无可见性保障:**table 、size 、threshold 等核心成员变量未用 volatile 修饰,多线程下无法保证变量修改的可见性,线程可能读取到过期的数组状态;
-
**核心操作无原子性保障:**put 、 resize 等操作均为非原子操作,可被其他线程打断,导致数据状态不一致;
-
**无并发修改的互斥锁:**未通过synchronized 或 Lock 实现操作的互斥,多线程可同时修改同一内存区域。
**四、**HashMap 线程安全问题的解决方案
针对 HashMap 的线程安全缺陷,Java 提供了多种替代方案,我们从 "兼容性""性能""适用场景" 三个维度对比分析:
1. 方案 1:Hashtable------ 简单粗暴的全表锁
Hashtable 是 JDK 最早提供的线程安全键值对容器,其核心实现是对所有方法添加synchronized关键字,实现全表级别的互斥锁。
核心源码示例
java
public synchronized V put(K key, V value) {
// 实现逻辑(略)
}
public synchronized V get(Object key) {
// 实现逻辑(略)
}
方案优劣
-
**优点:**实现简单,完全兼容 Map 接口,无需额外适配;
-
**缺点:**性能极低,全表锁导致所有操作串行执行,多线程下并发度为 1,无法利用多核 CPU 资源;
-
**适用场景:**并发量极低、对性能无要求的老旧系统。
2. 方案 2:Collections.synchronizedMap------ 包装器模式的同步适配
Collections.synchronizedMap 是 JDK 提供的工具方法,通过包装器模式为普通 Map 添加同步锁,本质是给 HashMap 套上一层 synchronized 锁。
核心使用示例
java
// 创建线程安全的Map
Map<String, Integer> syncMap = Collections.synchronizedMap(new HashMap<>());
// 并发操作
syncMap.put("user1", 1001);
Integer userId = syncMap.get("user1");
方案优劣
-
**优点:**可灵活包装任意 Map 实现类,适配性强;
-
**缺点:**锁粒度仍为全表级,与 Hashtable 一样存在并发性能瓶颈,且迭代时需手动加锁(否则可能触发快速失败);
-
**适用场景:**小规模并发场景,或需要快速适配已有 HashMap 代码的临时方案。
3. 方案 3:ConcurrentHashMap------ 高并发场景的最优解
ConcurrentHashMap 是 JUC 包提供的高性能线程安全 Map 实现,JDK 1.7 和 JDK 1.8 采用了不同的锁优化方案,核心目标是降低锁粒度,提升并发度。
(1)JDK 1.7 实现:分段锁(Segment)
JDK 1.7 的 ConcurrentHashMap 将数组分为多个Segment(分段),每个 Segment 独立加锁,不同分段的操作可并发执行,锁粒度为 "分段级",核心结构为 Segment[] 数组,每个 Segment 对应一个 HashMap。
(2)JDK 1.8 实现:CAS + 局部锁
JDK 1.8 摒弃了分段锁,采用CAS 原子操作 +synchronized局部锁的方案,进一步降低锁粒度:
-
对空桶的插入操作,通过 CAS 实现无锁并发;
-
对非空桶的操作,仅对桶首节点加 synchronized 锁(锁粒度为 "桶级");
-
红黑树结构的操作,通过 TreeNode 的锁机制保证线程安全。
方案优劣
-
**优点:**并发性能极高,支持多线程同时操作不同桶;支持null 值(Key 不可为 null,Value 可为 null);无快速失败机制,迭代时允许并发修改;
-
**缺点:**JDK 1.8 不支持null Key(HashMap 支持),部分场景需代码适配;
-
**适用场景:**高并发读写的生产环境,是 HashMap 线程安全替代方案的首选。
三种方案核心对比
| 方案 | 锁粒度 | 并发性能 | 支持 null Key | 适用场景 |
|---|---|---|---|---|
| Hashtable | 全表锁 | 极低 | 不支持 | 低并发老旧系统 |
| Collections.synchronizedMap | 全表锁 | 低 | 支持(同原 Map) | 临时适配的小规模并发场景 |
| ConcurrentHashMap(JDK1.8) | 桶级锁 + CAS | 极高 | 不支持 | 高并发生产环境 |
**五、**线程安全方案的选型建议
-
**单线程场景:**优先使用 HashMap,兼顾性能和灵活性;
-
**低并发场景(QPS<1000):**可使用Collections.synchronizedMap 快速适配,减少代码改造量;
-
**高并发场景(QPS≥1000):**必须使用 ConcurrentHashMap,通过细粒度锁保证性能;
-
**需兼容 null Key 场景:**若业务依赖 null Key,可在 ConcurrentHashMap 外层封装适配逻辑,或降级使用synchronizedMap (需评估性能)。
六、典型面试题:HashMap与ConcurrentHashMap的核心区别
-
**线程安全:**HashMap 线程不安全,ConcurrentHashMap 线程安全;
-
**锁机制:**HashMap 无锁,ConcurrentHashMap JDK1.7 用分段锁、JDK1.8 用 CAS + 局部锁;
-
**null 值支持:**HashMap 支持 null Key 和 null Value,ConcurrentHashMap 不支持 null Key;
-
**迭代机制:**HashMap 迭代时触发快速失败(ConcurrentModificationException ),ConcurrentHashMap 为弱一致性迭代,允许并发修改。
七、结语
本文详细分析了 HashMap 线程不安全的三类典型问题及底层根源,并对比了三种主流的线程安全解决方案。在实际开发中,需根据并发量和业务特性选择合适的容器,高并发场景下优先使用 ConcurrentHashMap。