面试考察点
- 能否说明写时复制如何协调读写。
- 是否理解快照一致性和内存放大。
- 能否识别读多写少且集合较小的适用条件。
核心答案
CopyOnWriteArrayList 写操作加锁并复制整个底层数组,修改完成后发布新数组;读操作无需加锁,迭代器读取创建时的稳定快照。它适合监听器、配置列表等读远多于写且规模有限的场景。
写入成本是 O(n),高频写或大集合会造成复制、内存峰值和 GC 压力,因此不能把它当作通用线程安全 List。
关键机制
新数组发布依靠可见性保证,读线程要么看到旧快照,要么看到完整新数组,不会看到复制一半的状态。迭代器不支持修改操作,因为它面对的是历史快照。
写入过程
以 add 为例,核心过程可以概括为:
- 获取写锁,避免多个写线程同时覆盖结果。
- 读取当前数组并创建长度加一的新数组。
- 复制旧元素,把新元素放到末尾。
- 通过可见性语义发布新数组引用。
- 释放写锁。
读线程只读取当前数组引用和指定位置,不参与写锁竞争。一次写入至少复制 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,避免每次线性查找。
适用边界
它适合“允许读到稍旧数据”的场景。若读取必须立即看到最新写入,或多个操作需要形成事务性约束,应通过锁、不可变快照整体替换或其他数据结构实现。
常见误区
读操作线程安全不代表 contains 后 add 的组合天然原子;需要去重时可用 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」、配置实验和事故数据,比复述固定模板更有说服力。