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. 写入与数组复制
写操作需要防止多个线程同时基于旧数组更新,否则后一次发布会覆盖前一次更新。所以,加入元素时要在写锁保护下依次完成:
- 取得写锁。
- 读取当前数组。
- 分配长度增加后的新数组。
- 复制旧引用并写入新元素。
- 发布新数组引用。
成功删除或替换为不同引用时,也会复制数组:
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 旧数组的回收时机
旧数组失去所有强引用后,才可被 GC 回收。也就是说,只要迭代器还持有它,数组和其中引用的元素就会继续存活。频繁写入又长期保存迭代器时,多个大数组版本会同时占用内存。
10.4 CopyOnWriteArrayList 与同步列表
CopyOnWrite 将主要成本放在写入,遍历时无需持锁,但要保留旧数组。synchronizedList 是为列表方法加锁的包装器,它的更新无需每次完整复制数组。遍历同步列表时,需要一直持有包装对象的锁,因此其他使用同一锁的更新会等待。选择时要结合写入频率、列表大小和快照要求。
11 · 面试题卡片
本页题目与参考回答
11.1 CopyOnWriteArrayList 怎样保证遍历安全,代价是什么
出现公司:蚂蚁集团
考察重点
- 说明写锁、数组复制和引用发布的关系。
- 说明迭代器的快照语义。
- 说明为什么一次写入要复制多个引用,以及这对内存峰值和读取旧数据的影响。
参考回答
CopyOnWriteArrayList 通过固定迭代器的数组快照,保证遍历期间列表结构稳定。
写线程持锁复制旧数组,完成更新后再发布新引用,因此迭代器可以继续读取旧版本。
新增元素需要 O(n) 复制,多个写者还要串行等待。复制会增加内存分配和 GC 压力,长期保留的迭代器又会延长旧数组及其元素的存活时间。因此,它适合小型、读多写少且接受旧快照的列表,大列表或高频更新会放大这些成本。
11.2 CopyOnWriteArrayList 中的元素是否也线程安全
出现公司:蚂蚁集团
考察重点
- 说明容器结构快照与元素对象状态的区别。
- 说明浅复制不会复制元素。
- 说明怎样发布不可变配置或保护可变元素。
相关内容:不适合的场景。
参考回答
元素的线程安全由元素类型决定。
写时复制只复制引用数组,新旧快照仍指向相同对象。
要让完整配置快照保持稳定,可以使用不可变元素。若元素必须可变,可以用它自己的锁或原子字段保护读写,也可以限定只由一个线程访问。
11.3 contains 后再 add 能保证不重复吗?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:解释检查与插入分开执行时为什么会重复,以及写锁内再次确认如何避免竞态。
相关内容:原子去重插入。
参考回答
contains 后再 add 无法保证不重复,因为其他线程可以在两次调用之间插入同一个元素。
addIfAbsent 在写锁内再次确认元素缺失,再完成插入,所以这两步作为一个原子操作执行。其他写入入口也要遵守去重约定,否则直接调用 add 仍可加入重复元素。