面试考察点

  • 能否说明写时复制如何协调读写。
  • 是否理解快照一致性和内存放大。
  • 能否识别读多写少且集合较小的适用条件。

核心答案

CopyOnWriteArrayList 写操作加锁并复制整个底层数组,修改完成后发布新数组;读操作无需加锁,迭代器读取创建时的稳定快照。它适合监听器、配置列表等读远多于写且规模有限的场景。

写入成本是 O(n),高频写或大集合会造成复制、内存峰值和 GC 压力,因此不能把它当作通用线程安全 List。

关键机制

新数组发布依靠可见性保证,读线程要么看到旧快照,要么看到完整新数组,不会看到复制一半的状态。迭代器不支持修改操作,因为它面对的是历史快照。

写入过程

以 add 为例,核心过程可以概括为:

  1. 获取写锁,避免多个写线程同时覆盖结果。
  2. 读取当前数组并创建长度加一的新数组。
  3. 复制旧元素,把新元素放到末尾。
  4. 通过可见性语义发布新数组引用。
  5. 释放写锁。

读线程只读取当前数组引用和指定位置,不参与写锁竞争。一次写入至少复制 O(n) 个引用,旧数组还可能被正在遍历的线程持有,暂时不能回收。

内存一致性与快照

把对象放入 CopyOnWriteArrayList 之前的写入,对之后读取到该对象的线程可见;但这不等于对象后续字段修改自动线程安全。容器只保护列表结构,元素若可变,仍需自身同步或不可变设计。

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

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

发布期间注册或注销监听器不会影响当前轮遍历,也不会持有全局读锁。这正是监听器列表、路由规则快照等典型用途。

成本如何估算

假设列表有 100 万个引用,一次写入需要复制约 8 MB 引用数据(是否压缩指针取决于 JVM),并瞬时同时持有新旧数组。若每秒写几十次,就会制造显著内存带宽和 GC 压力。

因此“读写比 100:1”也不一定足够,集合大小同样关键。10 个元素的配置列表偶尔写入非常合适,百万元素列表即使写得少也可能在单次更新时产生长尾。

替代方案

  • 读写都频繁:普通 ArrayList 配合读写锁,或重新设计分片结构。
  • 只需最新完整配置:构造不可变 List 后用 volatile/AtomicReference 整体替换。
  • 高频追加和消费:选择并发队列而不是 CopyOnWriteArrayList。
  • 按键访问:使用 ConcurrentHashMap,避免每次线性查找。

适用边界

它适合“允许读到稍旧数据”的场景。若读取必须立即看到最新写入,或多个操作需要形成事务性约束,应通过锁、不可变快照整体替换或其他数据结构实现。

常见误区

读操作线程安全不代表 containsadd 的组合天然原子;需要去重时可用 addIfAbsent,并评估它的线性扫描成本。

高频追问与参考回答

追问:为什么迭代时不会抛并发修改异常?

迭代器持有旧数组引用,写线程操作的是复制后的新数组,二者互不修改同一结构。

追问:迭代器为什么不支持 remove?

它只能看到历史数组,无法安全表达“从当前最新数组删除这个位置”的语义,调用会抛出 UnsupportedOperationException。

追问:addIfAbsent 的成本是什么?

它需要扫描判断是否存在,并在获取写锁后再次检查以处理并发写入,时间复杂度为 O(n)。大集合频繁去重不适合该结构。

追问:快照会造成数据不一致吗?

它提供的是明确的时间点视图而非最新视图。若业务允许一轮通知使用同一版本,这反而更一致;若每次读必须立刻看到更新,就不适合。

总结

CopyOnWriteArrayList 用昂贵写入换取便宜、稳定的读取,使用前必须验证读写比例、集合大小和陈旧数据容忍度。

机制全景图

下面把「CopyOnWriteArrayList 适合什么场景?」从输入到结果压缩成一条可复述的主链路。面试时先用图建立全局坐标,再进入局部实现,能避免只背零散结论。

flowchart LR
    A["读取当前数组快照"]
    A --> B["写操作获取互斥锁"]
    B --> C["复制完整数组"]
    C --> D["在副本上修改"]
    D --> E["发布新数组引用"]

完整链路:从输入到结果

沿着「读取当前数组快照 → 写操作获取互斥锁 → 复制完整数组 → 在副本上修改 → 发布新数组引用」观察输入、状态与输出,下面每个阶段都对应一个可以在源码、日志或系统表中验证的位置。

1. 读取当前数组快照

读取只抓取当前 volatile 数组引用,因此无需锁,迭代器也基于创建时的固定快照。

2. 写操作获取互斥锁

所有写入串行获取锁,避免多个写线程互相覆盖副本。

3. 复制完整数组

每次写都复制整个数组,时间和临时内存与元素数量成正比,大列表写入成本很高。

4. 在副本上修改

修改只发生在新数组上,旧数组仍供已有读者使用,所以读者不会看到半完成结构。

5. 发布新数组引用

新数组通过 volatile 引用发布,后续读可见;旧迭代器不会自动看到更新,这是快照语义而非一致性缺陷。

源码与实现定位

入口 阅读重点
CopyOnWriteArrayList#add 加锁、Arrays.copyOf、setArray
COWIterator 构造时固定 snapshot 引用

源码或系统表应按上表顺序追踪:先确认入口实际走到哪条路径,再用运行时数据验证,而不是仅凭类名或配置推测。

参数配置与可复现实验

var next = new ArrayList<Listener>(loaded);
listeners.clear();
listeners.addAll(next);

列表规模从 10 到 100k,分别逐个 add 与单次 addAll;JFR 记录复制数组字节和旧快照晋升。

验证步骤与预期结果

1. 固定输入和基线

先在没有故障注入的环境执行上述配置,固定数据规模、并发度、运行时版本和预热时间。以「单次写复制字节」为主基线,记录值应满足「约等于数组引用大小」;同时保存 列表长度、单位时间写次数,使后续变化能够回到同一时间轴比较。

2. 从实现入口确认路径

在「CopyOnWriteArrayList#add」确认请求确实进入「加锁、Arrays.copyOf、setArray」对应的实现,再沿「COWIterator」观察「构造时固定 snapshot 引用」。如果入口路径都未命中,就不应继续调整下游参数,而应先检查调用条件、版本或路由是否与假设一致。

3. 注入本文特有的失败模式

优先复现「大列表频繁单元素写导致复制风暴」,并把单一变量逐级放大,直到「单次写复制字节」越过「每秒复制超过堆 5%」。随后再分别验证「误以为迭代器能看到最新元素」和「长生命周期迭代器延长旧数组存活」,三类故障分开执行,避免多个变量同时变化而无法归因。

4. 执行止损和根因修复

第一轮只应用「逐项更新改为构建后一次发布」,确认它能控制影响范围;第二轮应用「限制监听器列表规模」,验证核心链路恢复;最后落实「写频率升高时切换读写锁或不可变引用」,消除同类问题再次出现的条件。每一步都保留变更前后数据,不用“感觉变快了”替代测量。

5. 通过退出条件

实验只有同时满足三项才算通过:「单次写复制字节」回到「约等于数组引用大小」、「读写比」回到「建议远高于 1000:1」、「旧快照存活」回到「短迭代周期」,并且业务结果差异为零。若性能恢复但结果不一致,仍应视为失败;若指标恢复后很快再次越线,则说明只完成了临时止损,没有消除根因。

量化基线

指标 样例基线/口径 风险线 结论
单次写复制字节 约等于数组引用大小 每秒复制超过堆 5% 改批量快照
读写比 建议远高于 1000:1 持续写入 换锁/不可变引用
旧快照存活 短迭代周期 进入老年代 排查长生命周期迭代器

这些数值是实验口径或示例告警线,不是可复制到所有系统的固定答案;上线阈值应由本系统稳态、峰值和故障演练共同确定。

事故复盘:监听器列表读快写少却出现内存尖峰

监听器注册本来很少,但一次配置重载逐个 add 数千项,每次都复制不断增长的数组,产生大量短命对象。改为先构建普通 List,再一次 addAll 发布,并限制列表规模后,保留了无锁读取优势。

失败模式 首要证据 第一处置动作
大列表频繁单元素写导致复制风暴 列表长度 逐项更新改为构建后一次发布
误以为迭代器能看到最新元素 单位时间写次数 限制监听器列表规模
长生命周期迭代器延长旧数组存活 数组复制分配量 写频率升高时切换读写锁或不可变引用

发布与回滚检查点

  • 发布前:确认「CopyOnWriteArrayList#add」对应实现和上述配置在目标版本仍然有效,并保存「单次写复制字节」基线。
  • 灰度中:同时观察 列表长度、单位时间写次数、数组复制分配量;任一指标越过表中风险线,就停止继续扩量。
  • 回滚时:先执行「逐项更新改为构建后一次发布」控制影响,再回退代码或参数;涉及持久状态时必须额外核对结果差异。
  • 发布后:至少覆盖一个完整峰值周期,确认「大列表频繁单元素写导致复制风暴」没有再次出现,才关闭变更观察窗口。

方案对比与选型

方案 更适合的场景 主要收益 代价与边界
CopyOnWriteArrayList 小规模、读极多写极少且允许快照读取 读无锁、遍历稳定 写放大和旧快照占内存
同步 List 读写都不频繁且需简单互斥 语义直接 遍历需持锁,全局竞争
不可变 List + volatile 整批替换配置快照 读取最轻、版本清晰 不适合高频单元素修改

选型至少带上 元素数量、读写比例、遍历方式、并发度和内存预算,并用上面的量化基线验证;未知数据应明确为待测假设。

设计边界与工程取舍

Copy-on-write 用写成本换读性能和快照一致性,适合监听器、路由快照等场景;“线程安全”不代表所有负载下都高效。

工程落地遵循:先保证数据结构语义正确,再依据访问模式选择实现。回答时直接引用「CopyOnWriteArrayList#add」、配置实验和事故数据,比复述固定模板更有说服力。