本节目标:知道并发容器解决的是「争用点分散」,不是「无脑更快」,并能判断什么时候该用 COW。
适用版本:JDK 25(Temurin 25.0.5);只使用 JDK 标准库。
承接的问题
libraryd 有两个典型结构:
- ISBN → 借阅次数的内存索引。每次借阅成功加一,报表页按次数排出「热门图书 Top 20」,馆员端每本书的详情页也要读。
- 借阅事件监听器列表。审计、积分、消息推送各注册一个监听器,借阅成功后依次回调。监听器在启动时注册,运行期几乎不变。
4.3 的做法是「一把 ReentrantLock 包住所有读写」。它正确,但把互不相关的操作也串行化了:读 Top 20 的时候,别的线程不能给任何一本书加计数。索引的键有几十万个,绝大多数操作根本不冲突,被一把锁排队是浪费。
并发容器要解决的就是这个问题:把争用点从「整个容器」缩小到「某个桶」。但要先说清楚,它不会让「所有线程改同一个键」变快。
原子复合操作:get 加 put 会丢
ConcurrentHashMap 保证单个 get、put 是线程安全的,但组合操作不是。下面这段代码在 8 个线程、每个 20 万次、1000 个键上跑:
// 反例:先读后写,不是原子操作
Long current = map.get(isbn);
map.put(isbn, current == null ? 1L : current + 1);
本机输出:
mode=naive keys=1000 threads=8 ops=1600000 total=1549080 lost=50920 elapsedMs=92
160 万次写入,最终只统计到 154.9 万,丢了 5 万多次。两个线程读到同一个旧值,各自加一写回,后写的覆盖先写的。容器是线程安全的,业务逻辑不是。
正确的写法是用容器提供的原子复合方法:
map.merge(isbn, 1L, Long::sum);
merge 在桶级别加锁(或 CAS)完成「读—算—写」,同一个键的并发更新不会丢。另一个常用的是 computeIfAbsent:
counts.computeIfAbsent(isbn, key -> new LongAdder()).increment();
这里 computeIfAbsent 只保证「取出或创建」这一步原子,随后的 increment() 由 LongAdder 自己保证原子。两步组合起来仍然正确,因为同一个键永远对应同一个 LongAdder 实例。
快不快取决于争用点在哪里
把三种实现放在同一套 harness 里:8 线程 × 20 万次,键的数量分别取 8、1000、100000。
javac --release 25 -d /tmp/vol3-b/collections/out /tmp/vol3-b/collections/IndexBench.java
java -Dkeys=8 -cp /tmp/vol3-b/collections/out IndexBench 8 200000 sync
java -Dkeys=8 -cp /tmp/vol3-b/collections/out IndexBench 8 200000 chm
java -Dkeys=8 -cp /tmp/vol3-b/collections/out IndexBench 8 200000 adder
本机输出(Apple M1 Pro,macOS 26.3,Temurin 25.0.5,单次运行,未用 JMH):
mode=sync keys=8 threads=8 ops=1600000 total=1600000 lost=0 elapsedMs=123 opsPerMs=12937.6
mode=chm keys=8 threads=8 ops=1600000 total=1600000 lost=0 elapsedMs=164 opsPerMs=9748.1
mode=adder keys=8 threads=8 ops=1600000 total=1600000 lost=0 elapsedMs=45 opsPerMs=34804.2
mode=sync keys=1000 threads=8 ops=1600000 total=1600000 lost=0 elapsedMs=129 opsPerMs=12365.7
mode=chm keys=1000 threads=8 ops=1600000 total=1600000 lost=0 elapsedMs=85 opsPerMs=18762.1
mode=adder keys=1000 threads=8 ops=1600000 total=1600000 lost=0 elapsedMs=63 opsPerMs=25111.0
mode=sync keys=100000 threads=8 ops=1600000 total=1600000 lost=0 elapsedMs=105 opsPerMs=15140.6
mode=chm keys=100000 threads=8 ops=1600000 total=1600000 lost=0 elapsedMs=69 opsPerMs=23069.5
mode=adder keys=100000 threads=8 ops=1600000 total=1600000 lost=0 elapsedMs=94 opsPerMs=17001.6
三个反直觉的地方,正好是选型依据:
- 只有 8 个热点键时,
ConcurrentHashMap.merge比synchronized包HashMap还慢(164ms vs 123ms)。8 个键挤在少数几个桶里,merge要做装箱、CAS 失败重试,而短临界区的synchronized有偏向锁消除和更少的对象分配。争用点没有分散,换容器没有收益。 - 1000 个键时
ConcurrentHashMap快约 1.5 倍(85ms vs 129ms)。键散开了,不同桶可以并行。 LongAdder在 8 个热点键时快 3 倍多(45ms vs 164ms)。它把计数分散到多个 Cell,同一个键也不再是单点。到了 10 万个键,LongAdder反而变慢(94ms),因为每个键一个LongAdder对象,内存和分配成了成本。
结论不是「用哪个容器」,而是「先看争用点」。热点键少、需要精确计数,用 LongAdder;键多、读写混合,用 ConcurrentHashMap;只有少数几个键且临界区极短,synchronized 也不差。
CopyOnWriteArrayList:用写成本换读的无锁
监听器列表的形状是「启动时写几次,运行期只读」。这正是 CopyOnWriteArrayList 的设计场景:每次修改都复制整个数组,读操作完全无锁,迭代器拿到的是修改前那一刻的快照。
实验一:遍历时注册一个新监听器。
for (LoanListener listener : cow) {
listener.onBorrowed("978000000001");
cow.add(isbn -> System.out.println(" [cow-late] " + isbn));
}
本机输出:
-- COW: add during iteration
[cow-1] 978000000001
size after loop=2
-- ArrayList: add during iteration
[plain-1] 978000000002
java.util.ConcurrentModificationException: null
COW 的迭代器遍历的是旧数组,新增的监听器本轮看不到,也不会抛 ConcurrentModificationException;循环结束后 size 是 2。换成 ArrayList,同样的写法直接抛 CME。
这个语义要理解成「快照隔离」:迭代期间的新增对本次迭代不可见,删除也不会让迭代器失效。对事件分发来说这是优点——本轮事件的监听器集合在开始时确定,不会中途变化。
实验二:写成本。在一个已有 N 个元素的 COW 列表上做 1000 次 add:
baseSize=1000 1000 adds: COW=0ms ArrayList=0ms
baseSize=10000 1000 adds: COW=11ms ArrayList=0ms
baseSize=100000 1000 adds: COW=23ms ArrayList=0ms
10 万元素时,1000 次 add 复制了约 1 亿个引用,用了 23ms;ArrayList 同规模不到 1ms。写越频繁、列表越大,代价越明显。所以 COW 只适合「写极少、读极多、元素不多」的场景,监听器列表、白名单、路由表都属于这一类。
还有两个细节:
- COW 的
add/remove各自加锁,但多个写之间仍然串行,它不是为并发写设计的。 - 迭代器不支持
remove,调用会抛UnsupportedOperationException。
弱一致的视图
ConcurrentHashMap 的 size()、isEmpty()、keySet() 的迭代都是弱一致的:它们不锁全表,返回的是「某个中间时刻」的近似值。
size()可能包含正在插入、尚未完全可见的元素,也可能漏掉刚删除的元素。- 迭代器是弱一致的,不会抛 CME,但可能看到部分更新。
clear()也不是原子的。
对 libraryd 的索引来说这没问题:trackedIsbns() 只用于监控面板。但如果业务要求「元素个数必须精确」,就得换一种结构,或者在调用方加同步。
另一个硬限制:ConcurrentHashMap 不允许 null 键和 null 值。map.get(k) 返回 null 时无法区分「键不存在」和「值是 null」,所以在并发容器里干脆禁止 null。迁移老代码时这经常炸:
java.lang.NullPointerException
at java.base/java.util.concurrent.ConcurrentHashMap.putVal(ConcurrentHashMap.java:1011)
在 libraryd 里落地
索引类把 ConcurrentHashMap 和 LongAdder 组合起来,兼顾「键分散」和「热点键也不打架」:
package com.plume.library.persistence;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.LongAdder;
public final class IsbnBorrowIndex {
private final ConcurrentHashMap<String, LongAdder> counts = new ConcurrentHashMap<>();
public void recordBorrow(String isbn) {
counts.computeIfAbsent(isbn, key -> new LongAdder()).increment();
}
public long borrowCount(String isbn) {
LongAdder adder = counts.get(isbn);
return adder == null ? 0L : adder.sum();
}
public List<Map.Entry<String, Long>> top(int limit) {
return counts.entrySet().stream()
.map(entry -> Map.entry(entry.getKey(), entry.getValue().sum()))
.sorted(Map.Entry.comparingByValue(Comparator.reverseOrder()))
.limit(limit)
.toList();
}
public int trackedIsbns() {
return counts.size();
}
public void evict(String isbn) {
counts.remove(isbn);
}
public void replaceAll(Map<String, Long> databaseCounts) {
ConcurrentHashMap<String, LongAdder> fresh = new ConcurrentHashMap<>();
databaseCounts.forEach((isbn, count) -> {
LongAdder adder = new LongAdder();
adder.add(count);
fresh.put(isbn, adder);
});
counts.clear();
counts.putAll(fresh);
}
}
replaceAll 是每天凌晨的对账入口:用数据库的真值覆盖内存计数。它先构造一份新 map 再 clear + putAll,中间会有一个短暂的空窗期,读到 0。如果要避免这个空窗,应该把整个索引做成 4.1 那样的不可变快照,用 volatile 引用发布;这里选择简单实现,是因为对账在低峰期执行,且失败可以重跑。
监听器用 COW:
package com.plume.library.persistence;
import com.plume.library.domain.LoanEvent;
import java.util.concurrent.CopyOnWriteArrayList;
public final class LoanListenerRegistry {
private static final System.Logger LOG = System.getLogger("libraryd.loan.listener");
public interface LoanListener {
void onEvent(LoanEvent event);
}
private final CopyOnWriteArrayList<LoanListener> listeners = new CopyOnWriteArrayList<>();
public void register(LoanListener listener) {
listeners.add(listener);
}
public boolean unregister(LoanListener listener) {
return listeners.remove(listener);
}
public void fire(LoanEvent event) {
for (LoanListener listener : listeners) {
try {
listener.onEvent(event);
} catch (RuntimeException e) {
LOG.log(System.Logger.Level.WARNING,
"listener " + listener.getClass().getName()
+ " failed on loan " + event.loanId(), e);
}
}
}
public int size() {
return listeners.size();
}
}
fire 里对每个监听器单独 try/catch:一个监听器抛异常不能让后面的收不到事件。这是事件分发的基本要求,用 COW 的 for-each 天然满足「本轮集合固定」。
使用处是借阅成功之后:
Loan loan = loanRepository.insert(...);
loanMetrics.recordBorrowed();
isbnIndex.recordBorrow(loan.isbn());
listeners.fire(new LoanEvent(loan.id(), loan.isbn(), loan.memberId(),
LoanEvent.Type.BORROWED, Instant.now()));
顺序有讲究:先落库,再更新内存。反过来会出现「内存里加了,数据库回滚了」的脏数据。
常见错误与边界
错误一:在 computeIfAbsent 的映射函数里改同一个 map。 同一个键递归更新会直接抛异常:
java.lang.IllegalStateException: Recursive update
改别的键不会抛异常,但会让这次计算的结果不可预测(本机实测 map 里两个键都被写入了)。映射函数里只做「根据 key 构造 value」,不要产生副作用。
错误二:把 COW 用在写多的场景。 日志缓冲、请求上下文列表这类每秒写几十次的结构,用 COW 会把 CPU 烧在数组复制上。写多读少应该用 ConcurrentLinkedQueue 或加锁的 ArrayList。
错误三:以为 ConcurrentHashMap 的复合操作都原子。 只有 merge、compute、computeIfAbsent、putIfAbsent、replace(k, old, new) 这些是原子的;get 后再 put、containsKey 后再 remove 都不是。
边界:size() 不是精确值。 高并发下 size() 可能比实际少,也可能多。用它做限流、配额判断会出错;要么用 LongAdder 单独计数,要么接受它是监控指标。
边界:COW 的迭代器是只读快照。 想在遍历时删除元素,必须先收集再统一 removeAll,不能在循环里 iterator.remove()。
小结
ConcurrentHashMap保证单操作线程安全,get加put的组合仍会丢更新;本机 160 万次丢了 5 万次。- 容器不会让「所有线程改同一个键」变快:8 个热点键时
merge比短临界区的synchronized还慢,LongAdder才是那个场景的答案。 - 1000 个键时
ConcurrentHashMap比全局锁快约 1.5 倍,收益来自桶级争用分散。 CopyOnWriteArrayList用写时复制换无锁读,迭代器是快照;10 万元素时 1000 次add约 23ms。size()、迭代器都是弱一致的;ConcurrentHashMap不允许 null 键和 null 值。- libraryd 用
ConcurrentHashMap<String, LongAdder>做借阅索引,用 COW 做监听器注册表。
容器解决了「共享状态怎么并发访问」,但借阅事件本身还需要从请求线程传到后台。下一节用阻塞队列把这条流水线搭起来,并处理队列满和停机。
阅读导航:上一节:4.3 锁与条件队列:synchronized、ReentrantLock 与 AQS · 下一节:5.2 阻塞队列:借阅事件流水线 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。