← 返回题目列表

CopyOnWriteArrayList 的原理是什么?适合什么场景?

高频 中等 第 5 / 30 题 更新于 2026/07/25
CopyOnWriteArrayList写时复制并发集合快照迭代

简化版

CopyOnWriteArrayList 使用写时复制:读操作访问当前数组,新增、修改、删除时在锁保护下复制新数组、完成修改后再替换引用。它适合读多写少且允许迭代器读取快照的场景;写操作是 O(n) 并产生额外内存,不适合大列表或频繁更新。

详细版

读取通常不需要锁,写线程通过锁串行修改,并在新数组准备完整后一次发布,因此读线程不会看到修改一半的结构。迭代器创建时固定当前数组快照,后续增删不会反映到该迭代器,也不会抛 ConcurrentModificationException;迭代器的 removesetadd 操作不受支持。

它保证的是列表结构并发安全,不会让元素对象自动线程安全。监听器列表、路由规则快照等读远多于写、元素数量可控的场景比较合适;计数、任务队列和高频写入应选择更匹配的数据结构。

完整版教学

一、写时复制如何工作

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
  • 追问:如何原子地去重添加? 使用 addIfAbsentaddAllAbsent,不要手写 contains 后 add。
  • 追问:为什么大列表会造成内存峰值? 写入发布新数组时,旧数组可能仍被迭代器持有,两代甚至多代快照会并存。

选择心法:先估算“每秒读多少次、写多少次、每次要复制多大数组”,再决定是否值得用写放大换读性能。

快照的生命周期由迭代器引用决定。若一个长任务持有旧迭代器 30 分钟,即使列表期间更新了 100 次,那份旧数组也至少要等迭代器释放后才能回收;更新频率高时可能同时存活多代数组。

iterator₀ → array₀(仍被长任务持有)
current   → array₁ → array₂ → ... → array₁₀₀

因此排查内存占用时不仅要看当前 list 的 size,还要看是否有长生命周期迭代器或流操作滞留旧快照。

七、加强记忆

CopyOnWriteArrayList 在写锁内复制旧数组、修改副本并一次发布,读线程始终面对不再原地变化的稳定数组。快照迭代器因此不 fail-fast,却看不到创建后的更新,也不能通过迭代器修改。它只保证容器结构,不保证元素状态;适合小规模、读极多写极少且允许旧快照的场景,高频写入、任务队列或严格实时遍历应选择其他并发结构。