CopyOnWriteArrayList 的读写分离,更新开销有多大
「CopyOnWriteArrayList 的读写分离,更新开销有多大」
去年排查一个线上 OOM 问题时,堆栈里反复出现 java.util.concurrent.CopyOnWriteArrayList 的 add 方法,这让我开始认真审视这个类的真实代价。之前我对它的印象停留在"读不加锁、写复制一份"这个层面,觉得是个典型的空间换时间设计。但那个案例里,一个后台任务每 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。测试场景很简单:分别用 ArrayList、Vector、CopyOnWriteArrayList 做 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.addAll 和 COWList.addAll 在批量场景下的表现。ArrayList 会预扩容(grow(minCapacity)),通常只需要一次数组分配,复制也只发生一次。COWList 同样是一次分配一次复制,但 ArrayList 的扩容策略是 1.5 倍,预留了空间给后续添加,而 COWList 永远是精确长度,下次 add 又要重新复制。
更隐蔽的是 removeAll 和 retainAll。removeAll 的实现是遍历原数组,把不需要移除的元素收集到新数组,然后原子替换。这意味着即使只移除 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 的写这么重,那用无锁队列呢?ConcurrentLinkedQueue 的 add 和 offer 都是 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 的源码里,SnapshotStateList 的 add 操作:
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 写的开销,即使"没有变化"。
对比 AtomicReferenceArray 的 set(int i, E newValue),它直接对数组元素做 volatile store,不需要复制整个数组。但 AtomicReferenceArray 不支持动态扩容,且 get 也是 volatile load,读开销比 COWList 的普通数组元素读取更高。这两个类的设计目标完全不同,不能简单互换,但理解它们的差异有助于选择正确的工具。
性能数据的再审视
前面提到的 JMH 基准,需要补充一些约束条件。45ms 的 COWList add 10000 次,是在单线程、无竞争、冷启动(JVM 未预热)的环境下测的。实际应用中的数字会不同:
ReentrantLock 的排队和上下文切换会进一步拖慢写操作我在 OpenJDK 17 上重复了测试,-XX:+UseZGC,同样的 10000 次 add,COWList 降到约 38ms。ZGC 的并发回收和染色指针减少了 GC 暂停,但数组分配和复制的 CPU 开销不变。ArrayList 则因为 JVM 整体优化更好,降到了 210μs,差距拉到 180 倍。
另一个角度:如果列表初始容量已知,COWList 没有 ensureCapacity 或带初始容量的构造来避免复制——它只有一个无参构造和一个基于现有数组的构造。这意味着从空列表开始增长,必然经历所有中间长度的复制。而 ArrayList 的 new 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) 但可能阻塞,需要测量具体场景下的吞吐和延迟分布。没有银弹,只有数据。