ConcurrentHashMap 与 HashMap 和 Collections.synchronizedMap 的区别
1. 总览
当多个线程同时读写一个 Map 时,你有三个常用选择:HashMap、Collections.synchronizedMap 和 ConcurrentHashMap。这三种选择在多线程安全性、锁定方式和遍历行为上有明显的区别。
本文通过可运行的示例来解释每种类型。读完后,你将知道应该选择哪种类型、如何编写原子操作,以及如何避免常见的错误。
2. 准备
这些示例使用 Java 21。测试部分需要 JUnit 5 和 AssertJ:
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
</dependencies>
如果你的项目是 Spring Boot 3.x,spring-boot-starter-test 已经包含了这两个库。
3. HashMap:快速但不是线程安全的
HashMap 没有任何同步机制。在单线程环境中,它是最快的选择。当多个线程同时写入时,数据可能会丢失。
3.1. 并发计数时丢失更新
下面的例子用 8 个线程计算一个客户的订单数:
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class HashMapRaceDemo {
public static void main(String[] args) {
Map<String, Integer> orderCount = new HashMap<>();
try (ExecutorService executor = Executors.newFixedThreadPool(8)) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> orderCount.merge("customer-1", 1, Integer::sum));
}
} // close() 等待所有任务完成
System.out.println("订单数: " + orderCount.get("customer-1"));
}
}
我们提交 10,000 个任务,每个任务使用 merge 添加 1。从 Java 19 起,ExecutorService 是 AutoCloseable 的,所以 try-with-resources 会等待所有任务完成。结果在每次运行时都会变化:
订单数: 9817
原因是 HashMap 的 merge 包含多个步骤:读取旧值、计算新值、然后写回。两个线程可能同时读取 100、都写入 101,导致一次加法丢失。更糟糕的是,如果两个线程同时触发 resize,内部结构可能会被破坏。
3.2. 遍历时的 ConcurrentModificationException
HashMap 的迭代器是 fail-fast 的。如果在遍历时 map 被修改(不通过迭代器本身),下次调用 next() 可能会抛出 ConcurrentModificationException。这个错误甚至在单线程中也会发生,例如在 for-each 循环中调用 map.remove()。
注意: fail-fast 机制只是“尽力而为”(best-effort)。没有异常不代表你的代码是正确的。
4. Collections.synchronizedMap:一个锁保护所有操作
Collections.synchronizedMap 包装任何 Map。包装器的所有方法都在 synchronized (mutex) 中运行,默认情况下 mutex 就是包装器对象本身。
Map<String, Integer> orderCount = Collections.synchronizedMap(new HashMap<>());
如果将第 3.1 节示例中的初始化行替换为上面这一行,结果将始终正确:
订单数: 10000
从 Java 8 开始,这个包装器重写了默认方法如 merge、compute 和 putIfAbsent,因此每个单独的调用都是原子的。
4.1. 组合操作仍不是原子的
每个方法都是单独同步的。当你将两个调用组合在一起时,这个序列不再是原子的:
Map<String, Customer> customers = Collections.synchronizedMap(new HashMap<>());
// 错误:两个线程可能同时看到"不存在"并都执行 put
if (!customers.containsKey(id)) {
customers.put(id, new Customer(id));
}
// 正确:在整个检查-然后-动作中保持锁
synchronized (customers) {
if (!customers.containsKey(id)) {
customers.put(id, new Customer(id));
}
}
更简单的方式是使用 customers.putIfAbsent(id, new Customer(id)),因为这是一个单一的调用。
4.2. 遍历时必须手动加锁
包装器的迭代器没有同步。Javadoc 要求你在 map 本身上加锁:
synchronized (customers) {
for (Customer c : customers.values()) {
System.out.println(c);
}
}
如果忘记 synchronized 块,你仍然可能遇到 ConcurrentModificationException。一旦加锁,其他任何想读写的线程都必须等待循环完成。
4.3. 性能瓶颈
只有一个锁保护整个 map。两个线程读取不同的键仍然必须排队。随着线程数增加,锁竞争(contention)增加,吞吐量明显下降。
5. ConcurrentHashMap:为多线程而设计
ConcurrentHashMap(在 java.util.concurrent 包中)是专为并发访问而设计的。从 Java 8 开始,它的实现如下:
- 读操作不加锁。
get使用 volatile 读,几乎不会被阻塞。 - 按桶加锁写入。 当桶为空时,它使用 CAS(比较并交换)进行插入。当桶已有元素时,它只在该桶的头节点上进行
synchronized。写入不同桶的线程可以并行运行。 - 合作式 resize。 多个线程参与将数据转移到新表,而不是锁定整个 map。
5.1. 原子的组合操作
方法如 putIfAbsent、computeIfAbsent、computeIfPresent、compute 和 merge 都是按每个键原子执行的:
import java.util.concurrent.ConcurrentHashMap;
public class InventoryService {
private final ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>();
public void addStock(String product, int quantity) {
stock.merge(product, quantity, Integer::sum);
}
public boolean reserve(String product) {
Integer result = stock.computeIfPresent(product,
(key, current) -> current > 0 ? current - 1 : current);
return result != null && result >= 0;
}
public int available(String product) {
return stock.getOrDefault(product, 0);
}
}
addStock 使用 merge 累加数量。如果键不存在,merge 直接使用 quantity 作为值。reserve 在 computeIfPresent 中减少库存 1。整个读取、检查和写入操作对该键来说是原子的,所以没有两个线程能同时占用最后一个单位。
为了在非常高频率的计数中降低竞争,可以与 LongAdder 结合使用:
ConcurrentHashMap<String, LongAdder> hits = new ConcurrentHashMap<>();
hits.computeIfAbsent("/orders", k -> new LongAdder()).increment();
5.2. 弱一致的迭代器
ConcurrentHashMap 的迭代器永远不会抛出 ConcurrentModificationException。你可以在遍历时修改 map:
ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>(
Map.of("apple", 10, "banana", 0, "cherry", 3));
for (String product : stock.keySet()) {
if (stock.get(product) == 0) {
stock.remove(product);
}
}
System.out.println(stock);
{apple=10, cherry=3}
迭代器反映的是 map 从创建之时起的某个时间点的状态。迭代器可能看到或看不到之后发生的更改。每个元素最多被返回一次。这是刻意的权衡:你既不会被阻塞也不会遇到异常,但也不能获得精确的快照。
5.3. 不接受 null
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("apple", null); // NullPointerException
map.put(null, 1); // NullPointerException
对于 HashMap,get(key) 返回 null 有两种含义:键不存在,或键映射到 null。在单线程中,你可以额外调用 containsKey 来区分。在并发环境中,这两个调用之间 map 可能已经改变。因此 ConcurrentHashMap 禁止 null,使得 get 返回 null 只有一个含义:键不存在。
5.4. size() 只是一个估计值
当其他线程正在写入时,size() 和 isEmpty() 只反映一个近似的状态。如果 map 可能超过 Integer.MAX_VALUE 个元素,使用 mappingCount(),因为该方法返回 long。不要写依赖于 size() 必须在某个时刻精确的业务逻辑。
6. 测试并发行为
下面的测试确认 ConcurrentHashMap 在并发负载下计数正确,并拒绝 null 值:
import org.junit.jupiter.api.Test;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ConcurrentMapTest {
@Test
void givenManyThreads_whenMerge_thenNoUpdateIsLost() {
Map<String, Integer> orderCount = new ConcurrentHashMap<>();
try (ExecutorService executor = Executors.newFixedThreadPool(8)) {
IntStream.range(0, 10_000).forEach(i ->
executor.submit(() -> orderCount.merge("customer-1", 1, Integer::sum)));
}
assertThat(orderCount).containsEntry("customer-1", 10_000);
}
@Test
void givenNullValue_whenPut_thenThrowNpe() {
Map<String, Integer> stock = new ConcurrentHashMap<>();
assertThatThrownBy(() -> stock.put("apple", null))
.isInstanceOf(NullPointerException.class);
}
}
第一个测试总是通过的。如果改为 new HashMap<>(),这个测试在大多数运行时会失败,实际值小于 10,000。
注意: 多线程测试通过不能证明代码正确。竞态条件可能只在真实负载下才显现。应该依赖于 API 的保证,如
merge的原子性,而不是仅依赖于测试通过。
7. 使用 ConcurrentHashMap 时的常见错误
先 get 后 put。 使用 ConcurrentHashMap 不会自动使你的代码线程安全:
// 错误:get 和 put 之间存在竞态条件
Integer current = stock.get("apple");
stock.put("apple", current - 1);
// 正确
stock.computeIfPresent("apple", (k, v) -> v - 1);
在 compute* 中的函数运行时间过长或有副作用。 映射函数在桶被锁定时运行。如果你在 computeIfAbsent 中调用 HTTP 或查询数据库,其他写入该桶的线程都会被阻塞。保持函数简短且无副作用。
在 compute* 中修改 map 本身。 在映射函数中调用 map.put 或 map.computeIfAbsent 可能会抛出 IllegalStateException: Recursive update,或导致死锁。典型的例子是使用 computeIfAbsent 实现递归 memoize Fibonacci。
需要在多个键上进行原子操作。 ConcurrentHashMap 只保证单个键的原子性。例如从仓库 A 转移库存到仓库 B 是对两个键的操作。它需要单独的锁,或需要重新设计数据结构以合并为单个键。
需要顺序。 ConcurrentHashMap 不保持插入顺序。如果需要有序的键,使用 ConcurrentSkipListMap。
8. 综合比较
标准 | HashMap | synchronizedMap | ConcurrentHashMap
--------------------|------------------|--------------------------|--------------------------
线程安全 | 否 | 是 | 是
锁机制 | 无 | 整个 map 一个锁 | CAS + 按桶加锁
并发读 | 不安全 | 顺序(被阻塞) | 无锁
组合操作 | 不是原子的 | 需要外部 synchronized | compute/merge 原子
迭代器 | Fail-fast | Fail-fast,需手动加锁 | 弱一致
Key/value null | 允许 | 由被包装的 map 决定 | 不允许
多线程性能 | — | 竞争高时较差 | 优秀
那么应该选择哪种呢?
HashMap:map 只被单个线程访问,或创建后安全发布并仅读取。对于只读情况,Map.copyOf更优。Collections.synchronizedMap:你需要包装现有的Map如LinkedHashMap或TreeMap来保持顺序或特殊行为、并发负载低、或需要锁定整个 map 来执行长的操作序列。ConcurrentHashMap:用于缓存、计数器、注册表以及多个线程共享的任何 map 的默认选择。
9. 结论
HashMap 快速但多个线程同时写入时会丢失数据。Collections.synchronizedMap 因一个唯一的锁而安全。代价是它创建了瓶颈,你仍需在遍历时或组合多个操作时手动加锁。ConcurrentHashMap 允许无锁读和按桶并行写。它提供原子操作如 merge 和 computeIfAbsent,其迭代器在 map 改变时不会抛出异常。
记住两个原则。首先,线程安全的集合不会自动使你的操作序列原子化,所以使用 compute* 和 merge 替代 get 后 put。其次,保持映射函数简短且不在其内部修改 map。