承接的问题
第 5 章讲完 ConcurrentHashMap 的弱一致迭代:迭代器不抛异常,但可能看不到别的线程刚写入的元素。那是一套“边遍历边容忍变化”的语义。本章第一节走另一个极端——遍历时根本不允许底层数据变化,办法是写操作直接换掉整个数组,让读者手上那一个数组永远不变。这不是两种实现风格的差异,而是两种取舍:CHM 用桶级 CAS 换取写的高并发,代价是读到的视图会“晃动”;写时复制(copy-on-write)用“写的人负责复制”换取读的完全无锁,代价写在写的那一侧。
先交代版本差异,否则很容易照着旧文章读错。JDK 8 的 CopyOnWriteArrayList 里锁是一个字段:
Compiled from "CopyOnWriteArrayList.java" // Zulu 8u482,javap -p
public class java.util.concurrent.CopyOnWriteArrayList<E> implements java.util.List<E>, java.util.RandomAccess, java.lang.Cloneable, java.io.Serializable {
private static final long serialVersionUID;
final transient java.util.concurrent.locks.ReentrantLock lock;
private volatile transient java.lang.Object[] array;
private static final sun.misc.Unsafe UNSAFE;
private static final long lockOffset;
final java.lang.Object[] getArray();
final void setArray(java.lang.Object[]);
JDK 25 不是这样。本机 javap -p 的真实输出:
Compiled from "CopyOnWriteArrayList.java" // Temurin 25.0.5+8,javap -p
public class java.util.concurrent.CopyOnWriteArrayList<E> implements java.util.List<E>, java.util.RandomAccess, java.lang.Cloneable, java.io.Serializable {
private static final long serialVersionUID;
private static final java.lang.Object[] EMPTY_ELEMENTDATA;
final transient java.lang.Object lock;
private volatile transient java.lang.Object[] array;
final java.lang.Object[] getArray();
final void setArray(java.lang.Object[]);
lock 的类型从 ReentrantLock 变成了 Object。源码注释把理由写得毫不含糊:
// JDK 25 源码摘录,已删减
/**
* The lock protecting all mutators. (We have a mild preference
* for builtin monitors over ReentrantLock when either will do.)
*/
final transient Object lock = new Object();
“when either will do”——当两者都能胜任时,作者更偏好内置监视器。这句话值得停下来想一想:卷一整章都在讲 AQS 与 ReentrantLock 的能力,为什么这里反而退回 synchronized?因为写路径只需要互斥,不需要公平、不需要可中断、不需要超时、不需要多个条件队列。ReentrantLock 的每个额外能力都是一段要维护的代码和一个额外的对象头;而监视器在无竞争时由 JIT 直接编译成轻量级锁路径,还省掉一个对象。这不是“AQS 不好”,是“不要为用不到的能力付费”。
javap -c 可以直接看到这一点:
public boolean add(E);
Code:
0: aload_0
1: getfield #13 // Field lock:Ljava/lang/Object;
6: monitorenter
7: aload_0
8: invokevirtual #30 // Method getArray:()[Ljava/lang/Object;
16: aload_3
17: iload 4
19: iconst_1
20: iadd
21: invokestatic #120 // Method java/util/Arrays.copyOf:([Ljava/lang/Object;I)[Ljava/lang/Object;
25: aload_3
26: iload 4
28: aload_1
29: aastore
32: invokevirtual #20 // Method setArray:([Ljava/lang/Object;)V
37: monitorexit
一次 monitorenter、一次 Arrays.copyOf、一次 aastore、一次 setArray,就是写时复制的全部。
写路径:复制、改副本、换引用
add 的源码只有六行,但它把整个设计压缩进去了:
// JDK 25 源码摘录,已删减
public boolean add(E e) {
synchronized (lock) {
Object[] es = getArray();
int len = es.length;
es = Arrays.copyOf(es, len + 1);
es[len] = e;
setArray(es);
return true;
}
}
四个动作对应四个必须成立的条件。
第一,getArray() 与 setArray() 都只是普通字段读写,但 array 声明为 volatile。这一次 volatile 写就是发布点:任何线程只要读到新数组引用,就一定能看到 es[len] = e 的写入。这是卷一第 3 章那条 happens-before 规则的最朴素应用,不需要额外的屏障。
第二,复制发生在锁内,发布也发生在锁内。这保证了同时只有一个写者在复制,len 不会读到别人刚改过的长度,也就不存在“两个写者各复制一份、后写的覆盖先写的”的丢更新。
第三,Arrays.copyOf 而不是 clone + 扩容。copyOf 一次分配目标大小的新数组并复制,没有“先扩容再截断”的中间态。set(int, E) 用的却是 clone():长度不变,只需要浅拷贝。
第四,setArray 是无条件的,即使 set 发现新旧元素相同也照样写一次。源码注释解释了原因:// Ensure volatile write semantics even when oldvalue == element。如果跳过这次写,别的线程可能永远看不到这次(逻辑上无变化的)发布,而某些依赖“写一次就可见”的代码会因此卡住。
addIfAbsent 是这里唯一有两段路径的方法,也是 CopyOnWriteArraySet 的唯一入口:
// JDK 25 源码摘录,已删减
public boolean addIfAbsent(E e) {
Object[] snapshot = getArray();
return indexOfRange(e, snapshot, 0, snapshot.length) < 0
&& addIfAbsent(e, snapshot);
}
private boolean addIfAbsent(E e, Object[] snapshot) {
synchronized (lock) {
Object[] current = getArray();
int len = current.length;
if (snapshot != current) {
// Optimize for lost race to another addXXX operation
int common = Math.min(snapshot.length, len);
for (int i = 0; i < common; i++)
if (current[i] != snapshot[i]
&& Objects.equals(e, current[i]))
return false;
if (indexOfRange(e, current, common, len) >= 0)
return false;
}
Object[] newElements = Arrays.copyOf(current, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
}
}
先在锁外拿快照做一次完整扫描——绝大多数“元素已存在”的调用在这里就返回了,完全不进锁。只有扫描结果是“不存在”才进锁,而进锁后必须重新验证:因为刚才那次扫描是在锁外做的,期间别人可能已经把它加进去了。重新验证的技巧在 if (snapshot != current) 这一行。如果数组引用没变,说明没有别人写过,直接追加即可,不用二次扫描。如果变了,也不需要全量重扫:两个数组的前 common 个位置如果引用相同,元素就相同;只需检查引用不同的位置,再加上 current 尾部新增的那一段。这是一次精心设计的“差量校验”,把并发失败的开销从 O(n) 降到 O(变化量)。
CopyOnWriteArraySet 自己几乎没有逻辑。javap -p 只暴露一个字段:
public class java.util.concurrent.CopyOnWriteArraySet<E> extends java.util.AbstractSet<E> implements java.io.Serializable {
private static final long serialVersionUID;
private final java.util.concurrent.CopyOnWriteArrayList<E> al;
public boolean add(E);
public boolean remove(java.lang.Object);
而它的实现就是两行委托:
// JDK 25 源码摘录,已删减
public boolean add(E e) {
return al.addIfAbsent(e);
}
public boolean remove(Object o) {
return al.remove(o);
}
所以这一节剩下来的所有结论,对 Set 都成立,只是 Set 的 add 多了一次 O(n) 的存在性判断——这是下一节要算的账。
读路径:一次 volatile 读,零同步
读的一端短得不像话:
// JDK 25 源码摘录,已删减
public E get(int index) {
return elementAt(getArray(), index);
}
static <E> E elementAt(Object[] a, int index) {
return (E) a[index];
}
public int size() {
return getArray().length;
}
getArray() 读一次 volatile 字段,elementAt 做一次数组下标访问。没有锁、没有 CAS、没有屏障、没有计数器。size() 更直接:数组长度就是大小,O(1),而且天然一致——你不可能读到一个“长度 5 但第 5 个位置是 null”的中间态,因为数组一旦发布就不再修改。
这就是写时复制真正的卖点。不是“读比加锁快一点”,而是读路径上根本不存在共享的可变状态。64 个线程读同一个列表,缓存行的状态是 Shared,不需要任何一致性流量;换成 synchronized 或 ReadWriteLock,每次读都要碰同一个锁字的缓存行,核越多越糟。
本机顺序读的实测(10 万元素,单线程,各取 5 轮最好值):
== 顺序读 ns/次(load average 高,仅作量级参考)
ArrayList 4.83
CopyOnWriteArrayList 4.88
synchronizedList 9.07
CopyOnWriteArrayList 与裸 ArrayList 落在同一档,Collections.synchronizedList 大约贵一倍。采集时本机 uptime 报的 load average 是 89.87 / 109.91 / 111.28(1/5/15 分钟),绝对值不能横向引用,但“COW 读 ≈ 裸数组读、且明显低于加锁读”这个方向是稳定的。多核并发读时差距只会更大,因为加锁版本还要处理缓存行争用。
迭代器是同一套逻辑的延伸:
// JDK 25 源码摘录,已删减
static final class COWIterator<E> implements ListIterator<E> {
/** Snapshot of the array */
private final Object[] snapshot;
/** Index of element to be returned by subsequent call to next. */
private int cursor;
COWIterator(Object[] es, int initialCursor) {
cursor = initialCursor;
snapshot = es;
}
public boolean hasNext() {
return cursor < snapshot.length;
}
public E next() {
if (! hasNext())
throw new NoSuchElementException();
return (E) snapshot[cursor++];
}
public void remove() { throw new UnsupportedOperationException(); }
public void set(E e) { throw new UnsupportedOperationException(); }
public void add(E e) { throw new UnsupportedOperationException(); }
}
snapshot 是 final,cursor 是迭代器私有的。ArrayList 的迭代器需要 modCount 是因为底层数组会被原地修改,它必须靠一个版本号发现“我脚下的数据变了”;这里底层数组不可能变,写操作换的是列表的 array 字段,跟迭代器手里的 snapshot 是两个对象。没有可检测的变化,也就没有 ConcurrentModificationException 可言。
所以在 COW 上可以放心地写 for (Handler h : handlers) { h.handle(event); }——回调里顺手把 h 自己摘掉也不会炸。这是 CopyOnWriteArraySet 最经典的用法:监听器注册表。回调中增删监听器是常态,普通集合会抛 CME,COW 只是让你这一轮看不到新加的那个。
三个 UnsupportedOperationException 不是偷懒,是语义必然:remove 要改的是“当前数组”,而迭代器持有的是历史快照,改了也不会有人看见。与其提供一个会静默失效的方法,不如直接拒绝。
代价:分配量、旧数组与 O(n) 的写
写时复制的账全在写这一侧。下面这个探针用 com.sun.management.ThreadMXBean.getCurrentThreadAllocatedBytes() 统计每次 add 在线程内分配的平均字节数(含随后被丢弃的旧数组)。它是 HotSpot 扩展 API,--release 25 可直接编译:
import com.sun.management.ThreadMXBean;
import java.lang.management.ManagementFactory;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
public final class CowCost {
static final ThreadMXBean TMX = (ThreadMXBean) ManagementFactory.getThreadMXBean();
static long allocPerAdd(List<Object> list, Object[] pool) {
long before = TMX.getCurrentThreadAllocatedBytes();
for (Object o : pool) list.add(o);
return (TMX.getCurrentThreadAllocatedBytes() - before) / pool.length;
}
public static void main(String[] args) {
System.out.println("== 每次 add 的分配字节数(本机,JDK " + Runtime.version() + ")");
for (int n : new int[] {1_000, 5_000, 20_000}) {
Object[] pool = new Object[n];
for (int i = 0; i < n; i++) pool[i] = new Object();
System.out.printf("n=%-6d COWAL %8d B/add ArrayList %6d B/add%n",
n,
allocPerAdd(new CopyOnWriteArrayList<>(), pool),
allocPerAdd(new ArrayList<>(), pool));
}
}
}
本机输出:
== 每次 add 的分配字节数(本机,JDK 25.0.5+8-LTS)
n=1000 COWAL 2020 B/add ArrayList 15 B/add
n=5000 COWAL 10020 B/add ArrayList 15 B/add
n=20000 COWAL 40020 B/add ArrayList 12 B/add
数字非常规整:每次 add 的分配量约等于 2n 字节。推一下就对上了。数组元素是引用,压缩指针下 4 字节;对象头 12 字节加长度 4 字节,共 16 字节,再按 8 字节对齐。追加第 n 个元素要分配长度 n+1 的数组,约 16 + 4(n+1) 字节,向上取整后接近 4n;但这里测出的是 2n,说明测量区间内的平均长度是 n/2——因为是从空表开始连续加到 n,长度取遍 0 到 n,平均值就是 n/2。
换算成总量更直观:连续追加 n 个元素,总分配量是 Σ 4i ≈ 2n² 字节。n=20000 时约 800 MB,n=100000 时约 20 GB。这就是为什么 CopyOnWriteArrayList 只适合小列表 + 低频写:它不是一个“稍慢的 List”,而是一个写复杂度为 O(n)、且每次写都产生 O(n) 垃圾的结构。
同样重要的是旧数组的命运。每次写之后,上一个数组立刻变成垃圾;如果此时还有线程在遍历,它手上的快照是活的,这块内存要等遍历结束才能回收。列表越大、遍历越久,浮动垃圾越多。一次 10 万元素的写会产生约 400 KB 的即时垃圾,年轻代很快被填满,晋升与 Full GC 的压力都随之上升。这是“读无锁”的对价,不能只算读的那本账。
Set 的账更重。addIfAbsent 每次都要线性扫描,用计数 equals 的探针实测:
== 插入 N 个互不相同的元素,equals 调用次数
n=500 CopyOnWriteArraySet 124750 次 HashSet 0 次
n=1000 CopyOnWriteArraySet 499500 次 HashSet 0 次
n=2000 CopyOnWriteArraySet 1999000 次 HashSet 0 次
124750 正好是 500×499/2,499500 是 1000×999/2,1999000 是 2000×1999/2。插入 n 个互异元素共需 n(n-1)/2 次比较,平方级。HashSet 那 0 次是因为元素各自独立、哈希不冲突,根本不需要 equals;这不是说 HashSet 不做比较,而是说它的比较次数与规模无关。
所以判断能不能用 COW,有一个粗口径:写操作次数 × 当前规模 要远小于读操作次数。实践中更实用的经验值是元素个数保持在几十到几百、写频率低于每分钟一次。超过这个量级,ConcurrentHashMap 的 newKeySet() 几乎总是更好的选择。
什么时候明确不要用?四种情况:一,写频率接近或超过读频率(计数器、缓存、会话表);二,列表规模会随业务增长到几千以上;三,需要按索引频繁删除、插入(每次都是全量复制);四,元素本身很大且需要频繁更新字段——注意 COW 只保护“引用数组”,元素对象内部的可变字段它一概不管,那需要另外的同步手段。
在 libraryd-conc 里落地
libraryd-conc 有一个天然的 COW 场景:通知扇出。conc-core 要把借阅成功事件同时发给 mail、sms、push 三个下游,下游列表在启动后基本不变,但运维需要能在运行期上下线某一个通道;同时每个借阅请求都要遍历这个列表,属于典型的“读极多、写极少”。
package com.plume.conc.core;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
/** 下游通知通道注册表:读多写极少,允许回调中增删。 */
public final class DownstreamRegistry {
/** 元素是不可变 record,注册表只负责“有哪些通道”。 */
public record Downstream(String name, int weight) {
public Downstream {
if (name == null || name.isBlank()) throw new IllegalArgumentException("name");
if (weight <= 0) throw new IllegalArgumentException("weight");
}
}
private final CopyOnWriteArrayList<Downstream> channels =
new CopyOnWriteArrayList<>(List.of(
new Downstream("mail", 1),
new Downstream("sms", 1),
new Downstream("push", 1)));
/** 热路径:无锁遍历,返回的是调用瞬间的快照。 */
public void fanout(Loan loan, ChannelSender sender) {
for (Downstream d : channels) sender.send(d, loan); // 允许回调 add/remove
}
/** 运维操作:幂等注册;写时复制,代价与通道数成正比。 */
public boolean register(Downstream d) { return channels.addIfAbsent(d); }
public boolean unregister(String name) {
return channels.removeIf(d -> d.name().equals(name));
}
/** 只读快照,给管理端点用。 */
public List<Downstream> snapshot() { return List.copyOf(channels); }
}
三个要点。fanout 里没有任何同步,注释也说明了它可以容忍回调中修改注册表——这正是 COW 换来的东西;如果换成 ArrayList 加锁,回调里 unregister 立刻抛 CME。register 用 addIfAbsent 而不是先 contains 再 add,因为后者是两次独立操作,中间可能被别人插进来(卷三第 2 章会展开这类复合操作的事故)。snapshot() 用 List.copyOf 再拷一次,是为了把“快照”这件事显式化:调用方拿到的不是活引用。
但同一个 conc-core 里的 ISBN 索引不能这么写。索引条目随库存变化而更新,一次借还就是一次写,规模上万,用 COW 的分配量会是每次上百 KB——6.3 会用实测数据把这条改造讲完。
常见错误与边界
第一,把 COW 当“线程安全的 ArrayList”通用替代品。它是为“读远多于写”设计的;写频繁时性能会塌方,而且是 O(n) 塌方,不是常数倍。
第二,以为元素也被复制了。array 里存的是引用,Arrays.copyOf 是浅拷贝。两个线程同时改同一个元素对象的字段,COW 一点都不管。要安全就让元素不可变(如 record),或者元素内部自己保证线程安全。
第三,用 subList 然后期待它和迭代器一样免于 CME。恰恰相反:COWSubList 持有 expectedArray,任何 get/size/add 前都调 checkForComodification(),只要底层数组被换过就抛 ConcurrentModificationException。顶层迭代器不抛、子列表抛,这个不对称经常让人困惑。
第四,在 COW 上做“先查后改”的复合操作。if (!list.contains(x)) list.add(x) 两步之间别人可能已经加了。用 addIfAbsent;删改类操作用 removeIf、replaceAll 这类一次性方法。
第五,忽略 size() 的快照性。两次 size() 之间可能有写入,拿它做循环上界去 get(i),在列表被缩短时会抛 IndexOutOfBoundsException。要么先取一次快照,要么直接用迭代器。
第六,以为 addLast 是一次加锁。源码里 addLast 外层 synchronized (lock) 里又调了会再次加锁的 add——监视器可重入,所以正确,但也说明“看起来原子的方法”内部可能有嵌套加锁。读源码时要看完整调用链,不要只看一层。
小结
CopyOnWriteArrayList 用一把 Object 监视器保护写、用 Arrays.copyOf 造新数组、用一次 volatile 写发布,读路径因此退化成“读一个字段 + 数组下标”。迭代器持有 final 快照,所以不可能抛 CME,也不可能支持 remove/set/add。代价是每次写分配约 2n 字节、总分配 O(n²),以及旧数组变成浮动垃圾。JDK 25 把 JDK 8 的 ReentrantLock 换成了内置监视器,理由是“两者都能胜任时偏好监视器”——这句话本身就是选型课:能力用不上就不要付钱。
下一节看另一种极端:既要并发写,又要全局有序,JDK 给出的答案是跳表而不是红黑树。
阅读导航:上一节 5.3 弱一致迭代与复合操作 | 下一节 6.2 ConcurrentSkipListMap
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。