承接的问题
第 5 章拆了 ConcurrentHashMap,6.1 拆了写时复制,6.2 拆了跳表。三节的结论各自成立,但放到真实代码里,问题从来不是“这个类怎么实现”,而是“我该 new 哪一个”。
这一节不再看源码,只做一件事:把选型从“凭印象”变成“按维度排除”。四个维度是有序性、读写比例(以及写失败时的行为)、迭代语义、内存放大。任何一次误选,本质上都是忽略了其中一个维度。
先给结论,再给推导。一句话定位表:
| 集合 | 一句话定位 | 典型误用 |
|---|---|---|
ConcurrentHashMap | 无序点查的默认答案 | 拿它当有序表用,再自己排序 |
ConcurrentSkipListMap | 需要顺序时的点查 + 范围查询 | 只做点查却用它,白付一个数量级延迟 |
CopyOnWriteArrayList | 极小、极冷写的读多写少列表 | 当通用线程安全 List,写频繁时 O(n²) |
CopyOnWriteArraySet | 监听器/处理器注册表 | 当去重集合存几万条数据 |
ConcurrentLinkedQueue | 无界、非阻塞、多生产者多消费者 | 需要“队列满就阻塞”的有界语义 |
ArrayBlockingQueue | 有界、单锁、可公平 | 容量给得过大,退化成无界 |
LinkedBlockingQueue | 有界/无界、双锁、吞吐略高 | 忘了默认容量是 Integer.MAX_VALUE |
Collections.synchronizedMap | 遗留代码兼容 | 新代码里当并发容器用 |
Hashtable / Vector | 只有历史意义 | 任何新代码 |
维度一:要不要有序
这是唯一一个“一旦需要就无法绕过”的维度。有序需求有三种形态,代价依次上升:
- 只要“按插入顺序遍历”。用
ConcurrentLinkedQueue或ConcurrentHashMap加一个序号字段都行,不需要有序容器。 - 要“按 key 排序遍历 / 范围查询 / 前驱后继”。只有
ConcurrentSkipListMap(或ConcurrentSkipListSet)能同时满足并发与有序。 - 要“按插入顺序 + 可随机访问”。这在并发容器里没有现成答案,通常用“
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 纳秒以上。有序是一种要付费的能力,不需要就别买。
维度二:读写比例与写失败行为
第二个维度是读写比例,但它真正的含义不是“谁多”,而是写操作会不会让读者付出代价。
| 结构 | 读的代价 | 写的代价 | 写与读是否互相阻塞 |
|---|---|---|---|
ConcurrentHashMap | O(1),无锁 | O(1) 均摊,桶级 CAS | 不阻塞 |
ConcurrentSkipListMap | O(log n),无锁 | O(log n),CAS + 补索引 | 不阻塞(索引可能暂时缺失) |
CopyOnWriteArrayList | O(1),无锁 | O(n) 复制 + O(n) 垃圾 | 写阻塞写,不阻塞读 |
synchronizedList | O(1),但要抢锁 | O(1),但要抢锁 | 读写互斥 |
ArrayBlockingQueue | O(1),单锁 | O(1),单锁 | 读写互斥(同一把锁) |
LinkedBlockingQueue | O(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 在本机统计的结构内存(粗估):
| 结构 | 每条约占 | 说明 |
|---|---|---|
TreeMap | 40 B | Entry 五个引用 + 颜色位 |
ConcurrentSkipListMap | 约 48 B | 数据节点 32 B + 平均 0.5 个索引节点 |
ConcurrentHashMap | 约 42 B | 节点 32 B + 表数组按 4 B/槽摊 |
CopyOnWriteArrayList | 4 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.sort | CHM + 每次查询排序 | 每次 O(n log n),且随数据量线性恶化 | ConcurrentSkipListMap.subMap |
只做 get/put,却用了跳表 | ConcurrentSkipListMap | 单点延迟高一个数量级 | ConcurrentHashMap |
for (x : syncList) { ... } 偶发 CME | synchronizedList 未同步遍历 | 遍历不是原子的 | 遍历时 synchronized (list),或换 COW/CHM |
队列 OOM,堆里全是 LinkedBlockingQueue$Node | new 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
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。