《Java 并发编程·JUC 源码与容器》6.1 CopyOnWriteArrayList/Set

以 JDK 25 源码拆开 CopyOnWriteArrayList 的写时复制:锁字段为何从 ReentrantLock 换成 Object 监视器、add 的复制路径、读为何无锁,并用本机分配计数给出每次写的字节数与 addIfAbsent 的比较次数。

承接的问题

第 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

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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