CopyOnWriteArrayList 为什么适合读多写少?读写锁怎么选?

#Java#面试八股

CopyOnWriteArrayList 为什么适合读多写少?读写锁怎么选?

结论先行:CopyOnWriteArrayList 在写时复制整个数组,读操作完全无锁——所以读的性能接近普通 ArrayList,写则要付出 O(n) 的复制代价。它只适合"读极多、写极少、元素不多"的场景;写频繁或数据量大时,应该换成读写锁方案。

1. 写时复制的机制

写操作(add/set/remove):
   array ──▶ [A][B][C]        ← 读线程此刻仍在读这个旧数组,不受影响
                │ 复制一份
                ▼
             [A][B][C][D]     ← 在新数组上修改
                │
                ▼
   array = 新数组              ← volatile 写,一次性切换引用

读操作(get/iterator):
   直接读 array 引用指向的数组,不加锁、不阻塞

两个关键点:

  1. 写加锁、读不加锁:写操作用 ReentrantLock 串行化,保证不会有两个线程同时复制;
  2. 数组引用是 volatile:保证新数组对读线程立即可见,读线程要么看到旧数组、要么看到新数组,不会看到"改了一半"的状态。

2. 与其它方案的对比

方案读写适用场景
CopyOnWriteArrayList无锁,O(1)加锁 + 复制数组,O(n)读极多写极少、元素少
ReentrantReadWriteLock + ArrayList读锁共享,有锁开销写锁独占读写都比较频繁、读多写少
Collections.synchronizedList全程互斥全程互斥并发很低
ConcurrentHashMap桶级并发桶级锁本质是 key-value,不是列表

3. 用法与迭代器

CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();

// 读:完全无锁,可以在遍历中安全地增删
for (Listener l : listeners) {
    l.onEvent(event);        // 遍历的是快照,不会抛 ConcurrentModificationException
}

// 写:每次 add 都复制一份新数组
listeners.add(new SmsListener());

// 批量写务必用 addAll,避免 N 次复制
listeners.addAll(Arrays.asList(a, b, c));

迭代器的语义值得单独记:它拿到的是创建迭代器那一刻的数组快照,之后别人怎么改都不影响这次遍历。所以它弱一致——遍历期间新增的元素看不到,但也不会报错。

4. 什么时候不该用

场景为什么不适合替代
写操作频繁每次写都复制 O(n),CPU 和 GC 双杀ReentrantReadWriteLock
列表元素很多(上万)一次写要复制几万个引用分段结构或读写锁
需要 size() 精确即时size() 读的是快照值加锁统计
需要按下标做批量修改每次修改都触发复制换成普通列表 + 锁

一个典型的滥用:把 CopyOnWriteArrayList 当作"线程安全的 ArrayList"随手用在写多读少的队列场景,结果 CPU 全耗在数组复制上。

5. 常见追问

追问一:为什么它的迭代器不会抛 ConcurrentModificationException? 因为它迭代的是旧数组,修改者改的是新数组,两者互不影响。代价是遍历期间看不到最新数据(弱一致性)。

追问二:写的时候为什么要复制整个数组,不能只改一个下标? 因为读线程可能正在遍历旧数组。如果原地修改,读线程会看到"一半新一半旧"的数组。复制换来的是读路径完全无锁,这是它的核心取舍。

追问三:CopyOnWriteArraySet 也是这个原理吗? 是,内部就是一个 CopyOnWriteArrayList,add 时先线性查找是否存在。所以它的 add 和 contains 都是 O(n)——元素多时性能很差。

追问四:它和读写锁怎么选? 看两个数字:读写的比例和每次操作的数据量。读操作占比 95% 以上、元素在几百个以内 → COW;读写都频繁或元素上千 → 读写锁。再多考虑一层:COW 的读是零锁开销,读写锁的读仍要 CAS 修改状态,高并发下也会有竞争。

追问五:COW 适合放什么? 监听器列表、白名单/黑名单、路由表、配置快照——共同特征是变更靠运维动作触发,读发生在每个请求上。

小结

CopyOnWriteArrayList 用"写时复制的成本"换"读时零锁的自由"。判断能不能用只需一问:这个列表是每读一百次才写一次吗?是就用,不是就别用。

笔记加载中…