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。

javaconcurrencycollectionsconcurrenthashmapmultithreading