跳到主要内容
约 7 分钟3 张卡

CopyOnWrite 容器

CopyOnWrite 容器在更新列表结构时复制数组,让读者继续使用完整的旧数组。本文以 OpenJDK 21 为准,先讲读写如何访问和发布数组,再解释快照迭代、写入成本与适用场景。

1. 面试核心​

先记住这几条,再读正文
  • 写时复制。 CopyOnWriteArrayList 在写锁内完成结构更新,复制元素引用后再发布新数组。卡 · 读操作
  • 快照迭代。 CopyOnWriteArrayList 的迭代器遍历创建时的数组快照,后续增删和替换元素都不会改变本次遍历。卡 · 读操作
  • 旧版本保留。 长期保留的迭代器会延长旧数组及其元素的存活时间。
  • 元素可变性。 CopyOnWriteArrayList 只复制元素引用,元素字段的并发读写仍由元素自身保证线程安全。卡 · 不适合的场景
  • 原子去重。 addIfAbsent 将检查与插入作为原子操作完成,分开调用 contains 和 add 会有重复插入的竞态。卡 · 原子去重插入

2. 读操作​

读操作需要拿到一个已经构造完成的数组版本。容器通过 volatile 字段保存数组引用,get 读取该引用后,再按索引取出元素。遍历也基于数组,例如下面的监听器广播:

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

for (Listener listener : listeners) {
listener.onEvent(event);
}

volatile 保证新数组发布后,读取该引用的线程能看到数组中已写入的内容。写者先构造完整数组,再替换引用,所以读者无需获取写锁,也不会看到复制到一半的数组。

3. 写入与数组复制​

写操作需要防止多个线程同时基于旧数组更新,否则后一次发布会覆盖前一次更新。所以,加入元素时要在写锁保护下依次完成:

  1. 取得写锁。
  2. 读取当前数组。
  3. 分配长度增加后的新数组。
  4. 复制旧引用并写入新元素。
  5. 发布新数组引用。

成功删除或替换为不同引用时,也会复制数组:

listeners.add(listener);
listeners.remove(listener);

新增元素需要复制已有的 n 个引用,成本为 O(n),还会分配一个新数组。写入集中时,多个写者要等待同一把锁,大量复制也会消耗内存带宽。新旧数组同时存活会抬高内存峰值,并增加垃圾回收(GC)的压力。

具体是否复制取决于操作结果。OpenJDK 21 中,set 为相同引用会复用数组,删除未命中时也可直接返回。

4. 快照迭代器​

一次遍历需要保持元素顺序稳定,所以迭代器会保存创建时的数组引用。这个数组就是本次遍历的快照,后续更新使用其他数组:

Iterator<Listener> iterator = listeners.iterator();
listeners.add(newListener);

while (iterator.hasNext()) {
iterator.next(); // 不会看到 newListener
}

示例中的迭代器始终读取旧数组,因此看不到 newListener。并发增删或替换元素也不会让它抛出 ConcurrentModificationException,即并发修改异常。

快照迭代器只提供读取操作。Iterator.remove 会抛出 UnsupportedOperationException。ListIterator 的 set 与 add 也会抛出该异常。

广播时,迭代器只访问创建时已有的监听器,因此新注册的监听器要等下一次广播。中途注销的监听器仍在旧数组中,轮到它时仍会收到回调。若业务要求立即取消,监听器自身需要检查是否仍然有效。

5. 适合的场景​

选择 CopyOnWrite 时,需要同时评估列表规模、读写比例和快照要求:

  • 列表较小。
  • 读与遍历频率远高于写。
  • 遍历耗时较长,需要避免一直占用写锁。
  • 读者可以接受短暂旧版本。
  • 每次写入由少量管理操作触发。

满足这些要求的用途包括以下小型列表:

  • 事件监听器列表。
  • 运行期很少变化的规则或处理器清单。
  • 小规模路由快照。
  • 只偶尔更新的白名单。

列表大小同样影响选择。比如,一个有百万元素的列表即使每天只更新几次,每次也要完整复制数组。因此,评估时还要看单次复制的延迟和内存峰值。

6. 不适合的场景​

复制数组和读取旧快照都有代价。下面这些更新与一致性要求会让 CopyOnWrite 难以适用:

  • 写入频繁,或把一批数据拆成多次单条写入。
  • 列表很大,复制会导致明显的内存峰值。
  • 写入后,要求所有正在进行的遍历立即看到新内容。
  • 业务需要通过迭代器修改列表。
  • 多个线程会修改元素字段,并希望由容器保护这些字段。

最后一项需要区分数组和元素对象。CopyOnWrite 只复制引用数组,新旧数组仍然指向相同元素,例如:

listeners.get(0).changeState();

这次调用修改的是 Listener 对象的状态。其他快照也引用同一个对象,所以字段读写需要由 Listener 自身同步。

7. 批量更新​

一批元素可以集中加入,减少重复复制。对比下面单条追加与批量追加的两种写法,实际使用时选择其中一种:

// 逐个追加
for (Listener listener : incoming) {
listeners.add(listener);
}

// 批量追加
listeners.addAll(incoming);
  • 逐个调用 add 时,每次都要复制已有数组,越晚加入的元素越要复制更多已有引用。
  • addAll 一次构造新版本,减少复制与发布次数。

去重插入可以使用 addIfAbsent 或 addAllAbsent,它们需要扫描并比较元素。高频去重时,可以选择基于哈希的 Set,并按线程共享方式增加同步。配置按批次变化时,也可以整体替换不可变快照。

整体替换时,先构造不可变 List,再通过 volatile 或 AtomicReference 一次替换引用。AtomicReference 用于原子地读取或更新对象引用。这样,写入成本集中在快照构建阶段,读者只取一次引用就能固定本次读取的列表版本。

8. CopyOnWriteArraySet​

需要去重且保留快照遍历时,可以使用 CopyOnWriteArraySet。它基于 CopyOnWriteArrayList,通过逐个比较元素来判断是否重复。所以,成员判断的成本为 O(n),适合小型、读多写少的集合。

Set<Listener> listeners = new CopyOnWriteArraySet<>();

大量键的并发成员判断可用 ConcurrentHashMap.newKeySet()。

9. 原子去重插入​

判断元素不存在后再插入,需要把两步作为一个原子操作完成。分开调用 contains 与 add 时,两个写者可以同时发现元素缺失,随后各插入一次。addIfAbsent 会在写锁内确认元素仍然缺失,再发布新数组:

boolean added = listeners.addIfAbsent(listener);

返回 true 表示本次插入成功,false 表示已有相等元素。相等判断依赖 equals,所以参与比较的字段应保持稳定。批量去重可用 addAllAbsent,减少数组发布次数。

10. 常见问题​

10.1 读操作成本​

get 需要读取数组引用和元素,成本为 O(1)。引用在数组中连续存放,有利于 CPU 缓存连续读取,这称为缓存局部性。迭代器则固定旧数组,所以即使读取很快,业务也要接受旧列表内容。

10.2 迭代器的只读约束​

10.3 旧数组的回收时机​

10.4 CopyOnWriteArrayList 与同步列表​

11 · 面试题卡片​

本页题目与参考回答

11.1 CopyOnWriteArrayList 怎样保证遍历安全,代价是什么​

出现公司:蚂蚁集团

考察重点

  • 说明写锁、数组复制和引用发布的关系。
  • 说明迭代器的快照语义。
  • 说明为什么一次写入要复制多个引用,以及这对内存峰值和读取旧数据的影响。

相关内容:读操作至快照迭代器。

参考回答

CopyOnWriteArrayList 通过固定迭代器的数组快照,保证遍历期间列表结构稳定。

写线程持锁复制旧数组,完成更新后再发布新引用,因此迭代器可以继续读取旧版本。

新增元素需要 O(n) 复制,多个写者还要串行等待。复制会增加内存分配和 GC 压力,长期保留的迭代器又会延长旧数组及其元素的存活时间。因此,它适合小型、读多写少且接受旧快照的列表,大列表或高频更新会放大这些成本。

11.2 CopyOnWriteArrayList 中的元素是否也线程安全​

出现公司:蚂蚁集团

考察重点

  • 说明容器结构快照与元素对象状态的区别。
  • 说明浅复制不会复制元素。
  • 说明怎样发布不可变配置或保护可变元素。

相关内容:不适合的场景。

参考回答

元素的线程安全由元素类型决定。

写时复制只复制引用数组,新旧快照仍指向相同对象。

要让完整配置快照保持稳定,可以使用不可变元素。若元素必须可变,可以用它自己的锁或原子字段保护读写,也可以限定只由一个线程访问。

11.3 contains 后再 add 能保证不重复吗?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:解释检查与插入分开执行时为什么会重复,以及写锁内再次确认如何避免竞态。

相关内容:原子去重插入。

参考回答

contains 后再 add 无法保证不重复,因为其他线程可以在两次调用之间插入同一个元素。

addIfAbsent 在写锁内再次确认元素缺失,再完成插入,所以这两步作为一个原子操作执行。其他写入入口也要遵守去重约定,否则直接调用 add 仍可加入重复元素。

12. 参考资料​