作为 Java 开发者,HashMap 简直就是家常便饭。但在多线程环境下,各种诡异的问题就会找上门来。有一次我们的交易系统 CPU 突然飙到 100%,服务直接挂了。排查下来一看,一堆线程全卡在 HashMap.get()上死循环。这次教训让我彻底研究了 HashMap 的线程安全性问题,总结出这篇实战经验,希望能帮你避坑。
HashMap 为什么不是线程安全的?
先来看 HashMap 在 Java 8 中的内部结构:一个 Node<K,V>[]数组,哈希冲突时通过链表或红黑树解决。
graph TD
A[HashMap] --> B[Node数组]
B --> C1[bucket 0]
B --> C2[bucket 1]
B --> C3[bucket ...]
B --> C4[bucket n-1]
C2 --> D1[Node]
D1 --> D2[Node]
D2 --> D3[Node ...]
往 HashMap 中放一个键值对时,大致执行这些步骤:
- 计算 key 的哈希值
- 根据哈希值定位到数组位置
- 如果该位置为空,直接放入新 Node
- 如果该位置已有元素,则遍历链表/树,找到相同 key 就更新值,否则添加新 Node
- 检查是否需要扩容
单线程下一切正常,但多线程环境下这个过程不是原子的,会导致各种奇葩问题:
1. 数据丢失问题
看个例子就明白了,多线程并发修改 HashMap 时到底会发生什么:
public class HashMapLostUpdateDemo {
public static void main(String[] args) throws Exception {
final Map<String, String> map = new HashMap<>();
// 两个线程更新相同位置的数据
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
map.put("key" + i % 8, "value from t1-" + i);
try { Thread.sleep(1); } catch (Exception e) {}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
map.put("key" + i % 8, "value from t2-" + i);
try { Thread.sleep(1); } catch (Exception e) {}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("预期元素个数: 8, 实际元素个数: " + map.size());
// 检查是否有元素丢失
for (int i = 0; i < 8; i++) {
if (map.get("key" + i) == null) {
System.out.println("key" + i + " 丢失了!");
}
}
}
}
多跑几次这段代码,你会发现实际元素个数可能小于 8,说明有元素丢失了。问题不仅仅是值覆盖,更要命的是并发修改导致的结构破坏。当多个线程同时修改 HashMap 时,尤其是触发扩容的时候,链表结构可能会变得混乱,导致某些节点"凭空消失"。
2. 死循环
在 Java 7 中,HashMap 扩容时使用头插法重建链表。多线程环境下,这可能导致链表形成环形结构:
这个问题在生产环境特别难查。我们的事故就是这样,系统运行好好的,突然 CPU 飙升到 100%。通过jstack -l <pid>发现大量线程卡在 HashMap.get()方法上。分析堆转储(用jmap -dump:live,format=b,file=heap.bin <pid>获取)后发现链表中存在环形引用。
Java 8 改为尾插法后,彻底解决了环形链表死循环问题,但并发修改仍可能导致元素丢失。
3. 并发修改异常
最常见的问题是 ConcurrentModificationException,当一个线程遍历 HashMap 时,另一个线程修改了结构:
public class ConcurrentModificationDemo {
public static void main(String[] args) {
Map<String, String> map = new HashMap<>();
map.put("a", "1");
map.put("b", "2");
Thread t1 = new Thread(() -> {
try {
for (Map.Entry<String, String> entry : map.entrySet()) {
Thread.sleep(100); // 模拟慢操作
System.out.println(entry.getKey() + ":" + entry.getValue());
}
} catch (Exception e) {
System.out.println("线程t1异常: " + e.getClass().getName());
}
});
Thread t2 = new Thread(() -> {
try {
Thread.sleep(50); // 确保在遍历中修改
map.put("c", "3");
System.out.println("线程t2添加了新元素c=3");
} catch (Exception e) {
e.printStackTrace();
}
});
t1.start();
t2.start();
}
}
这段代码几乎 100%会抛出 ConcurrentModificationException。原因是 HashMap 内部维护了一个 modCount 计数器记录结构修改次数。创建迭代器时会保存当前的 modCount,遍历过程中检查到 modCount 变化就抛异常。
这种设计叫"fail-fast"(快速失败)——发现并发修改立即报错,而不是继续处理可能已经不一致的数据。就像你往前走路时,突然发现前面有坑,立马停下来比冒然踩下去要好得多。
HashMap 线程安全问题的本质
归根结底,HashMap 线程不安全的原因是:
- 非原子操作:插入、删除、扩容等操作包含多个步骤,没有事务保护
- 可见性问题:HashMap 的字段没有 volatile 修饰,线程间修改不可见
- 结构共享:多线程同时修改共享的数组和链表,导致结构混乱
打个比方,HashMap 就像一个没有服务员的自助餐厅,每个人都可以随时往任何位置放食物或拿走食物。没有协调的情况下,必然会出现多人同时操作同一个位置,导致食物被覆盖、丢失,或者餐桌结构被破坏。
Java 中的线程安全 Map 实现
好在 Java 提供了几种线程安全的 Map 实现,我们先看看这些现成的解决方案:
1. Collections.synchronizedMap
最简单直接的方法,给 HashMap 套上 synchronized:
Map<String, String> syncMap = Collections.synchronizedMap(new HashMap<>());
实现原理就是所有操作都要先获取锁:
public V get(Object key) {
synchronized (mutex) {
return m.get(key);
}
}
public V put(K key, V value) {
synchronized (mutex) {
return m.put(key, value);
}
}
这种方案有个容易忽略的坑:遍历时也需要手动同步,否则还是会抛异常:
Map<String, String> syncMap = Collections.synchronizedMap(new HashMap<>());
// 必须获取锁后才能安全遍历
synchronized (syncMap) { // 获取的是mutex对象锁
for (Map.Entry<String, String> entry : syncMap.entrySet()) {
// 处理entry
}
}
2. ConcurrentHashMap
这是专为并发设计的高性能 Map,在 Java 8 中的实现非常精妙:
graph TD
A[ConcurrentHashMap] --> B[volatile Node数组]
B --> C1[bucket 0]
B --> C2[bucket 1]
B --> C3[bucket ...]
B --> C4[bucket n-1]
C2 -->|"synchronized(首节点)"| D1[Node]
D1 --> D2[Node]
D2 --> D3[Node ...]
style C2 fill:#f9f,stroke:#333
style D1 fill:#ff9,stroke:#333
ConcurrentHashMap 的并发控制有几个关键点:
- 细粒度锁:只锁定当前操作的链表首节点,不同的哈希桶可以并发操作
- 读操作完全无锁:get 方法无需加锁,通过 volatile 保证可见性
- volatile 保证可见性:Node.val 和 Node.next 是 volatile 的,保证读操作能看到最新值(Node.key 不需要 volatile 因为 key 一旦设置就不会变)
- CAS + synchronized 结合:先尝试 CAS 无锁更新,失败再用 synchronized
- 分段迁移扩容:通过 sizeCtl 和 transferIndex 等状态变量,多线程协作完成扩容
例如 put 操作的简化逻辑:
1. 如果桶为空,用CAS设置新节点(无锁)
2. 如果CAS失败或桶不为空,对首节点加synchronized锁
3. 在锁内完成链表/红黑树的更新
ConcurrentHashMap 的扩容是个精妙的设计,通过特殊的 ForwardingNode 标记节点,允许多个线程同时参与扩容过程:
// 简化的扩容状态控制
private transient volatile int sizeCtl;
// sizeCtl < 0 表示有线程在进行初始化或扩容
// sizeCtl = -1 表示正在初始化
// sizeCtl = -N 表示有N-1个线程在进行扩容
特别之处在于,ConcurrentHashMap 的迭代器是"fail-safe"的,不会抛 ConcurrentModificationException。它基于弱一致性设计——遍历过程中可以继续修改 Map,但可能看不到最新修改。就像你拍了一张照片,照片里的东西不会变,但真实世界已经变了。
3. Hashtable
最古老的线程安全 Map,但现在基本没人用了:
Map<String, String> table = new Hashtable<>();
它给所有方法都加上 synchronized,导致性能奇差。就像一家餐厅只有一个服务员,每次只能服务一位客人,其他人都要排队。
HashMap 和 ConcurrentHashMap 的版本演进
HashMap 和 ConcurrentHashMap 在不同 Java 版本中的实现差异很大:
graph TD
A[Java 7 HashMap] --> B["数组 + 链表"]
B --> B1["头插法扩容(可能导致环形链表)"]
C[Java 8+ HashMap] --> D["数组 + 链表/红黑树"]
D --> D1["链表长度>8转红黑树尾插法扩容(解决环形链表)"]
graph TD
A[Java 7 ConcurrentHashMap] --> B["Segment数组(16个分段锁)继承ReentrantLock"]
B --> B1["每个Segment一把锁分离读写锁"]
C[Java 8+ ConcurrentHashMap] --> D["去除Segment数组 + 链表/红黑树"]
D --> D1["CAS + synchronized桶级别的锁"]
D --> D2["多线程协作扩容sizeCtl状态控制"]
Java 8 后两者的内部结构更加相似,最大区别是 ConcurrentHashMap 增加了并发控制机制。
手写一个线程安全 HashMap(基于锁)
如果想自己实现线程安全的 HashMap,最直接的方式是使用读写锁:
public class ThreadSafeHashMap<K, V> {
private final Map<K, V> map = new HashMap<>();
private final ReadWriteLock lock = new ReentrantReadWriteLock();
private final Lock readLock = lock.readLock();
private final Lock writeLock = lock.writeLock();
public V get(K key) {
readLock.lock();
try {
return map.get(key);
} finally {
readLock.unlock();
}
}
public V put(K key, V value) {
writeLock.lock();
try {
return map.put(key, value);
} finally {
writeLock.unlock();
}
}
public V remove(K key) {
writeLock.lock();
try {
return map.remove(key);
} finally {
writeLock.unlock();
}
}
public boolean containsKey(K key) {
readLock.lock();
try {
return map.containsKey(key);
} finally {
readLock.unlock();
}
}
public Set<Map.Entry<K, V>> entrySet() {
readLock.lock();
try {
// 返回不可修改的副本,保证不会被外部修改
// 注意:这只提供获取时的一致性视图,不保证后续不变
return Collections.unmodifiableSet(new HashSet<>(map.entrySet()));
} finally {
readLock.unlock();
}
}
// 原子性组合操作示例
public V putIfAbsent(K key, V value) {
writeLock.lock();
try {
V old = map.get(key);
if (old == null) {
map.put(key, value);
return null;
}
return old;
} finally {
writeLock.unlock();
}
}
}
读写锁允许多线程同时读取,只要没有写操作。这就像图书馆:多人可以同时阅读,但有人要整理书架时必须暂停阅读。
graph TD
A[请求读锁] --> B{有写锁?}
B -->|是| C[等待]
B -->|否| D[获取读锁]
E[请求写锁] --> F{有读锁或写锁?}
F -->|是| G[等待]
F -->|否| H[获取写锁]
这种实现提供了强一致性视图——在锁保护期间,你看到的数据就是最新的数据,没有中间状态。而 ConcurrentHashMap 只提供最终一致性,不保证跨方法调用的原子性。
读写锁实现有个重要优势:可以实现原子性的组合操作。比如"先检查 key 是否存在,不存在则添加"这种复合操作,在 ConcurrentHashMap 中需要用 computeIfAbsent,而读写锁实现可以自由组合任意操作。
但这种方案也有局限性:
- 写操作完全互斥,不同 key 的写操作也会阻塞
- 锁的粒度较大,并发性能有限
- 读写锁本身有一定开销
无锁/低锁并发 HashMap 实现
如果追求更高性能,可以借鉴 ConcurrentHashMap 的思路,使用 CAS 和细粒度锁:
public class LockFreeHashMap<K, V> {
private static final AtomicReferenceFieldUpdater<LockFreeHashMap, Node[]> UPDATER =
AtomicReferenceFieldUpdater.newUpdater(LockFreeHashMap.class, Node[].class, "table");
private static final int DEFAULT_CAPACITY = 16;
private static final float LOAD_FACTOR = 0.75f;
// volatile保证可见性
private volatile Node<K, V>[] table;
private final AtomicInteger size = new AtomicInteger(0);
private int threshold;
@SuppressWarnings("unchecked")
public LockFreeHashMap(int initialCapacity) {
table = (Node<K, V>[]) new Node[initialCapacity];
threshold = (int) (initialCapacity * LOAD_FACTOR);
}
public LockFreeHashMap() {
this(DEFAULT_CAPACITY);
}
private static final class Node<K, V> {
final int hash;
final K key; // key无需volatile因一旦赋值不再修改
volatile V value; // value需要volatile保证可见性
volatile Node<K, V> next; // next指针需要volatile避免指令重排导致的可见性问题
Node(int hash, K key, V value, Node<K, V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
// 读操作无锁
public V get(K key) {
int hash = hash(key);
Node<K, V>[] tab = table;
int n = tab.length;
int index = (n - 1) & hash;
for (Node<K, V> e = tab[index]; e != null; e = e.next) {
if (e.hash == hash && (e.key == key || (key != null && key.equals(e.key)))) {
return e.value;
}
}
return null;
}
// 写操作使用CAS
public V put(K key, V value) {
int hash = hash(key);
Node<K, V>[] tab = table;
int n = tab.length;
int index = (n - 1) & hash;
// 如果桶为空,使用CAS操作创建新节点
if (tab[index] == null) {
Node<K, V> newNode = new Node<>(hash, key, value, null);
if (casTabAt(index, null, newNode)) {
size.incrementAndGet();
if (size.get() > threshold) {
resize(); // 需要扩容
}
return null;
}
}
// 如果桶不为空,遍历链表
Node<K, V> first = tab[index];
for (Node<K, V> e = first; e != null; e = e.next) {
if (e.hash == hash && (e.key == key || (key != null && key.equals(e.key)))) {
// 找到相同的key,更新value
V oldValue = e.value;
e.value = value;
return oldValue;
}
}
// 没有找到相同的key,添加新节点到链表头部
Node<K, V> newNode = new Node<>(hash, key, value, first);
if (casTabAt(index, first, newNode)) {
size.incrementAndGet();
if (size.get() > threshold) {
resize(); // 需要扩容
}
return null;
}
// CAS失败,重试
return put(key, value);
}
private int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
@SuppressWarnings("unchecked")
private boolean casTabAt(int i, Node<K, V> expect, Node<K, V> update) {
return UPDATER.compareAndSet(this, table,
replaceElement(table, i, expect, update));
}
@SuppressWarnings("unchecked")
private Node<K, V>[] replaceElement(Node<K, V>[] arr, int i,
Node<K, V> expect, Node<K, V> update) {
Node<K, V>[] copy = Arrays.copyOf(arr, arr.length);
if (copy[i] == expect) {
copy[i] = update;
}
return copy;
}
// 注意:这只是简化的伪代码,真实无锁扩容极其复杂
// 真实无锁扩容需要解决:
// 1. 多线程协作迁移元素(通过CAS分配任务)
// 2. 使用特殊标记节点(如ForwardingNode)标记已迁移的桶
// 3. 处理并发读写与扩容的竞争(读操作可能需要帮助扩容)
// 4. 解决ABA问题(通过版本号或标记位)
private void resize() {
/*
无锁扩容的核心思路:
1. 用CAS设置状态标记(类似ConcurrentHashMap的sizeCtl)
2. 多线程协作分段迁移元素,每个线程负责一段区间
3. 用特殊的ForwardingNode标记已迁移的桶
4. 读操作遇到ForwardingNode时转向新表
5. 所有桶迁移完成后切换到新table引用
*/
// 此处省略具体实现,可参考ConcurrentHashMap源码中的transfer方法
// 真实实现约200行代码,涉及复杂的并发控制
}
}
这里的核心是 CAS(Compare-And-Swap)操作,它是 CPU 硬件支持的原子指令,无需加锁就能保证操作的原子性。CAS 逻辑类似:
当前值 = 读取内存位置X
如果(当前值 == 预期值)
将新值写入X
返回成功
否则
返回失败
形象地说,CAS 就像你和售货员交易:
- 你看到一件标价 100 元的商品
- 你拿出 100 元递给售货员
- 如果价格还是 100 元,交易成功;如果价格已经变成了 120 元(别人修改了),交易失败
sequenceDiagram
participant 线程
participant 内存
线程->>内存: 读取当前值A
线程->>线程: 计算新值B
线程->>内存: CAS(期望值A,新值B)
alt 当前值仍为A
内存-->>线程: 更新成功
else 当前值已变为C
内存-->>线程: 更新失败,需重试
end
无锁设计在低竞争场景下性能出色,但高竞争时可能因 CAS 自旋导致性能下降。这就是为什么 ConcurrentHashMap 采用 CAS 和 synchronized 结合的方式,而不是纯 CAS 实现。
另一种完全不同的思路是使用不可变数据结构,每次修改都创建新 Map,如 Guava 的 ImmutableMap:
// 不可变Map,线程安全但修改开销大
Map<String, String> map1 = ImmutableMap.of("key1", "value1", "key2", "value2");
// 需要修改时创建新Map而非修改原Map
Map<String, String> map2 = ImmutableMap.<String, String>builder()
.putAll(map1)
.put("key3", "value3")
.build();
这种方式像 Git 提交,每次修改都创建一个新版本,而不修改原版本。它在读多写极少的场景下有奇效,因为读操作完全无同步开销。
各种实现的性能对比与适用场景
不同线程安全 Map 在不同场景下的性能表现:
graph LR
A[性能比较] --> B[读多写少]
A --> C[读写均衡]
A --> D[写多读少]
A --> E[竞争程度]
B --> B1["ConcurrentHashMap ≈ 不可变Map > 读写锁 > synchronizedMap"]
C --> C1["低竞争: 无锁实现 ≈ ConcurrentHashMap > 读写锁 > synchronizedMap"]
C --> C2["高竞争: ConcurrentHashMap > 读写锁 > 无锁实现 > synchronizedMap"]
D --> D1["低竞争: 无锁实现 > ConcurrentHashMap > 读写锁 > synchronizedMap"]
D --> D2["高竞争: ConcurrentHashMap > 读写锁 > 无锁实现 > synchronizedMap"]
E --> E1["低竞争(线程操作不同数据): 无锁/CAS方案更优"]
E --> E2["高竞争(线程操作相同数据): 细粒度锁方案更稳定"]
影响性能的主要因素:
- 竞争程度:多线程操作同一数据时,CAS 会频繁自旋重试,性能下降
- 数据规模:数据量大时,细粒度锁优势明显
- 读写比例:读多写少场景下,读写锁和 ConcurrentHashMap 优势大
- 一致性要求:强一致性通常需要更多同步,性能更低
比如说:
- 键值随机分布的场景(线程很少操作相同的 key),无锁实现和 ConcurrentHashMap 性能接近
- 热点数据集中的场景(多线程频繁操作少量 key),ConcurrentHashMap 的细粒度锁更稳定
- 读操作为主的场景,读写锁和 ConcurrentHashMap 都表现良好
- 极端写多的场景,可能需要分片或其他特殊设计
实战案例:生产环境中的 HashMap 并发问题
案例 1:商品缓存系统的并发崩溃
我们的商品服务系统用 HashMap 缓存商品信息,初版代码:
public class ProductCacheService {
// 错误示范:多线程环境下有安全隐患
private final Map<String, Product> productCache = new HashMap<>();
public Product getProduct(String id) {
// 双重检查锁也无法解决HashMap自身的线程安全问题
Product product = productCache.get(id);
if (product == null) {
synchronized (this) {
product = productCache.get(id);
if (product == null) {
product = loadProductFromDb(id);
productCache.put(id, product); // 线程不安全的操作
}
}
}
return product;
}
}
上线后系统时不时出现诡异的空指针异常。开始以为是业务逻辑问题,但问题复现极不稳定。某天系统负载高峰期,突然大量请求响应缓慢,CPU 飙升到 100%。
排查过程:
- 使用
jstack -l <pid>抓取线程堆栈,发现大量线程卡在 HashMap.get()方法上 - 用
jmap -dump:live,format=b,file=heap.bin <pid>获取堆转储文件 - 通过 VisualVM 分析转储文件,发现 HashMap 内部链表存在环形引用
- 问题确诊:并发修改 HashMap 导致的环形链表
解决方案简单粗暴:
public class ProductCacheService {
// 修复:使用ConcurrentHashMap
private final Map<String, Product> productCache = new ConcurrentHashMap<>();
public Product getProduct(String id) {
return productCache.computeIfAbsent(id, this::loadProductFromDb);
}
private Product loadProductFromDb(String id) {
// 从数据库加载商品
return dbService.getProduct(id);
}
}
改用 ConcurrentHashMap 后:
- 代码更简洁,直接用 computeIfAbsent 原子操作
- 彻底解决了线程安全问题
- 性能反而因为减少了锁竞争而提升了
案例 2:统计分析系统的一致性保证
我们的数据统计系统需要生成完整的时间点快照报表,使用 ConcurrentHashMap 后发现问题:报表数据总是不一致——有时漏数据,有时多数据。
问题复现过程:
- 使用 JMeter 模拟多线程并发读(模拟报表生成)和定时写(模拟数据更新)
- 通过日志记录操作时间戳,发现报表遍历期间 HashMap 结构被修改
- 进一步分析发现,ConcurrentHashMap 的迭代器是"弱一致性"的,允许遍历期间 Map 被修改
问题根源:ConcurrentHashMap 提供的是最终一致性(允许遍历时看到部分修改),而报表需要在某个时间点的完整一致快照。
解决方案:使用读写锁实现强一致性视图:
public class StatisticsAnalyzer {
private final Map<String, AnalysisData> dataMap = new HashMap<>();
private final ReadWriteLock lock = new ReentrantReadWriteLock();
public List<AnalysisData> generateReport() {
lock.readLock().lock();
try {
// 在一个原子操作中获取完整快照
return new ArrayList<>(dataMap.values());
} finally {
lock.readLock().unlock();
}
}
public void updateData(String key, AnalysisData data) {
lock.writeLock().lock();
try {
dataMap.put(key, data);
} finally {
writeLock.unlock();
}
}
// 批量更新,保证原子性
public void batchUpdate(Map<String, AnalysisData> updates) {
lock.writeLock().lock();
try {
dataMap.putAll(updates);
} finally {
writeLock.unlock();
}
}
}
这个方案确保了报表数据的强一致性,满足了业务需求。虽然并发性能不如 ConcurrentHashMap,但在我们的场景下读多写少,性能完全够用。
总结
HashMap 的线程安全性问题及各种解决方案:
| 实现方式 | 优点 | 缺点 | 适用场景 | 一致性模型 |
|---|---|---|---|---|
| HashMap | 单线程性能最佳 | 非线程安全,可能丢数据、死循环 | 仅适用于单线程 | 无线程安全保证 |
| Collections.synchronizedMap | 实现简单,保证线程安全 | 全局锁,性能差 | 并发量低的场景 | 强一致性(需手动同步迭代器) |
| ConcurrentHashMap | 细粒度锁,并发性能好 | 实现复杂,一致性较弱 | 大多数并发场景 | 最终一致性(遍历可能看到部分修改) |
| Hashtable | 简单直接 | 性能最差,已过时 | 不推荐使用 | 强一致性 |
| 读写锁实现 | 读操作并发,写操作串行 | 全局写锁,写并发受限 | 读多写少、需强一致性 | 强一致性 |
| 无锁/低锁实现 | 低竞争下并发性能好 | 实现复杂,高竞争下性能下降 | 低竞争高并发场景 | 通常是最终一致性 |
| 不可变 Map | 绝对线程安全,无需同步 | 修改成本高,创建新对象 | 读多写极少场景 | 绝对一致(不可变) |
实际开发中的选择建议:
- 默认首选 ConcurrentHashMap,适合绝大多数并发场景
- 需要强一致性视图时,考虑读写锁实现
- 读多写极少场景,可考虑不可变集合
- 除非你是并发编程专家,否则不要自己实现无锁数据结构