1. 引言

本文将深入探讨如何使用 ConcurrentHashMap 类实现线程安全的哈希表读写操作。作为 Java 并发编程中的核心工具,它提供了高效的并发访问能力,特别适合高吞吐量场景。

2. 核心概念

ConcurrentHashMapConcurrentMap 接口的主要实现类,属于 Java 提供的线程安全集合之一。它底层基于普通哈希表实现,与 Hashtable 类似但存在关键差异,我们将在后续章节详细分析。

2.1 关键方法

ConcurrentHashMap API 提供了多个实用方法,本文重点分析以下两个核心方法:

  • get(K key):读取指定键的值(读操作)
  • computeIfPresent(K key, BiFunction<K, V, V> remappingFunction):当键存在时,应用重映射函数更新值(写操作)

这些方法的具体应用将在第 3 节通过代码示例展示。

2.2 为什么选择 ConcurrentHashMap

与普通 HashMap 相比,ConcurrentHashMap 的核心优势在于:

  • 读操作完全并发:任意数量的线程可同时读取同一键
  • 写操作高度并发:仅在键级别阻塞,而非整个映射表

⚠️ 关键特性:读操作永不阻塞且不被写操作阻塞,写操作仅在键级别阻塞其他写操作。这种设计在高吞吐量和最终一致性场景中至关重要。

对比其他线程安全方案:

实现方式 并发粒度 性能影响
HashTable/synchronizedMap 整表锁定 ❌ 严重阻塞
ConcurrentHashMap 键级别锁定 ✅ 高效并发

结论:在多线程环境中,ConcurrentHashMap 通过细粒度锁机制显著优于传统方案,是高并发场景的首选。

3. 线程安全操作

ConcurrentHashMap 内置了线程安全保证,能有效规避常见的并发陷阱。下面通过数字频率统计的测试用例演示其工作原理:

public class ConcurrentHashMapUnitTest {

    private Map<Integer, Integer> frequencyMap;

    @BeforeEach
    public void setup() {
        frequencyMap = new ConcurrentHashMap<>();
        frequencyMap.put(0, 0);
        frequencyMap.put(1, 0);
        frequencyMap.put(2, 0);
    }

    @AfterEach
    public void teardown() {
        frequencyMap.clear();
    }

    private static void sleep(int timeout) {
        try {
            TimeUnit.SECONDS.sleep(timeout);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

3.1 读操作

ConcurrentHashMap 允许完全并发的读操作,这意味着:

  • ✅ 多个线程可同时读取同一键
  • ✅ 读操作永不阻塞写操作
  • ⚠️ 可能读取到"旧"数据(最终一致性)

以下测试演示了写操作过程中读操作的行为:

@Test
public void givenOneThreadIsWriting_whenAnotherThreadReads_thenGetCorrectValue() throws Exception {
    ExecutorService threadExecutor = Executors.newFixedThreadPool(3);

    Runnable writeAfter1Sec = () -> frequencyMap.computeIfPresent(1, (k, v) -> {
        sleep(1);
        return frequencyMap.get(k) + 1;
    });

    Callable<Integer> readNow = () -> frequencyMap.get(1);
    Callable<Integer> readAfter1001Ms = () -> {
        TimeUnit.MILLISECONDS.sleep(1001);
        return frequencyMap.get(1);
    };

    threadExecutor.submit(writeAfter1Sec);
    List<Future<Integer>> results = threadExecutor.invokeAll(asList(readNow, readAfter1001Ms));

    assertEquals(0, results.get(0).get());
    assertEquals(1, results.get(1).get());

    if (threadExecutor.awaitTermination(2, TimeUnit.SECONDS)) {
        threadExecutor.shutdown();
    }
}

执行流程解析

  1. 写线程延迟 1 秒更新键 1 的值
  2. 读线程立即读取 → 获取旧值 0
  3. 延迟读线程在 1001ms 后读取 → 获取新值 1

💡 踩坑提示:如果需要强一致性读(阻塞式),可使用 computeIfPresent 配合恒等函数替代 get(),但会牺牲读取性能。

3.2 写操作

ConcurrentHashMap 的写操作具有以下特性:

  • ✅ 同一键的写操作串行化(保证一致性)
  • ✅ 不同键的写操作完全并发(提升吞吐量)

同一键写入测试

@Test
public void givenOneThreadIsWriting_whenAnotherThreadWritesAtSameKey_thenWaitAndGetCorrectValue() throws Exception {
    ExecutorService threadExecutor = Executors.newFixedThreadPool(2);

    Callable<Integer> writeAfter5Sec = () -> frequencyMap.computeIfPresent(1, (k, v) -> {
        sleep(5);
        return frequencyMap.get(k) + 1;
    });

    Callable<Integer> writeAfter1Sec = () -> frequencyMap.computeIfPresent(1, (k, v) -> {
        sleep(1);
        return frequencyMap.get(k) + 1;
    });

    List<Future<Integer>> results = threadExecutor.invokeAll(asList(writeAfter5Sec, writeAfter1Sec));

    assertEquals(1, results.get(0).get());
    assertEquals(2, results.get(1).get());

    if (threadExecutor.awaitTermination(2, TimeUnit.SECONDS)) {
        threadExecutor.shutdown();
    }
}

关键结论

  • 第一个写线程(5秒延迟)获取锁后阻塞其他写操作
  • 第二个写线程必须等待锁释放
  • 最终结果按提交顺序返回:12

不同键写入测试

@Test
public void givenOneThreadIsWriting_whenAnotherThreadWritesAtDifferentKey_thenNotWaitAndGetCorrectValue() throws Exception {
    ExecutorService threadExecutor = Executors.newFixedThreadPool(2);

    Callable<Integer> writeAfter5Sec = () -> frequencyMap.computeIfPresent(1, (k, v) -> {
        sleep(5);
        return frequencyMap.get(k) + 1;
    });

    AtomicLong time = new AtomicLong(System.currentTimeMillis());
    Callable<Integer> writeAfter1Sec = () -> frequencyMap.computeIfPresent(2, (k, v) -> {
        sleep(1);
        time.set((System.currentTimeMillis() - time.get()) / 1000);
        return frequencyMap.get(k) + 1;
    });

    threadExecutor.invokeAll(asList(writeAfter5Sec, writeAfter1Sec));

    assertEquals(1, time.get());

    if (threadExecutor.awaitTermination(2, TimeUnit.SECONDS)) {
        threadExecutor.shutdown();
    }
}

性能验证

  • 第二个写线程操作不同键(2),无需等待
  • 实际执行时间仅 1 秒(而非 5 秒)
  • 证明不同键的写操作真正并行

简单粗暴的结论ConcurrentHashMap 通过键级锁机制,实现了写操作的高并发,性能远超整表锁方案。

4. 总结

本文深入分析了 ConcurrentHashMap 的读写特性:

  1. 读操作:完全并发,非阻塞,提供最终一致性
  2. 写操作:键级锁保证一致性,不同键的写操作真正并行
  3. 性能优势:在高并发场景下显著优于 HashTablesynchronizedMap

💡 开发建议:当需要在多线程环境中使用哈希表时,优先考虑 ConcurrentHashMap,除非有特殊的一致性要求。

完整示例代码可在 GitHub 仓库 获取。


原始标题:Reading and Writing With a ConcurrentHashMap