CopyOnWriteArrayList的代价:读高效但写的时候究竟复制了多少
CopyOnWriteArrayList的代价:读高效但写的时候究竟复制了多少
适读人群:Java并发容器使用者、关心内存和GC的后端工程师 | 阅读时长:约14分钟
开篇故事
2020年,我做一个配置中心的本地缓存设计。配置项大概有几百个,需要多线程并发读(每秒几万次),但更新很少(秒级到分钟级一次)。
最初用了HashMap + ReadWriteLock。同事小李建议换成CopyOnWriteArrayList——他说"读不加锁,性能好很多"。
我当时没多想,直接换了。压测读性能确实很好。但没过多久,监控发现Full GC频率明显升高,从原来的一天一次变成了每小时几次,每次GC暂停约200ms。
排查下来,原因是:
- 配置中心会订阅多个配置变更,我们有大约30个
CopyOnWriteArrayList - 每个列表有约200个元素,每个元素是个包含10个字段的Config对象
- 配置变更时,每次write操作都会复制整个数组——200个Object引用的数组
- 30个列表,每秒可能有5-10次配置更新,每次产生一个新数组对象
问题不在单个复制的大小,而在于复制频率 × 数量累积导致的GC压力。
最终换回了ConcurrentHashMap + 不可变Value对象,问题解决。
这件事让我真正理解了CopyOnWriteArrayList的"代价"。
一、CopyOnWriteArrayList的核心设计
1.1 读写分离的实现
CopyOnWriteArrayList的核心思路:
- 读操作:无锁,直接读取当前数组引用(
volatile Object[] array),读期间不会被write影响 - 写操作:加锁(
ReentrantLock),复制当前数组创建新数组,修改新数组,再用volatile写替换引用
// JDK 11 CopyOnWriteArrayList核心代码(简化)
public class CopyOnWriteArrayList<E> {
final transient Object lock = new Object();
private transient volatile Object[] array; // volatile!
// 读:无锁
public E get(int index) {
return elementAt(getArray(), index);
}
// 写:加锁+复制
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); // volatile写,对所有读可见
return true;
}
}
}1.2 volatile数组引用的内存语义
array字段是volatile的,这确保了:
- 写线程
setArray(newArray)(volatile写)happens-before 读线程getArray()(volatile读) - 读线程总能看到最新的数组引用
- 但注意:读线程在拿到array引用后,后续操作的是这个"快照",不受后续写操作影响
迭代的一致性: CopyOnWriteArrayList的迭代器在创建时拿到当前数组的快照,整个迭代过程中数组不会变化(即使写线程修改了列表)。这是它与ArrayList使用Iterator时的ConcurrentModificationException的本质区别。
二、写操作究竟复制了多少
2.1 复制成本的量化分析
设列表有N个元素,每个元素引用大小为4字节(开启指针压缩):
- 每次写操作分配新数组:
N * 4 + 16字节(16字节是数组对象头) - 旧数组成为垃圾:
N * 4 + 16字节进入GC管理
如果N=1000,每次写分配约4KB数组; 如果N=100000,每次写分配约400KB数组;
当写操作频繁时(如每秒100次),N=1000的列表每秒产生400KB垃圾;N=100000每秒产生40MB垃圾,这对GC来说相当可观。
2.2 复制的是引用,不是对象
重要理解:Arrays.copyOf复制的是对象引用,不是对象本身(浅拷贝)。如果列表里存的是可变对象,读线程拿到旧数组快照后,通过引用修改对象的字段,写线程也能看到(因为指向同一个对象)。
这意味着:如果你想用COW实现真正的隔离(读线程和写线程看到的对象完全独立),存入的元素必须是不可变对象(Immutable)。
三、完整代码实现
3.1 CopyOnWriteArrayList基本使用与迭代一致性验证
package com.laozhang.concurrent.collection;
import java.util.Iterator;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
/**
* CopyOnWriteArrayList核心特性验证
*
* 测试点:
* 1. 迭代器快照语义(迭代期间的修改不可见)
* 2. 不抛ConcurrentModificationException
* 3. 写操作的数组复制可观察
*
* 测试环境:JDK 11
*/
public class CopyOnWriteDemo {
public static void main(String[] args) throws InterruptedException {
// === 测试1:迭代器快照语义 ===
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
list.add("A");
list.add("B");
list.add("C");
// 获取迭代器(此时拿到快照:[A, B, C])
Iterator<String> it = list.iterator();
// 在另一个线程里修改列表
list.add("D");
list.add("E");
list.remove("A");
System.out.println("迭代器快照中的内容(不包含后来的修改):");
while (it.hasNext()) {
System.out.print(it.next() + " ");
}
System.out.println();
System.out.println("当前列表内容:" + list);
// 预期:迭代器输出 A B C,当前列表输出 [B, C, D, E]
// === 测试2:并发读写不会ConcurrentModificationException ===
CopyOnWriteArrayList<Integer> concurrentList = new CopyOnWriteArrayList<>();
for (int i = 0; i < 100; i++) concurrentList.add(i);
CountDownLatch latch = new CountDownLatch(2);
// 读线程:持续迭代
Thread reader = new Thread(() -> {
for (int round = 0; round < 10; round++) {
int sum = 0;
for (Integer v : concurrentList) { // 不会CME
sum += v;
try { Thread.sleep(1); } catch (InterruptedException e) {}
}
System.out.println("读线程第" + (round+1) + "轮,sum=" + sum);
}
latch.countDown();
}, "reader");
// 写线程:并发修改
Thread writer = new Thread(() -> {
for (int i = 100; i < 110; i++) {
concurrentList.add(i);
try { Thread.sleep(15); } catch (InterruptedException e) {}
}
latch.countDown();
}, "writer");
reader.start();
writer.start();
latch.await();
System.out.println("最终列表大小:" + concurrentList.size());
}
}3.2 性能对比:CopyOnWriteArrayList vs ReadWriteLock ArrayList
package com.laozhang.concurrent.collection;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* 性能对比:CopyOnWriteArrayList vs ReadWriteLock + ArrayList
*
* 测试场景1:读多写少(读:写 = 100:1)→ 预期COW更好
* 测试场景2:读写均衡(读:写 = 1:1)→ 预期ReadWriteLock更好
* 测试场景3:写多读少(读:写 = 1:10)→ 预期ReadWriteLock明显更好
*
* 测试环境:JDK 11,8核机器,预热后测试
* 结果(8读线程 + 2写线程,各执行100万次,列表大小1000):
* 读多写少:COW ~850ms,RWL ~1100ms(COW快约25%)
* 读写均衡:COW ~2800ms,RWL ~1300ms(RWL快约53%)
* 写多读少:COW ~9500ms,RWL ~1800ms(RWL快约81%)
*/
public class COWPerformanceTest {
private static final int LIST_SIZE = 1000;
private static final int OPERATIONS = 1_000_000;
// 测试工具方法
private static long testCOW(int readerCount, int writerCount, int readOps, int writeOps)
throws InterruptedException {
CopyOnWriteArrayList<Integer> cow = new CopyOnWriteArrayList<>();
for (int i = 0; i < LIST_SIZE; i++) cow.add(i);
CountDownLatch latch = new CountDownLatch(readerCount + writerCount);
long start = System.currentTimeMillis();
for (int i = 0; i < readerCount; i++) {
new Thread(() -> {
for (int op = 0; op < readOps; op++) {
cow.get(op % LIST_SIZE); // 读操作
}
latch.countDown();
}).start();
}
for (int i = 0; i < writerCount; i++) {
final int threadId = i;
new Thread(() -> {
for (int op = 0; op < writeOps; op++) {
// set操作(不改变大小,避免数组grow的额外开销)
cow.set(op % LIST_SIZE, threadId * 10000 + op);
}
latch.countDown();
}).start();
}
latch.await();
return System.currentTimeMillis() - start;
}
private static long testReadWriteLock(int readerCount, int writerCount,
int readOps, int writeOps) throws InterruptedException {
List<Integer> list = new ArrayList<>(LIST_SIZE);
for (int i = 0; i < LIST_SIZE; i++) list.add(i);
ReadWriteLock rwl = new ReentrantReadWriteLock();
CountDownLatch latch = new CountDownLatch(readerCount + writerCount);
long start = System.currentTimeMillis();
for (int i = 0; i < readerCount; i++) {
new Thread(() -> {
for (int op = 0; op < readOps; op++) {
rwl.readLock().lock();
try { list.get(op % LIST_SIZE); }
finally { rwl.readLock().unlock(); }
}
latch.countDown();
}).start();
}
for (int i = 0; i < writerCount; i++) {
final int threadId = i;
new Thread(() -> {
for (int op = 0; op < writeOps; op++) {
rwl.writeLock().lock();
try { list.set(op % LIST_SIZE, threadId * 10000 + op); }
finally { rwl.writeLock().unlock(); }
}
latch.countDown();
}).start();
}
latch.await();
return System.currentTimeMillis() - start;
}
public static void main(String[] args) throws InterruptedException {
System.out.println("=== 读多写少(读:写 = 100:1,8读线程2写线程)===");
long cowTime = testCOW(8, 2, OPERATIONS, OPERATIONS / 100);
long rwlTime = testReadWriteLock(8, 2, OPERATIONS, OPERATIONS / 100);
System.out.printf("COW: %dms,RWL: %dms%n", cowTime, rwlTime);
Thread.sleep(2000);
System.out.println("\n=== 读写均衡(读:写 = 1:1,4读线程4写线程)===");
cowTime = testCOW(4, 4, OPERATIONS / 2, OPERATIONS / 2);
rwlTime = testReadWriteLock(4, 4, OPERATIONS / 2, OPERATIONS / 2);
System.out.printf("COW: %dms,RWL: %dms%n", cowTime, rwlTime);
Thread.sleep(2000);
System.out.println("\n=== 写多读少(读:写 = 1:10,2读线程8写线程)===");
cowTime = testCOW(2, 8, OPERATIONS / 5, OPERATIONS);
rwlTime = testReadWriteLock(2, 8, OPERATIONS / 5, OPERATIONS);
System.out.printf("COW: %dms,RWL: %dms%n", cowTime, rwlTime);
}
}四、踩坑实录
坑1:列表元素是可变对象,COW不能保证线程安全
报错现象: 用CopyOnWriteArrayList存配置对象,读线程读出配置后,写线程修改了配置对象的某个字段,读线程看到的值发生了变化(尽管配置列表没有被修改)。
原因分析: COW保证的是"列表结构的线程安全"(添加、删除元素),不保证"元素内容的线程安全"。如果元素是可变对象,读线程拿到引用后,可以通过这个引用修改对象内部状态,其他线程也能看到这些修改。
// 可变对象,COW无法保护其字段
class Config {
volatile String value; // 需要volatile或其他同步
}
CopyOnWriteArrayList<Config> list = ...;
Config cfg = list.get(0); // 读出引用
cfg.value = "newValue"; // 直接修改对象字段!其他线程也看到了变化解法: 存入COW列表的对象应该是不可变(Immutable)对象:所有字段final,没有setter方法。
坑2:大列表频繁写操作引发GC风暴
报错现象: 接口偶发延迟飙高,监控发现GC暂停时间从5ms增加到500ms,GC日志里Eden区几乎每秒满一次。
原因分析: 如前所述,每次写操作产生等量数组垃圾。如果列表有10万个元素,每次写操作产生约400KB垃圾。配合高频写入,Eden区被快速填满,Young GC频繁触发。
JVM参数监控:
-XX:+PrintGCDetails -XX:+PrintGCDateStamps
-Xmx2g -Xmn512m // 适当增大Young区,减少Young GC频率解法: 超过几千元素或写操作频率超过1次/秒的场景,不要用CopyOnWriteArrayList。用ConcurrentHashMap(如果不需要顺序)或ReadWriteLock + ArrayList(需要顺序)。
坑3:COWIterator不支持remove操作
报错现象: 用COW列表的迭代器调用iterator.remove(),抛UnsupportedOperationException。
原因分析: CopyOnWriteArrayList.COWIterator是只读迭代器,不支持remove()/set()/add()操作。因为迭代器持有的是快照数组,通过迭代器修改快照在语义上没有意义。
Iterator<String> it = cowList.iterator();
while (it.hasNext()) {
String s = it.next();
if (s.equals("remove me")) {
it.remove(); // UnsupportedOperationException!
}
}
// 正确做法:用removeIf(Java 8+)
cowList.removeIf(s -> s.equals("remove me"));
// 或者先collect要删除的元素,再批量removeAll坑4:COW的addAll操作代价是一次数组复制+新内容
报错现象: 批量添加1000个元素(addAll),以为只有一次数组复制,实际GC监控发现有多次分配。
原因分析: CopyOnWriteArrayList.addAll(Collection c)实际上只做一次数组复制(复制当前数组,然后把新集合的元素全部追加进去),不是每次add都复制。这是优化过的。
但如果用for循环逐个add,每次add都复制一次数组,N次add = N次复制,代价是O(N²)级别的内存分配。
// 错误:O(N²)
for (String item : newItems) {
cowList.add(item); // 每次都复制整个数组
}
// 正确:O(N)
cowList.addAll(newItems); // 只复制一次五、总结与延伸
CopyOnWriteArrayList适用场景:
- 读操作远多于写操作(读:写 > 100:1)
- 列表元素数量较少(< 1000)
- 写操作频率低(每秒 < 1次)
- 元素是不可变对象
- 需要迭代时不加锁且不抛CME(如事件监听器列表)
CopyOnWriteArrayList不适用场景:
- 写操作频繁
- 列表很大(万级以上元素)
- 存储可变对象且需要字段级线程安全
- GC敏感场景
Spring事件系统是典型用法: Spring的ApplicationEventMulticaster内部用CopyOnWriteArrayList存储事件监听器,因为监听器注册通常在启动时完成,之后只读,完美适配COW的特性。
