CopyOnWriteArrayList 的原理是什么?适合什么场景?
简化版
CopyOnWriteArrayList 使用写时复制:读操作访问当前数组,新增、修改、删除时在锁保护下复制新数组、完成修改后再替换引用。它适合读多写少且允许迭代器读取快照的场景;写操作是 O(n) 并产生额外内存,不适合大列表或频繁更新。
详细版
读取通常不需要锁,写线程通过锁串行修改,并在新数组准备完整后一次发布,因此读线程不会看到修改一半的结构。迭代器创建时固定当前数组快照,后续增删不会反映到该迭代器,也不会抛 ConcurrentModificationException;迭代器的 remove、set、add 操作不受支持。
它保证的是列表结构并发安全,不会让元素对象自动线程安全。监听器列表、路由规则快照等读远多于写、元素数量可控的场景比较合适;计数、任务队列和高频写入应选择更匹配的数据结构。
完整版教学
一、写时复制如何工作
以 add 为例,逻辑过程可以概括为:
获取写锁 → 读取旧数组 → 复制成长度 +1 的新数组
→ 在新数组写入元素 → 发布新数组 → 释放锁
旧数组在此期间始终保持不变,正在读取它的线程可以继续完成操作。新数组发布后,后续读操作看到最新结构。
二、为什么读操作快
普通 get(index) 读取当前数组后直接按下标访问,不需要和写线程争抢同一把锁。数组一旦发布就不会被原地修改,所以读线程无需防范扩容或搬移到一半的中间状态。
“读不加锁”不等于没有任何并发成本,数组引用仍要满足可见性语义,元素本身的业务状态也可能需要同步。
三、快照迭代器是什么
CopyOnWriteArrayList<String> list =
new CopyOnWriteArrayList<>(List.of("A", "B"));
Iterator<String> iterator = list.iterator();
list.add("C");
iterator.forEachRemaining(System.out::println); // 只输出 A、B
迭代器保存创建那一刻的数组引用,因此遍历期间不受后续写入干扰。这是快照一致性,不是实时一致性:新元素 C 已经存在于 list 中,却不会出现在旧迭代器里。
因为快照数组不能通过迭代器修改,iterator.remove() 等方法会抛 UnsupportedOperationException。
四、写操作为什么昂贵
每次结构修改都要复制数组,时间复杂度通常为 O(n),写入瞬间还可能同时保留旧数组和新数组。列表很大、写入频繁时,会增加 CPU、内存分配和 GC 压力。
多个写线程仍需竞争写锁,所以它并不是“读写都无锁”。批量修改应优先使用集合提供的批量方法,减少反复复制,但仍要评估整体数据量。
五、复合操作与元素安全
下面的“先检查再添加”由两个独立方法组成,并不自动具备整体原子性:
if (!list.contains(value)) {
list.add(value);
}
需要去重添加时可使用 addIfAbsent,批量去重可考虑 addAllAbsent。即使列表结构安全,列表中的可变 User、Map 等对象被多线程修改时,仍要由对象自身保证线程安全。
六、常见误区与追问
先用数量级判断是否适合:列表有 10,000 个元素时,每次 add 都要复制约 10,000 个引用;1 秒写 100 次就可能搬运约 100 万个引用,还会让旧快照继续占据旧数组。相反,若监听器列表只有 20 项、每分钟更新一次,却每秒读取数万次,写时复制就非常合适。
| 场景 | 推荐结构 | 原因 |
|---|---|---|
| 小型监听器/路由快照,读远多于写 | CopyOnWriteArrayList | 读低争用、遍历稳定 |
| 高频生产消费 | BlockingQueue | 明确阻塞与背压语义 |
| 按 key 并发访问 | ConcurrentHashMap | 桶级并发与原子复合 API |
| 普通单线程随机访问 | ArrayList | 无复制锁开销 |
- 误区:CopyOnWriteArrayList 的所有操作都无锁。 读通常不加互斥锁,写操作仍需加锁串行并复制数组。
- 误区:快照迭代器能看到遍历期间的新元素。 它固定创建时的数组,新写入只对之后创建的迭代器可见。
- 误区:容器线程安全会让元素对象也线程安全。 列表只保护结构,可变元素内部状态仍需自己的同步策略。
- 追问:为什么迭代器不支持 remove? 修改旧快照无法安全合并到当前数组,因而直接抛
UnsupportedOperationException。 - 追问:如何原子地去重添加? 使用
addIfAbsent或addAllAbsent,不要手写 contains 后 add。 - 追问:为什么大列表会造成内存峰值? 写入发布新数组时,旧数组可能仍被迭代器持有,两代甚至多代快照会并存。
选择心法:先估算“每秒读多少次、写多少次、每次要复制多大数组”,再决定是否值得用写放大换读性能。
快照的生命周期由迭代器引用决定。若一个长任务持有旧迭代器 30 分钟,即使列表期间更新了 100 次,那份旧数组也至少要等迭代器释放后才能回收;更新频率高时可能同时存活多代数组。
iterator₀ → array₀(仍被长任务持有)
current → array₁ → array₂ → ... → array₁₀₀
因此排查内存占用时不仅要看当前 list 的 size,还要看是否有长生命周期迭代器或流操作滞留旧快照。
七、加强记忆
CopyOnWriteArrayList 在写锁内复制旧数组、修改副本并一次发布,读线程始终面对不再原地变化的稳定数组。快照迭代器因此不 fail-fast,却看不到创建后的更新,也不能通过迭代器修改。它只保证容器结构,不保证元素状态;适合小规模、读极多写极少且允许旧快照的场景,高频写入、任务队列或严格实时遍历应选择其他并发结构。