跳到主要内容

CopyOnWriteArrayList

CopyOnWriteArrayList 是一种 写时复制 (Copy-On-Write) 的线程安全 List 实现,属于 JUC 并发集合。它通过“读写分离”在读多写少场景下提供极高的读取性能,非常适合存储监听器、黑名单、白名单等少量需要偶尔更新的数据。


核心原理 🧠

  • 底层仍是数组 transient volatile Object[] array
  • 每次写操作都会 复制一份新数组,在新数组上修改,然后把 array 引用指向新数组。
  • 读取操作直接访问 array,无须加锁,天然线程安全。
  • 写操作通过 ReentrantLock 保证同一时间只有一个线程执行复制,避免数据竞争。
  • 迭代器遍历的是创建迭代器时的快照,不会感知之后的修改(弱一致性快照)。
  • 由于数组引用是 volatile,写线程在 setArray() 后对其他线程可见;老数组将等待 GC 回收。

示例(JDK 源码片段):

public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}

适用场景 ✅

  • 监听器/订阅者列表:如 GUI 事件监听、Spring 事件广播。
  • 系统配置或白名单:更新频率低,但读取频率极高。
  • 黑名单/过滤规则:需要频繁遍历匹配,偶尔才更新。
  • 广播消息:一次写入后,多个线程读取快照。
  • 热更新特性开关:配置热删改不频繁,但每次请求都要读取最新快照。

API 速览 🔧

CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();

list.add("listener");
list.addIfAbsent("listener"); // 原子检测+插入
list.addAllAbsent(Arrays.asList("A", "B"));

list.get(0); // 读操作无锁
list.remove("listener");

Iterator<String> it = list.iterator(); // 快照迭代器
while (it.hasNext()) {
System.out.println(it.next());
}

特殊方法:

  • addIfAbsent(E e):只有在列表中不存在 e 时才添加。
  • addAllAbsent(Collection<? extends E> c):批量添加不存在的元素。
  • iterator():返回快照迭代器,可在遍历时安全修改列表(修改不可见)。
  • subList:返回的视图本质上仍是快照,不支持结构性修改,否则抛 UnsupportedOperationException

优缺点对比 ⚖️

维度优点缺点
读性能完全无锁,读非常快-
写性能简单可靠,无需额外同步每次写都复制数组,成本高
内存-写操作需要额外分配 + GC 压力
一致性迭代器不会抛 ConcurrentModificationException迭代器看到的是旧快照,非实时
场景读多写少、元素规模小写多或大列表时不合适
  • 灾备 | 快照天然容错,写失败不影响读 | 写放大,可能触发 Safepoint 延迟

与其他方案比较 🥊

  • vs Collections.synchronizedList:后者读写都要获取同一把锁,吞吐较低;CopyOnWriteArrayList 读完全无锁,但写成本高。
  • vs ConcurrentLinkedQueue:CLQ 适合频繁入队/出队的队列;CopyOnWriteArrayList 更适合“随机访问 + 遍历”场景。
  • vs CopyOnWriteArraySet:后者基于 CopyOnWriteArrayList 实现,元素唯一性由 addIfAbsent 保证。

性能注意事项 ⚠️

  1. 避免大对象或大集合写入:复制成本与数组长度线性相关。
  2. 批量更新慎用:连续多次写等于多次复制,可考虑批更新(先复制数组,替换后一次设置)。
  3. 注意内存峰值:写时同时存在旧数组和新数组,可能短暂占用双倍空间。
  4. 遍历快照语义:读到的是旧数据,不适合要求强一致的场景。

面试常问 🎯

  1. CopyOnWriteArrayList 为什么适合读多写少?
    因为写操作需要复制整份数组,成本高;但读完全不用加锁,非常快。

  2. 迭代器为何不会抛 ConcurrentModificationException
    迭代器持有创建时的数组快照,后续修改不会影响快照,也不会检测结构变化。

  3. 如何处理大规模写操作?
    不建议使用;可考虑 ConcurrentLinkedQueue + 批量转换、ReadWriteLock + 普通 List 等替代方案。

  4. addIfAbsent 如何实现?
    内部通过加锁 + 数组复制,在复制前遍历一次数组确认不存在元素。

  5. 为何数组字段是 volatile?
    确保写线程替换数组后,其他线程能立即看到最新引用,保证可见性。

  6. CopyOnWrite 会不会导致内存泄漏?
    不会长期泄漏,但写入越多,短时间内旧数组越多,可能造成 GC 压力;因此要控制写频率并监控老年代占用。

  7. 如何在 CopyOnWrite 场景下做批量更新?
    可以先获取底层数组,复制成 newElements,在单次锁保护内完成所有增删,再一次性 setArray(newElements),减少锁竞争次数。

  8. ReadWriteLock 相比的优势是什么?
    CopyOnWrite 完全免锁读取,不存在锁升级/饥饿问题,且读写互不干扰;缺点是写放大和内存成本,适合元素规模较小的场景。


实战小贴士 🛠️

  • 控制列表大小,尽量保持在几千元素以内。
  • 更新逻辑最好集中在少量线程执行,减少复制次数。
  • 如果需要频繁写入,可考虑 ReadWriteLock + ArrayListConcurrentLinkedQueue
  • 可搭配 Collections.unmodifiableList() 在发布阶段提供不可变视图,进一步防御误修改。
  • 监控 GC 日志和写放大次数,避免在小内存容器中出现频繁 Full GC。

小结

CopyOnWriteArrayList 是“读多写少”场景的利器:它以牺牲写性能和内存为代价,换取读操作的极致简单与安全。只要理解其写时复制和快照迭代特性,就能在监听器、配置、白名单等业务中放心使用,并在面试中讲清楚其底层机制与适用边界。