CopyOnWriteArrayList 的读写分离,更新开销有多大

CopyOnWriteArrayList 的读写分离,更新开销有多大

CopyOnWriteArrayList 的读写分离,更新开销有多大


「CopyOnWriteArrayList 的读写分离,更新开销有多大」


去年排查一个线上 OOM 问题时,堆栈里反复出现 java.util.concurrent.CopyOnWriteArrayListadd 方法,这让我开始认真审视这个类的真实代价。之前我对它的印象停留在"读不加锁、写复制一份"这个层面,觉得是个典型的空间换时间设计。但那个案例里,一个后台任务每 5 秒往 COWList 里追加元素,运行两天后内存暴涨到接近 1GB,而元素总量其实只有几千个。问题出在写操作的复制开销被严重低估了,更麻烦的是,这个类的设计让内存碎片和 GC 压力变得很难预测。


从一次 add 操作看内存分配


先打开 OpenJDK 8 的源码,定位到 CopyOnWriteArrayList.add(E e) 的实现:


public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] elements = getArray();
        int len = elements.length;
        Object[] newElements = Arrays.copyOf(elements, len + 1);
        newElements[len] = e;
        setArray(newElements);
        return true;
    } finally {
        lock.unlock();
    }
}

Arrays.copyOf 内部调用的是 System.arraycopy,这是 native 方法,但语义上就是完整分配新数组 + 全量复制。假设当前数组有 1000 个引用,add 一次要分配 1001 长度的 Object[],复制 1000 个引用,再把旧数组设为可回收。引用复制本身很快,但数组对象在堆上的分配是实打实的。


我用 JMH 跑了个基准测试,OpenJDK 8u372,G1GC,-Xms2g -Xmx2g。测试场景很简单:分别用 ArrayListVectorCopyOnWriteArrayList 做 10000 次 add 操作,元素是空的 Object()


@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Thread)
public class COWAddBenchmark {
    @Benchmark
    public void testCOWAdd(Blackhole bh) {
        CopyOnWriteArrayList<Object> list = new CopyOnWriteArrayList<>();
        for (int i = 0; i < 10000; i++) {
            list.add(new Object());
        }
        bh.consume(list);
    }
}

结果:ArrayList 平均 280μs,Vector 约 420μs(synchronized 开销),CopyOnWriteArrayList 直接飙到 45ms 左右,慢了 160 倍。这个数字本身不意外,毕竟每次 add 都复制整个数组。但换个角度想:如果读操作占 99%,写只占 1%,这个代价是否值得?


读操作的"无锁"真相


COWList 的读方法确实没有显式锁。get(int index) 直接读 volatile 数组引用:


public E get(int index) {
    return get(getArray(), index);
}

@SuppressWarnings("unchecked")
private E get(Object[] a, int index) {
    return (E) a[index];
}

final Object[] getArray() {
    return array;
}

array 字段是 volatile 的,这保证了读线程能看到最新的数组引用。但注意,这里没有任何 happens-before 关系保证数组元素的可见性——Java 内存模型中,数组元素的写入并不自动获得 volatile 语义。COWList 的解决方案是:写操作通过 setArray(newElements) 原子地替换整个数组引用,而读操作拿到的是完整的数组快照。


这意味着读线程永远不会看到中间状态(比如写到一半的数组),这是 COWList 的核心保证。但"无锁"不等于"零开销"。volatile 读在 x86 上确实有不错的性能(直接走缓存,不需要内存屏障),但数组引用的频繁变更会导致缓存失效。更关键的是,如果写操作很频繁,读线程每次拿到的都是不同的数组对象,CPU 缓存命中率会下降。


我实际测试了一个场景:10 个读线程持续遍历列表,1 个写线程每 100ms add 一个元素。用 perf stat 看缓存未命中情况,COWList 的 L1 cache miss 比 Collections.synchronizedList(new ArrayList<>()) 高出约 40%。 synchronizedList 的读需要获取锁,但锁竞争不激烈时(读多写少且写频率低),偏向锁或轻量级锁的开销其实很小,而数组对象的稳定性让缓存更友好。


迭代器的隐藏陷阱


COWList 的迭代器设计是另一个容易踩坑的地方。它的 iterator() 返回的是基于当前数组快照的迭代器:


public Iterator<E> iterator() {
    return new COWIterator<E>(getArray(), 0);
}

private static class COWIterator<E> implements ListIterator<E> {
    private final Object[] snapshot;
    private int cursor;
    
    private COWIterator(Object[] elements, int initialCursor) {
        cursor = initialCursor;
        snapshot = elements;
    }
}

迭代器持有 snapshot 引用,这意味着即使原始列表被清空,这个数组对象也无法被回收,直到迭代器本身被回收。我遇到的那个 OOM 案例,本质上就是迭代器生命周期过长导致的。


具体场景:一个 WebSocket 连接管理器用 COWList 存储活跃连接,每个连接断开时从列表移除。同时有个定时任务遍历列表发送心跳。心跳任务用的是 for (Connection c : connections) 这种增强 for 循环,编译后就是创建 Iterator。问题在于,这个迭代器在循环结束后如果还在栈上存活(比如后续有大段代码),或者更常见的情况——在异步回调里被闭包引用——就会一直持有那个快照数组。


线上 dump 的堆文件显示,某个迭代器实例持有一个 32768 长度的 Object[],而当前列表实际只有 12 个元素。这是历史写操作留下的快照:列表曾经增长到 3 万多个连接,后来大部分断开,但某个迭代器卡在了旧的快照上。G1 的 region 大小是 2MB,这个数组本身约 256KB(引用数组,8 字节 * 32768 / 压缩 oops),不算巨大,但类似的泄漏对象有几百个,加起来就很可观。


修复方案是显式用索引遍历,避免创建 Iterator:


for (int i = 0, size = connections.size(); i < size; i++) {
    Connection c = connections.get(i);
    // ...
}

但这也只是缓解。索引遍历在 COWList 上同样面临快照问题:size()get(i) 是两次独立的 volatile 读,中间如果有写操作,可能读到不一致的状态。虽然对于心跳这种"尽力而为"的场景可以接受,但严格来说已经破坏了遍历的一致性。


批量写的性能悬崖


COWList 提供了 addAll(Collection<? extends E> c) 方法,实现上比单个 add 聪明一点:


public boolean addAll(Collection<? extends E> c) {
    Object[] cs = (c.getClass() == CopyOnWriteArrayList.class) ?
        ((CopyOnWriteArrayList<?>)c).getArray() : c.toArray();
    // ... 加锁,复制,合并
}

但核心逻辑仍然是 Arrays.copyOf(elements, len + cs.length) 加上 System.arraycopy(cs, 0, newElements, len, cs.length)。如果批量添加 1000 个元素到已有 10000 个元素的列表,就是分配 11000 的数组,复制 10000 + 1000。


我对比了 ArrayList.addAllCOWList.addAll 在批量场景下的表现。ArrayList 会预扩容(grow(minCapacity)),通常只需要一次数组分配,复制也只发生一次。COWList 同样是一次分配一次复制,但 ArrayList 的扩容策略是 1.5 倍,预留了空间给后续添加,而 COWList 永远是精确长度,下次 add 又要重新复制。


更隐蔽的是 removeAllretainAllremoveAll 的实现是遍历原数组,把不需要移除的元素收集到新数组,然后原子替换。这意味着即使只移除 1 个元素,也要复制 N-1 个引用。如果列表有 10 万个元素,remove 1 个的代价和 remove 9 万个差不多。


public boolean removeAll(Collection<?> c) {
    // ... 加锁
    Object[] elements = getArray();
    int len = elements.length;
    if (len != 0) {
        int newlen = 0;
        Object[] temp = new Object[len];
        for (int i = 0; i < len; ++i) {
            Object element = elements[i];
            if (!c.contains(element))
                temp[newlen++] = element;
        }
        if (newlen != len) {
            setArray(Arrays.copyOf(temp, newlen));
            return true;
        }
    }
    return false;
}

注意这里的 temp 是精确 len 长度,最后还要 Arrays.copyOf 截断到 newlen。两次数组分配,两次复制。对比 ArrayList.removeAll,它用的是 batchRemove,直接在原数组上双指针压缩,最后 elementData = Arrays.copyOf(elementData, size) 只截断一次。ArrayList 的批量删除明显更高效,但代价是操作期间列表处于不一致状态,需要外部同步。


真实场景中的替代方案


回到那个 OOM 案例,最终的解决方案是换掉 CopyOnWriteArrayList。评估了几个替代方案:


方案一:`ReadWriteLock` + `ArrayList`


private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
private final List<Connection> list = new ArrayList<>();

public void add(Connection c) {
    rwLock.writeLock().lock();
    try {
        list.add(c);
    } finally {
        rwLock.writeLock().unlock();
    }
}

public List<Connection> snapshot() {
    rwLock.readLock().lock();
    try {
        return new ArrayList<>(list);
    } finally {
        rwLock.readLock().unlock();
    }
}

读锁支持并发读,写锁独占。但 ReadWriteLock 在 Java 8 的实现有公平性问题:写锁饥饿。如果读线程持续不断,写线程可能长期拿不到锁。Java 8 的 ReentrantReadWriteLock 默认是非公平模式,写锁插队需要等待当前读锁全部释放,高并发读场景下写延迟不可控。Java 17 的 StampedLock 乐观读可以部分缓解,但代码复杂度上升不少。


方案二:`Collections.synchronizedList` + 手动快照


这是实际采用的方案。写操作直接 synchronized,读操作(心跳遍历)在同步块内创建 ArrayList 副本,然后遍历副本。副本创建是 O(n),但心跳频率是 5 秒一次,n 是连接数(通常几百到几千),完全可以接受。关键是副本创建后立刻释放锁,遍历期间不持有任何锁,也不影响原列表的修改。


private final List<Connection> list = Collections.synchronizedList(new ArrayList<>());

public void broadcastHeartbeat() {
    List<Connection> snapshot;
    synchronized (list) {
        snapshot = new ArrayList<>(list);
    }
    for (Connection c : snapshot) {
        c.sendHeartbeat();
    }
}

这个方案的读延迟是 O(n) 的副本创建,但写操作是 O(1)(均摊),整体更符合那个场景的实际负载特征。COWList 的 O(n) 写 + 快照泄漏风险,在这个高频写(连接上下线频繁)、低频批量读(定时心跳)的场景下反而是劣势。


方案三:并发容器分段


如果连接数真的很大(十万级),可以考虑 ConcurrentHashMap.newKeySet() 或者自定义分段锁。但对于几百到几千的量级,过度设计没必要。


与 `ConcurrentLinkedQueue` 的对比


有人可能会想,既然 COWList 的写这么重,那用无锁队列呢?ConcurrentLinkedQueueaddoffer 都是 CAS 操作,理论上写性能很好。但队列的遍历是弱一致性迭代器,且不支持随机访问。如果业务需要 get(index) 或者按索引删除,队列就不合适。


我测试了用 ConcurrentLinkedQueue 存储连接,遍历用 iterator() 的场景。CLQ 的迭代器在遍历期间允许并发修改,但可能反映也可能不反映修改,这取决于竞争时序。对于心跳场景,漏掉一个新连接或者多发给一个旧连接,通常可接受。但 size() 方法是 O(n) 的遍历计数,这个很多人不知道:


public int size() {
    int count = 0;
    for (Node<E> p = first(); p != null; p = succ(p))
        if (p.item != null)
            ++count;
    return count;
}

如果代码里依赖 size() 做分页或限流,CLQ 就是个陷阱。COWList 的 size() 是 O(1) 的数组长度读取,这点确实更友好。


Android 上的特殊考量


回到 Android 场景,ART 和 HotSpot 的 GC 行为有差异。Android 8.0 之前主要是 Dalvik 或 ART 的 CMS-like 并发标记,Android 8.0 开始逐步切换到 Concurrent Copying(CC)收集器,Android 10 默认启用。CC 收集器的特点是堆分代,年轻代用 copying GC,老年代用 concurrent marking + compaction。


COWList 的频繁大数组分配,在 CC 收集器下会加速年轻代耗尽。一个 Object[] 的分配,如果长度超过一定阈值(通常是几千到几万,取决于 heap config),可能直接进老年代。老年代的 concurrent copying 需要暂停线程来做 compaction,大数组对象的移动成本更高。


我在 Android 10 设备(Pixel 3)上跑了个测试:用 COWList 做 1000 次 add,每次 add 后列表长度 +1,观察 GC 日志。adb logcat -s AndroidRuntime 里看到两次 GC,而同样操作的 ArrayList 零次 GC。因为 COWList 产生了 1000 个不同大小的废弃数组,从 0 到 999,这些数组在年轻代里占用了大量空间,触发了 minor GC。ArrayList 的扩容策略是 10 -> 15 -> 22 -> 33 -> 50 -> 75 -> 113 -> 170 -> 255 -> 383 -> 575 -> 863 -> 1295,总共 12 次数组分配,且旧数组在扩容后很快不可达,GC 压力小得多。


更麻烦的是 Android 的 low-memory killer 机制。Java heap 的 GC 压力会间接影响进程的 adj 值和被杀优先级。一个后台服务如果持续产生 COWList 的内存抖动,可能被系统判定为内存敏感,在内存紧张时优先回收。


Kotlin 的 `SnapshotStateList` 借鉴


Jetpack Compose 的 SnapshotStateList 内部也用了类似的 copy-on-write 思想,但实现更精细。它维护了一个 StateRecord 链表,写操作创建新的 record 并尝试提交,读操作基于当前 snapshot 的 record 读取。和 COWList 的全量数组复制不同,SnapshotStateList 的"复制"是结构共享的持久化数据结构风格,修改时只复制受影响的路径。


Compose 运行时版本 1.4.0 的源码里,SnapshotStateListadd 操作:


override fun add(element: T): Boolean {
    return mutateBoolean {
        val list = readable.list
        val newList = list + element
        if (list == newList) false
        else {
            writable.list = newList
            true
        }
    }
}

这里的 list + element 在 Kotlin 的不可变列表实现中,对于小列表可能是全量复制,但对于某些底层结构(如 PersistentList)可能是结构共享。Compose 的 snapshot 系统还处理了多线程和重组的协调,比 COWList 的单纯数组替换复杂得多。


但这也说明,copy-on-write 作为一种思想,具体实现可以有很大差异。COWList 的"简单粗暴"全量复制,在 JDK 的通用容器场景下是合理权衡——实现简单,无外部依赖,读性能极致。但如果你的场景需要更细粒度的控制,或者写频率并非极低,就需要自己实现或寻找替代。


一个被忽略的细节:`set` 操作的语义


COWList 的 set(int index, E element) 同样要复制整个数组:


public E set(int index, E element) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] elements = getArray();
        E oldValue = get(elements, index);
        if (oldValue != element) {
            int len = elements.length;
            Object[] newElements = Arrays.copyOf(elements, len);
            newElements[index] = element;
            setArray(newElements);
        } else {
            setArray(elements); // still volatile write for visibility
        }
        return oldValue;
    } finally {
        lock.unlock();
    }
}

注意那个 else 分支:即使新值和旧值是同一个引用,也要执行 setArray(elements),做一次 volatile write。这是为了保证元素字段的可见性——如果调用者刚修改了 element 的内部状态,然后 set 同一个引用,需要让其他线程看到这个修改。这个设计在 Java 内存模型下是必要的,但代价是每次 set 都有 volatile 写的开销,即使"没有变化"。


对比 AtomicReferenceArrayset(int i, E newValue),它直接对数组元素做 volatile store,不需要复制整个数组。但 AtomicReferenceArray 不支持动态扩容,且 get 也是 volatile load,读开销比 COWList 的普通数组元素读取更高。这两个类的设计目标完全不同,不能简单互换,但理解它们的差异有助于选择正确的工具。


性能数据的再审视


前面提到的 JMH 基准,需要补充一些约束条件。45ms 的 COWList add 10000 次,是在单线程、无竞争、冷启动(JVM 未预热)的环境下测的。实际应用中的数字会不同:


  • 多线程写竞争时,ReentrantLock 的排队和上下文切换会进一步拖慢写操作
  • JVM 预热后,数组分配的 TLAB(Thread Local Allocation Buffer)优化会让小数组分配更快,但大数组仍要走共享 Eden 区
  • G1 的 region 大小和 IHOP(Initiating Heap Occupancy Percent)会影响大数组的晋升和老年代 GC 频率

  • 我在 OpenJDK 17 上重复了测试,-XX:+UseZGC,同样的 10000 次 add,COWList 降到约 38ms。ZGC 的并发回收和染色指针减少了 GC 暂停,但数组分配和复制的 CPU 开销不变。ArrayList 则因为 JVM 整体优化更好,降到了 210μs,差距拉到 180 倍。


    另一个角度:如果列表初始容量已知,COWList 没有 ensureCapacity 或带初始容量的构造来避免复制——它只有一个无参构造和一个基于现有数组的构造。这意味着从空列表开始增长,必然经历所有中间长度的复制。而 ArrayListnew ArrayList<>(expectedSize) 可以预分配,避免扩容复制。


    最后的踩坑:序列化与克隆


    COWList 实现了 Serializable,但序列化时会写入整个数组。如果列表曾经很大然后缩小,序列化后的体积仍可能包含历史膨胀的数组——不对,实际上 writeObject 写的是当前 array 引用指向的数组,也就是精确当前长度。但前面提到的迭代器快照泄漏,会导致旧数组无法被回收,间接造成内存占用。


    clone() 方法返回新的 COWList,但共享同一个数组引用:


    public Object clone() {
        try {
            CopyOnWriteArrayList<?> clone = (CopyOnWriteArrayList<?>) super.clone();
            clone.resetLock();
            return clone;
        } catch (CloneNotSupportedException e) {
            throw new InternalError();
        }
    }

    resetLock() 创建新的 ReentrantLock,但 array 字段是 volatile 且直接赋值(Object[] elements = getArray() 然后 setArray(elements) 在构造里),clone 后和原列表共享数组。写操作时各自复制,所以是 copy-on-write 的语义正确实现,但初始状态是共享引用。这个细节通常无害,但如果误用 clone 来做防御性拷贝,然后修改原列表,会发现 clone 不受影响——这是正确的行为,但和 ArrayList.clone() 的全量复制直觉不同。


    ArrayList.clone()new ArrayList<>(this),立即复制底层数组。COWList 的 clone 延迟到写操作时才复制,这符合其设计哲学,但文档里没有显式强调,容易误解。


    什么时候真的该用 COWList


    写了这么多问题,最后公平地说一下 COWList 的适用场景。它的核心价值是遍历的绝对安全性:迭代器不会抛出 ConcurrentModificationException,也不会看到模糊状态。这在事件监听列表场景特别有用。


    Android 的 View 系统里,旧版的 mOnClickListeners 等其实没用 COWList(用的是 CopyOnWriteArray 或普通数组加锁),但很多 GUI 框架确实采用类似模式。Java 的 javax.swing.event.EventListenerList 是另一种实现:用数组存储 listener,添加时复制数组,移除时标记为 null 后延迟清理。这和 COWList 的"全量复制"不同,是"写时复制 + 惰性删除"的混合。


    如果你的场景满足:

  • 写操作极少(比如配置变更时更新,运行期几乎只读)
  • 遍历操作需要强一致性快照(不能看到中间状态,不能失败)
  • 列表规模很小(几十到几百,复制成本可忽略)
  • 没有长时间存活迭代器的风险

  • 那 COWList 是合理选择。但对于"读多写少"中的"少"是每秒几次甚至更高的频率,或者列表规模上万,就需要重新评估。那个 OOM 案例的教训是:写频率 0.2Hz(5秒一次)在 48 小时运行后,累积的内存分配和快照泄漏就足以拖垮服务。这个"少"是相对读而言,但绝对频率并不低。


    更根本的是,COWList 的设计假设是"写操作代价高但可以接受,因为读操作极其频繁且要求无锁"。但现代 CPU 上,轻量级锁的获取释放代价已经很低,而缓存失效和内存分配的真实代价常被低估。 synchronized 的偏向锁、轻量级锁、重量级锁三级升级,在竞争不激烈时可能就是几个 CAS 指令,比分配新数组 + 全量复制要轻。这个假设在 2004 年 JSR 166 引入 COWList 时可能更成立,当时的 JVM 锁优化还不如今天成熟。


    实际选型时,建议用 JMH 或 Android Profiler 跑自己的负载模式,而不是凭"读多写少"的直觉。COWList 的写代价是 O(n) 且常数因子不小,这个 n 随列表增长而增长,是动态变化的。而锁方案的读代价是 O(1) 但可能阻塞,需要测量具体场景下的吞吐和延迟分布。没有银弹,只有数据。

    ProfileInstaller 的 Baseline Profile 分发,Play Store 之外怎么发 2026-06-24
    NDK 开发的 CMake 配置模板分享 2026-06-25

    评论区