深蓝黑背景封面:右侧大标题「并发容器,按读写比例选」,副标题「ConcurrentHashMap、CopyOnWrite 与阻塞队列的适用边界」;左侧原创示意图——三张容器卡片纵向排开,各带一条读写比例条:ConcurrentHashMap 读写各半,CopyOnWriteArrayList 读条极长写条极短,BlockingQueue 画成生产者与消费者两头对接的管道;左上标注「知识专栏 · 并发容器」,右上标注「Day 06 · 11-03」 图 1:本文封面,读写比例条决定选谁。

「HashMap 是不是线程安全、并发下用什么 Map」是一道经典面试题。那道题的正解止步于「HashMap 不安全、用 ConcurrentHashMap」;本文换个角度:JUC 一整个并发容器家族摆在面前,选型依据是什么。先把结论放在前面:选容器之前要回答三个问题——读写比例多少、遍历要不要看到一致快照、需不需要阻塞等待。

ConcurrentHashMap:读写都频繁的通用答案。

JDK 官方文档给它的定位:支持完全并发的读取和高并发更新的哈希表。几个关键语义:检索操作(get)完全不加锁,所以读可以任意多;迭代器是弱一致的——反映迭代器创建时或之后某个时刻的状态,可能看到也可能看不到迭代期间的写入,但绝不会抛 ConcurrentModificationException。文档还给了一个保证:对某 key 的更新 happens-before 任何读到该值的 get,也就是说读到的不会是「半写进去」的脏值。

它适合的场景画像:读写都频繁、能接受遍历时「大致正确」。计数器、本地缓存、注册表这类「get 为主、遍历只为监控」的场景是它的主场。官方文档甚至给了一个惯用法:配合 LongAdder 和 computeIfAbsent 做可扩展的频率统计。

CopyOnWriteArrayList:读极多写极少的特化答案。

它的并发手段简单粗暴:所有写操作(add、set、remove)都把底层数组整体复制一份,写在新副本上,写完替换引用;读完全无锁。官方文档明说这通常太昂贵,除非遍历操作远远多于写操作。换来的东西很值钱:迭代器是创建时刻的快照,遍历期间随便写,迭代器看到的世界不变、绝不抛 CME,也不用加任何锁。

典型画像是监听器列表、路由表、开关配置——一个月改三次、每秒读十万次。反过来,写一多它就崩:每次写都复制整个数组,一千个元素的列表高频写就是稳定制造 GC 压力。

BlockingQueue:它解的不是读写共享,是速率协调。

阻塞队列常被误放进「并发容器」里一起背,其实它回答的是另一个问题:生产者和消费者速率不匹配怎么办。官方文档把方法分成四种形式:抛异常的(add/remove)、返回特殊值的(offer/poll)、无限阻塞的(put/take)、超时阻塞的(带时间参数的 offer/poll)。容量有界时,满了 put 阻塞生产者、空了 take 阻塞消费者——队列本身就是流量整形器。文档还提醒:它不支持原生的关闭语义,惯例是生产者投入「毒丸」对象通知消费者收工。

三个容器的行为用代码对照一遍。

下面这段在 JDK 17 下编译运行(javac ConcurrentContainers.java && java ConcurrentContainers):

import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.atomic.LongAdder;

public class ConcurrentContainers {
    public static void main(String[] args) throws InterruptedException {
        // 1) ConcurrentHashMap:读写并发,get 不加锁,迭代器弱一致(不抛 CME)
        ConcurrentHashMap<String, LongAdder> freq = new ConcurrentHashMap<>();
        List<String> words = List.of("a", "b", "a", "c", "a", "b");
        for (String w : words) {
            freq.computeIfAbsent(w, k -> new LongAdder()).increment();
        }
        System.out.println("CHM 频率统计:" + freq);
        var it = freq.entrySet().iterator();
        freq.put("d", new LongAdder());          // 迭代过程中继续写
        System.out.print("CHM 弱一致迭代:");
        while (it.hasNext()) System.out.print(it.next().getKey() + " ");
        System.out.println("(不抛 CME,是否包含后写的 d 不确定)");

        // 2) CopyOnWriteArrayList:写时整体复制,读无锁,迭代器是创建时刻的快照
        CopyOnWriteArrayList<String> listeners =
            new CopyOnWriteArrayList<>(List.of("L1", "L2", "L3"));
        var snap = listeners.iterator();
        listeners.add("L4"); // 迭代器创建之后再写
        System.out.print("COW 快照迭代:");
        while (snap.hasNext()) System.out.print(snap.next() + " ");
        System.out.println("(看不到后加的 L4,也绝不会抛 CME)");
        System.out.println("COW 当前内容:" + listeners);

        // 3) BlockingQueue:容量满时 put 阻塞,空时 take 阻塞,解的是速率协调
        BlockingQueue<String> q = new LinkedBlockingQueue<>(2);
        q.put("m1");
        q.put("m2");
        System.out.println("队列已满,remainingCapacity=" + q.remainingCapacity());
        System.out.println("offer 非阻塞再塞一个:" + q.offer("m3"));
        System.out.println("take 取走:" + q.take());
        System.out.println("腾出一个空位后再 offer:" + q.offer("m3"));
        System.out.println("队列内容:" + q);
    }
}

实测输出:

CHM 频率统计:{a=3, b=2, c=1}
CHM 弱一致迭代:a b c d (不抛 CME,是否包含后写的 d 不确定)
COW 快照迭代:L1 L2 L3 (看不到后加的 L4,也绝不会抛 CME)
COW 当前内容:[L1, L2, L3, L4]
队列已满,remainingCapacity=0
offer 非阻塞再塞一个:false
take 取走:m1
腾出一个空位后再 offer:true
队列内容:[m2, m3]

两段迭代行为对照着看最有意思:CHM 的迭代器在迭代期间看到 d 也正常、看不到也正常(这次运行看到了),语义上它只承诺「某个时刻的状态」;COW 的迭代器则锁死在创建时刻的快照,L4 一定看不到。一个弱一致、一个快照一致——这就是「遍历要不要一致」这个问题对应的两种答案。

选型谱系图:上方是一条读写比例横轴,从「读极多写极少」到「读写都频繁」,三类容器按位置落点——CopyOnWriteArrayList 落在最左,ConcurrentHashMap 落在中间偏右,普通 HashMap 画在轴外并打叉表示并发禁用。中间三栏分别讲三种容器的并发手段与代价:ConcurrentHashMap 用 CAS 加分段锁,读写都扛得住但遍历是弱一致;CopyOnWriteArrayList 写时整体复制数组,读完全无锁,写一大就贵;BlockingQueue 用两把锁加条件队列,解的是生产者与消费者的速率协调而不是读写共享。底部提示:选容器先回答三个问题——读写比例多少、遍历要不要一致、需不需要阻塞等待 图 2:读写比例定横轴位置,一致性与阻塞需求定最终答案。

回到三个选型问题。

把三个问题落到决策上:读写都频繁、遍历只为监控——ConcurrentHashMap;读极多写极少、遍历必须是稳定快照——CopyOnWriteArrayList(还有同族的 CopyOnWriteArraySet);需要把生产者和消费者的速率解耦、允许互相等待——BlockingQueue 家族(ArrayBlockingQueue 有界数组、LinkedBlockingQueue 可可有界、SynchronousQueue 零容量直接交接)。至于普通 HashMap 和 ArrayList,在多线程读写下不在候选之列——这不是性能问题,是正确性问题。

下次 review 代码看到并发场景里的普通容器,或者读多写少场景里的 synchronizedList,你就知道该提什么意见了。你在项目里为并发容器选型翻过车吗?最常见的误用是不是拿 CopyOnWriteArrayList 扛高频写?欢迎聊聊。