图 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 一定看不到。一个弱一致、一个快照一致——这就是「遍历要不要一致」这个问题对应的两种答案。
图 2:读写比例定横轴位置,一致性与阻塞需求定最终答案。
回到三个选型问题。
把三个问题落到决策上:读写都频繁、遍历只为监控——ConcurrentHashMap;读极多写极少、遍历必须是稳定快照——CopyOnWriteArrayList(还有同族的 CopyOnWriteArraySet);需要把生产者和消费者的速率解耦、允许互相等待——BlockingQueue 家族(ArrayBlockingQueue 有界数组、LinkedBlockingQueue 可可有界、SynchronousQueue 零容量直接交接)。至于普通 HashMap 和 ArrayList,在多线程读写下不在候选之列——这不是性能问题,是正确性问题。
下次 review 代码看到并发场景里的普通容器,或者读多写少场景里的 synchronizedList,你就知道该提什么意见了。你在项目里为并发容器选型翻过车吗?最常见的误用是不是拿 CopyOnWriteArrayList 扛高频写?欢迎聊聊。