《Java 高级编程》5.1 并发容器:ConcurrentHashMap 与 CopyOnWriteArrayList

用 synchronized 包一个 HashMap 也能线程安全,为什么 libraryd 还要换 ConcurrentHashMap?本节用 800 万次原子与非原子写入、三组键规模的对比实验,说明争用点在哪里,再用 CopyOnWriteArrayList 实现借阅监听器,给出两者的适用边界。

本节目标:知道并发容器解决的是「争用点分散」,不是「无脑更快」,并能判断什么时候该用 COW。
适用版本:JDK 25(Temurin 25.0.5);只使用 JDK 标准库。

承接的问题

libraryd 有两个典型结构:

  1. ISBN → 借阅次数的内存索引。每次借阅成功加一,报表页按次数排出「热门图书 Top 20」,馆员端每本书的详情页也要读。
  2. 借阅事件监听器列表。审计、积分、消息推送各注册一个监听器,借阅成功后依次回调。监听器在启动时注册,运行期几乎不变。

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 阻塞队列:借阅事件流水线 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发