Skip to content

并发容器全景:从 synchronized Map 到 ConcurrentHashMap

本文是 Java 并发系统学习系列的 L2 核心篇。前置:AQS 源码走读。 学完可以配合面试题食用:ConcurrentHashMap 1.7 vs 1.8生产者消费者实现

为什么需要并发容器

ArrayList、HashMap 这些普通容器在多线程下用会出两类问题:数据覆盖丢失,以及结构被并发修改后迭代时直接抛 ConcurrentModificationException。最粗暴的修法是全局加一把 synchronized,Hashtable 和 Collections.synchronizedList(new ArrayList<>()) 就是这个思路,能跑,但读和写、写和写之间全互斥,并发一上来吞吐量掉得厉害。

JUC 的并发容器换了个思路:把锁的粒度切小(比如只锁一个桶),读操作尽量无锁,写操作互不冲突的不抢同一把锁。代价是实现复杂了,还弱化了一些语义——比如容器 size 是弱一致的。这篇把这些容器的原理和选型一次讲清。

fail-fast 与 fail-safe:迭代器为什么抛异常

ConcurrentModificationException 的触发机制是 modCount:ArrayList、HashMap 内部维护一个修改计数器,迭代器创建时记下当前值,每次 next() 前比对,发现不一致就抛异常。这就是 fail-fast,目的是尽快暴露并发修改 bug,而不是默默给你一份脏数据。

java
List<Integer> list = new ArrayList<>(List.of(1, 2, 3));
for (Integer x : list) {
    if (x == 2) list.remove(x); // 下一次 next() 抛 ConcurrentModificationException
}

fail-safe 迭代器不这样做。CopyOnWriteArrayList 的迭代器拍的是创建那一刻的数组快照,ConcurrentHashMap 的迭代器则直接遍历当前结构、读到什么算什么。两者都不抛异常,但代价不同:COW 的迭代器绝对一致但占一份内存,CHM 的迭代器是弱一致——迭代期间发生的插入或删除,可能反映也可能不反映。

一个常见误区:fail-fast 的异常机制是用来检测 bug 的,不是并发安全的保证。单线程里在增强 for 循环中 remove 同样会炸,正确姿势是用 iterator.remove()

ConcurrentHashMap:1.7 到 1.8 锁粒度的演进

1.7 分段锁

1.7 的结构是 Segment 数组套 HashEntry 数组,Segment 继承 ReentrantLock。写操作先定位到所在 Segment 再加锁,理论并发度等于 Segment 数量(默认 16)。想扩并发度只能在构造时指定,建好之后改不了。读路径通过 volatile 的 hash/key/next 字段保证可见性,不加锁。

1.8 CAS + synchronized 桶级锁

1.8 抛掉了 Segment,直接在 Node 数组的每个桶上做文章:

mermaid
flowchart TD
    A[put key] --> B[hash 定位桶 i]
    B --> C{桶为空?}
    C -- 是 --> D[CAS 插入新节点<br/>失败自旋重试]
    C -- 否 --> E{首节点 hash >= 0?}
    E -- 是 --> F[synchronized 锁首节点<br/>链表尾插或树化]
    E -- 否 MOVED --> G[helpTransfer 协助扩容]
    D --> H[addCount 计数]
    F --> H
    G --> H

几个值得记住的设计细节:

  • 为什么敢用 synchronized:锁的只是单个桶的首节点,冲突域极小;JDK 6 之后 synchronized 有偏向锁、轻量级锁的升级路径(前面 synchronized 篇讲过),桶级锁竞争概率低,多数时候停留在轻量级锁,性能不输 ReentrantLock。
  • size 怎么算:用类似 LongAdder 的 baseCount + CounterCell 分散计数,求 size 时汇总,得到的是近似值。
  • 扩容可以多线程协助:迁移时每个线程认领一段桶区间(stride),别的线程 put 时发现桶状态是 MOVED 就先去帮忙搬数据,这就是 helpTransfer。
  • null 值禁止:二义性问题——get(key) 返回 null 时无法区分"不存在"还是"存的就是 null"。单线程 HashMap 可以用 containsKey 消歧,并发下这两次调用之间可能被改,所以直接禁。

computeIfAbsent 的原子性

多线程下"查一下没有就放入"这个复合操作,用 containsKey + put 是有竞态的。computeIfAbsent 把两步合成一个原子操作:对首节点加锁后执行映射函数,保证同一个 key 的初始化只执行一次。

java
ConcurrentHashMap<String, LongAdder> counter = new ConcurrentHashMap<>();
String[] words = {"a", "b", "a", "c", "a", "b"};

// 多线程分词计数:computeIfAbsent 保证每个 key 的 LongAdder 只 new 一次
ExecutorService pool = Executors.newFixedThreadPool(4);
CountDownLatch latch = new CountDownLatch(4);
for (int t = 0; t < 4; t++) {
    pool.execute(() -> {
        for (String w : words) {
            counter.computeIfAbsent(w, k -> new LongAdder()).increment();
        }
        latch.countDown();
    });
}
latch.await();
System.out.println(counter); // {a=3, b=2, c=1},无论怎么并发结果都正确

注意映射函数里不要再操作同一个 map,会导致死锁或异常——官方文档明说了计算过程应短小且不修改映射本身。

CopyOnWriteArrayList:读不加锁的代价

COW 的写操作复制整个底层数组,改完把引用切过去;读操作直接读当前数组,完全无锁。这个模型适合读多写少且迭代远多于修改的场景,典型如EventListener 列表:注册只在启动时发生几次,事件触发时全量遍历,COW 让遍历永远不抛异常、不需要加锁。

代价也很硬:每次写分配一个 O(n) 的新数组,写频繁时 GC 压力和复制开销都顶不住;而且 set/remove 和迭代器之间是快照隔离,迭代中看不到并发修改。一个实际坑:有人把 COW 当"线程安全的任务列表"用,任务高频增删,结果写性能比 synchronized ArrayList 还差——选 COW 前先掂量写频率。

BlockingQueue 家族:生产者消费者的选型

阻塞队列把"队列满了等一等、队列空了等一等"的协调逻辑封装好了,是生产者消费者模型的首选积木。三个代表实现:

  • ArrayBlockingQueue:有界数组,一把锁管读写(可配公平模式)。有界是关键特性——能背压,防止上游生产过快把内存打爆。
  • LinkedBlockingQueue:链表实现,默认无界(Integer.MAX_VALUE),两把锁分别管头尾,读写可并行。吞吐通常高于 Array 版,但无界默认值在生产环境是个雷,队列堆积到 OOM 才报错。
  • SynchronousQueue:零容量,put 必须等到有消费者同时 take,相当于直接交接。CachedThreadPool 用它实现"来一个任务起一个线程"的语义。
  • DelayQueue:元素到期才能被取走,定时任务、订单超时关闭的常见原料。
java
// 20 行版生产者消费者
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10);

Thread producer = new Thread(() -> {
    try {
        for (int i = 1; i <= 100; i++) queue.put(i); // 队列满则阻塞
        queue.put(-1); // 毒丸,通知结束
    } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});

Thread consumer = new Thread(() -> {
    try {
        int v;
        while ((v = queue.take()) != -1) {           // 队列空则阻塞
            System.out.println("consume " + v);
        }
    } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});

producer.start();
consumer.start();
producer.join();
consumer.join();

选型一句话:需要背压用有界 ArrayBlockingQueue;高吞吐且确认生产速率可控用 LinkedBlockingQueue 但显式设容量;任务直接交接不许排队用 SynchronousQueue。

无锁队列:ConcurrentLinkedQueue 的位置

ConcurrentLinkedQueue 用 Michael-Scott 算法,CAS 修改头尾指针,全程无锁也不阻塞。它的特点是任意时刻单线程都在往前推进,但没有容量上限、take 不会等待——队列空了 poll 直接返回 null,你得自己处理"没活干"的逻辑。

对比 LinkedBlockingQueue:后者锁 + Condition 阻塞,能挂起消费者省 CPU,且能设容量背压;前者无锁吞吐高但没有等待语义。所以事件回调分发(来一个处理一个,没事件就返回)适合 ConcurrentLinkedQueue,而生产者消费者协调(必须等待、必须限流)适合 BlockingQueue。两者不是替代关系,是等待语义的分界线。

常见误区与小结

  • "Hashtable 是线程安全的所以能用"——能跑但锁太粗,全表一把锁,没有理由在新代码里选它。
  • "CopyOnWriteArrayList 全面优于 synchronized List"——只在读多写少成立,写密集场景复制数组的开销是灾难。
  • "ConcurrentHashMap 的 get 不加锁所以读到的一定最新"——get 读的是 volatile 状态的桶数据,保证可见性,但复合操作(先 get 后 put)依然有竞态,原子性要靠 compute 这类方法。
  • "迭代 ConcurrentHashMap 不会修改数据就不会有问题"——弱一致迭代器不抛异常,但 size、isEmpty 是近似值,别拿它做强一致判断。
  • 用增强 for 遍历集合时结构性 remove 抛 CME,这是单线程 bug,换并发容器掩盖不了逻辑错误。

小结:并发容器的演进主线是锁粒度从全表(Hashtable)到分段(CHM 1.7)再到桶级(CHM 1.8),加上读路径尽量无锁(COW、CLQ)和协调语义(BlockingQueue 阻塞等待)。下一篇讲线程池实战与监控,会把这篇的 BlockingQueue 用到线程池的任务缓冲区上。

参考

参考:JDK 源码 java.util.concurrent.ConcurrentHashMap(1.8 put/transfer/addCount 实现);《Java 并发编程实战》第 5 章同步容器与并发容器;OpenJDK wiki "JEP 166: Deprecate and remove Vector, Hashtable"相关讨论。

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1