《Java 并发编程·JUC 源码与容器》6.3 并发集合选型矩阵

把 ConcurrentHashMap、跳表、写时复制与阻塞队列放进有序性、读写比例、迭代语义、内存放大四个维度的矩阵,给出可直接照做的决策树与五种常见误选,并用 libraryd-conc 的 ISBN 索引改造实测每次更新的分配量从 162684 字节降到约 570 字节。

承接的问题

第 5 章拆了 ConcurrentHashMap,6.1 拆了写时复制,6.2 拆了跳表。三节的结论各自成立,但放到真实代码里,问题从来不是“这个类怎么实现”,而是“我该 new 哪一个”。

这一节不再看源码,只做一件事:把选型从“凭印象”变成“按维度排除”。四个维度是有序性、读写比例(以及写失败时的行为)、迭代语义、内存放大。任何一次误选,本质上都是忽略了其中一个维度。

先给结论,再给推导。一句话定位表:

集合一句话定位典型误用
ConcurrentHashMap无序点查的默认答案拿它当有序表用,再自己排序
ConcurrentSkipListMap需要顺序时的点查 + 范围查询只做点查却用它,白付一个数量级延迟
CopyOnWriteArrayList极小、极冷写的读多写少列表当通用线程安全 List,写频繁时 O(n²)
CopyOnWriteArraySet监听器/处理器注册表当去重集合存几万条数据
ConcurrentLinkedQueue无界、非阻塞、多生产者多消费者需要“队列满就阻塞”的有界语义
ArrayBlockingQueue有界、单锁、可公平容量给得过大,退化成无界
LinkedBlockingQueue有界/无界、双锁、吞吐略高忘了默认容量是 Integer.MAX_VALUE
Collections.synchronizedMap遗留代码兼容新代码里当并发容器用
Hashtable / Vector只有历史意义任何新代码

维度一:要不要有序

这是唯一一个“一旦需要就无法绕过”的维度。有序需求有三种形态,代价依次上升:

  1. 只要“按插入顺序遍历”。用 ConcurrentLinkedQueue 或 ConcurrentHashMap 加一个序号字段都行,不需要有序容器。
  2. 要“按 key 排序遍历 / 范围查询 / 前驱后继”。只有 ConcurrentSkipListMap(或 ConcurrentSkipListSet)能同时满足并发与有序。
  3. 要“按插入顺序 + 可随机访问”。这在并发容器里没有现成答案,通常用“ConcurrentHashMap 存数据 + CopyOnWriteArrayList 存顺序”或干脆加锁。

第 2 种最容易被低估。很多团队先上 CHM,等报表需求来了,在业务代码里写:

List<String> isbns = new ArrayList<>(map.keySet());
Collections.sort(isbns);          // 每次查询 O(n log n)

一次导出几万条看不出问题,QPS 上来之后这段排序就会出现在火焰图顶端。6.2 的实测里,同样 10 万键、1000 次宽 300 的区间查询,跳表 2–6 微秒,CHM 过滤加排序 600–840 微秒——差两个数量级,而且差的是 CPU,不是锁。

反过来也常见:明明只做 get/put,却因为“听说跳表是有序的、更安全”而选了 ConcurrentSkipListMap,单点延迟从 20 纳秒变成 350 纳秒以上。有序是一种要付费的能力,不需要就别买。

维度二:读写比例与写失败行为

第二个维度是读写比例,但它真正的含义不是“谁多”,而是写操作会不会让读者付出代价。

结构读的代价写的代价写与读是否互相阻塞
ConcurrentHashMapO(1),无锁O(1) 均摊,桶级 CAS不阻塞
ConcurrentSkipListMapO(log n),无锁O(log n),CAS + 补索引不阻塞(索引可能暂时缺失)
CopyOnWriteArrayListO(1),无锁O(n) 复制 + O(n) 垃圾写阻塞写,不阻塞读
synchronizedListO(1),但要抢锁O(1),但要抢锁读写互斥
ArrayBlockingQueueO(1),单锁O(1),单锁读写互斥(同一把锁)
LinkedBlockingQueueO(1),take 锁O(1),put 锁读写各一把锁,互不阻塞

这张表解释了几件常被误解的事。

CopyOnWriteArrayList 的“写不阻塞读”是有代价的:写者之间仍然互斥,而且每次写都要复制整个数组,6.1 实测每次 add 分配约 2n 字节,连续追加 n 个元素总分配 O(n²)。所以它的适用区间非常窄:元素个数在几十到几百、写频率低于每分钟一次。

LinkedBlockingQueue 的双锁设计(putLock 与 takeLock 分开)让生产者和消费者不互斥,这是它相对 ArrayBlockingQueue 的主要优势;代价是节点要单独分配,且 size 用一个 AtomicInteger 维护(第 7 章展开)。选队列时如果生产与消费都很猛,双锁通常更划算;如果容量很小、要求严格 FIFO 与公平,单锁的 ArrayBlockingQueue 反而更可控。

还有一个容易忽略的点:写失败时怎么办。ConcurrentHashMap 的 put 永不失败(除非 OOM),computeIfAbsent 的映射函数里抛异常会让该桶保持原状;ConcurrentSkipListMap 的 put 对 null 键直接 NPE;CopyOnWriteArrayList.add 永远返回 true。而阻塞队列的 put 会阻塞、offer 会返回 false、add 会抛 IllegalStateException。选型时把“满了怎么办”写进方法名,比事后补 if 更可靠。

维度三:迭代语义

第三个维度决定你敢不敢在遍历里做事。

结构迭代器遍历时被修改支持 remove
ConcurrentHashMap弱一致不抛异常,可能看到部分修改支持(删当前元素)
ConcurrentSkipListMap弱一致不抛异常支持
CopyOnWriteArrayList快照不抛异常,看不到新修改不支持,抛 UnsupportedOperationException
CopyOnWriteArrayList.subList快照 + 校验抛 ConcurrentModificationException支持
synchronizedList快速失败抛 ConcurrentModificationException支持(需手动同步)
Hashtable / Vector快速失败抛 ConcurrentModificationException支持

这里有两个反直觉的点。第一,CopyOnWriteArrayList 的顶层迭代器不抛 CME,但它的 subList 会抛:COWSubList 持有一个 expectedArray,任何操作前都检查底层数组是否被换过,换过就抛。第二,Collections.synchronizedList 的迭代器仍然是快速失败的,而且遍历时必须自己 synchronized (list),否则照样抛——这是最经典的“以为加了同步就安全”的坑。

选型含义很直接:如果遍历过程中会回调外部代码(监听器、过滤器、访问者),优先选迭代语义宽松的结构(CHM、跳表、COW),否则调用方一个不小心就炸在别人的代码里。

维度四:内存放大

第四个维度在容量规划时才会痛。

同样 10 万个条目,6.2 在本机统计的结构内存(粗估):

结构每条约占说明
TreeMap40 BEntry 五个引用 + 颜色位
ConcurrentSkipListMap约 48 B数据节点 32 B + 平均 0.5 个索引节点
ConcurrentHashMap约 42 B节点 32 B + 表数组按 4 B/槽摊
CopyOnWriteArrayList4 B + 写时峰值只有一个引用数组,但写时同时存在两份
LinkedBlockingQueue约 24 B节点只有 item 与 next 两个引用

跳表比 TreeMap 多出来的那 20%,买的是“无锁 + 有序”;CHM 多出来的那部分主要是那 33% 的索引开销之外的表数组。真正需要警惕的放大源不是这些常数,而是两个非常数项:

  • CopyOnWriteArrayList 的瞬时双份数组:写的那一刻新旧两份同时存活,如果还有线程在遍历旧快照,旧数组的存活时间等于遍历时长。
  • ConcurrentHashMap 扩容时的双表:transfer 期间新旧 table 同时存在,峰值接近两倍。这一点第 5 章已讲。

决策树

把四个维度串起来,就是下面这棵树。它是排除法,从上往下走,第一个命中的分支就是答案。

需要按 key 排序 / 范围查询 / 前驱后继?
├── 是 → ConcurrentSkipListMap(点查也走它,别双写)
└── 否
    ├── 是 Map,且只需点查?
    │   └── 是 → ConcurrentHashMap
    ├── 是 List/Set,元素 < 几百,写极冷(配置、监听器、路由表)?
    │   └── 是 → CopyOnWriteArrayList / CopyOnWriteArraySet
    ├── 是队列,需要“满/空时阻塞”?
    │   ├── 有界且要公平 → ArrayBlockingQueue
    │   └── 高吞吐、生产消费都猛 → LinkedBlockingQueue(务必显式给容量)
    ├── 是队列,只是多线程传递、不想阻塞?
    │   └── 是 → ConcurrentLinkedQueue / ConcurrentLinkedDeque
    └── 以上都不是(需要复合原子操作、需要跨结构一致)
        └── 用锁 + 普通集合,把临界区写清楚(卷一第 9 章)

最后一条最容易被跳过。很多人不愿意承认“没有合适的并发容器”,于是硬套一个,再用 synchronized 在外面包一层——这比直接用锁更糟,因为你既付了并发容器的开销,又没有得到它的语义。当一次操作要原子地改两个结构时,正确的答案通常是一把锁,而不是两个并发容器。

这棵树可以直接写成代码,放进 conc-core 的工具类里,让选型理由跟着代码走:

package com.plume.conc.core;

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.concurrent.CopyOnWriteArrayList;

/** 选型辅助:把“为什么选它”固化成可读的决策。 */
public final class CollectionChoice {
    public enum Need { ORDERED_MAP, POINT_MAP, COLD_LIST, NONE }

    private CollectionChoice() {}

    public static String pick(Need need, int size, int writesPerMinute) {
        return switch (need) {
            case ORDERED_MAP -> "ConcurrentSkipListMap";
            case POINT_MAP -> "ConcurrentHashMap";
            case COLD_LIST -> (size < 500 && writesPerMinute < 1)
                    ? "CopyOnWriteArrayList"
                    : "synchronizedList 或换数据结构";
            case NONE -> "加锁 + 普通集合";
        };
    }

    public static void main(String[] args) {
        System.out.println(pick(Need.COLD_LIST, 3, 0));        // CopyOnWriteArrayList
        System.out.println(pick(Need.COLD_LIST, 50_000, 600)); // synchronizedList 或换数据结构
        System.out.println(pick(Need.ORDERED_MAP, 100_000, 1000)); // ConcurrentSkipListMap
    }
}

这段代码故意保留了一个陷阱:pick 只返回字符串,不返回实例。选型是设计期决策,不该在运行期由 size 动态决定——那只会得到一个“有时 COW、有时加锁”的不可预测系统。把判断写成常量或配置,比写成 if 好。

常见误选

下面五种误选在代码评审里出现频率最高,每一种都能对上前面四个维度里的一个。

症状误选后果正解
每次借还书都更新索引,GC 日志里老年代涨得快CopyOnWriteArrayList 存索引每次更新分配上百 KB,写放大到 O(n)ConcurrentHashMap,需要有序再叠加跳表
报表接口 P99 抖动,火焰图顶部是 Collections.sortCHM + 每次查询排序每次 O(n log n),且随数据量线性恶化ConcurrentSkipListMap.subMap
只做 get/put,却用了跳表ConcurrentSkipListMap单点延迟高一个数量级ConcurrentHashMap
for (x : syncList) { ... } 偶发 CMEsynchronizedList 未同步遍历遍历不是原子的遍历时 synchronized (list),或换 COW/CHM
队列 OOM,堆里全是 LinkedBlockingQueue$Nodenew LinkedBlockingQueue<>()默认容量 Integer.MAX_VALUE,等于无界显式给容量 + 拒绝策略

第三行值得多说一句。跳表的单点慢不是实现问题,是结构问题:14 层指针追逐,每层一次比较、一次可能的缓存缺失。任何“以后可能要排序”的模糊预期,都不足以支付这个代价;等需求真的来了再换,成本也就是改一个字段的类型。

在 libraryd-conc 里落地

libraryd-conc 的 ISBN 索引是一个真实的误选案例。最初的实现想的是“索引读多写少,用写时复制省掉读锁”,于是写成这样:

package com.plume.conc.core;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

/** 误选版本:用写时复制承担高写频的索引。 */
public final class CowIsbnIndex {
    public record Entry(String isbn, String shelf, int stock) {}

    private final CopyOnWriteArrayList<Entry> entries = new CopyOnWriteArrayList<>();

    public Entry find(String isbn) {
        for (Entry e : entries) if (e.isbn().equals(isbn)) return e;
        return null;
    }

    public void update(Entry e) {
        entries.removeIf(old -> old.isbn().equals(e.isbn()));  // 一次全量复制
        entries.add(e);                                        // 再一次全量复制
    }

    public List<Entry> range(String from, String to) {
        List<Entry> out = new ArrayList<>();
        for (Entry e : entries)
            if (e.isbn().compareTo(from) >= 0 && e.isbn().compareTo(to) < 0) out.add(e);
        out.sort((a, b) -> a.isbn().compareTo(b.isbn()));
        return out;
    }
}

问题出在“读多写少”这个判断上。索引的读确实多,但写也不少:每一次借书、还书、盘点都要更新对应条目的 stock,一个中型图书馆每天几万次。而且 update 是两次全量复制(removeIf 一次、add 一次),find 还是 O(n) 线性扫描。三个代价叠在一起:

本机在 2 万条目上测每次更新的线程内分配量:

== 索引更新一次的平均分配字节数(n=20000,本机)
CowIsbnIndex        162684 B/次
HybridIsbnIndex        596 B/次

162684 字节,约 159 KB/次。按每天 5 万次更新算,一天产生约 8 GB 的即时垃圾——这些数组全部是短命对象,年轻代会频繁晋升失败,GC 时间随之上升。改成下面这样,同一个指标降到 564–596 字节(三次运行分别在 564、568、596,差异来自 ConcurrentSkipListMap 节点的 TLAB 对齐),差约 280 倍:

package com.plume.conc.core;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentSkipListMap;

/** 正解:点查走 CHM,有序区间走 CSLM;两个结构各自 CAS。 */
public final class HybridIsbnIndex {
    public record Entry(String isbn, String shelf, int stock) {}

    private final ConcurrentHashMap<String, Entry> byIsbn = new ConcurrentHashMap<>();
    private final ConcurrentSkipListMap<String, Entry> ordered = new ConcurrentSkipListMap<>();

    public Entry find(String isbn) { return byIsbn.get(isbn); }

    public void update(Entry e) {
        byIsbn.put(e.isbn(), e);      // 点查结构:O(1)
        ordered.put(e.isbn(), e);     // 有序结构:O(log n)
    }

    public List<Entry> range(String from, String to) {
        return new ArrayList<>(ordered.subMap(from, true, to, false).values());
    }
}

改造后要接受一个新的代价:双写不是原子的。update 里两个 put 之间如果被读到,可能 find 拿到新条目而 range 拿到旧条目。对 ISBN 索引这是可接受的(两个结构最终一致,差异窗口是纳秒级);如果业务不能接受,正确做法是只保留 ordered 一个结构,点查也走它——这就是 6.2 里 ShelfCatalog 的写法,用一点单点延迟换掉双写不一致。

到底要不要双结构,取决于点查与区间查询的比例:点查占绝对多数、且对单点延迟敏感时,双结构划算;两者比例接近,或者不能容忍不一致时,单跳表更简单。简单性本身就是一个维度,只是它没写进那四个里。

小结

选型不是记 API,而是按维度排除:有序性决定能不能用哈希,读写比例决定能不能用写时复制,迭代语义决定敢不敢在遍历里回调,内存放大决定容量规划时的余量。ConcurrentHashMap 是无序点查的默认答案,ConcurrentSkipListMap 是有序需求的唯一解,CopyOnWriteArrayList 只属于“几百个元素、写极冷”的那一小块,队列则按“要不要阻塞、要不要有界”分流。拿不准时,回到决策树的最后一条:当一次操作要原子地改多个结构,用锁,别硬凑并发容器。

第 5、6 两章讲完了“容器”。下一章转向“传递”:阻塞队列如何用条件队列把生产者和消费者接起来。

阅读导航:上一节 6.2 ConcurrentSkipListMap | 下一节 7.1 ArrayBlockingQueue 与 LinkedBlockingQueue

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收