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保证。
性能注意事项 ⚠️
- 避免大对象或大集合写入:复制成本与数组长度线性相关。
- 批量更新慎用:连续多次写等于多次复制,可考虑批更新(先复制数组,替换后一次设置)。
- 注意内存峰值:写时同时存在旧数组和新数组,可能短暂占用双倍空间。
- 遍历快照语义:读到的是旧数据,不适合要求强一致的场景。
面试常问 🎯
-
CopyOnWriteArrayList 为什么适合读多写少?
因为写操作需要复制整份数组,成本高;但读完全不用加锁,非常快。 -
迭代器为何不会抛
ConcurrentModificationException?
迭代器持有创建时的数组快照,后续修改不会影响快照,也不会检测结构变化。 -
如何处理大规模写操作?
不建议使用;可考虑ConcurrentLinkedQueue+ 批量转换、ReadWriteLock+ 普通 List 等替代方案。 -
addIfAbsent如何实现?
内部通过加锁 + 数组复制,在复制前遍历一次数组确认不存在元素。 -
为何数组字段是 volatile?
确保写线程替换数组后,其他线程能立即看到最新引用,保证可见性。 -
CopyOnWrite 会不会导致内存泄漏?
不会长期泄漏,但写入越多,短时间内旧数组越多,可能造成 GC 压力;因此要控制写频率并监控老年代占用。 -
如何在 CopyOnWrite 场景下做批量更新?
可以先获取底层数组,复制成newElements,在单次锁保护内完成所有增删,再一次性setArray(newElements),减少锁竞争次数。 -
与
ReadWriteLock相比的优势是什么?
CopyOnWrite 完全免锁读取,不存在锁升级/饥饿问题,且读写互不干扰;缺点是写放大和内存成本,适合元素规模较小的场景。
实战小贴士 🛠️
- 控制列表大小,尽量保持在几千元素以内。
- 更新逻辑最好集中在少量线程执行,减少复制次数。
- 如果需要频繁写入,可考虑
ReadWriteLock+ArrayList或ConcurrentLinkedQueue。 - 可搭配
Collections.unmodifiableList()在发布阶段提供不可变视图,进一步防御误修改。 - 监控 GC 日志和写放大次数,避免在小内存容器中出现频繁 Full GC。
小结
CopyOnWriteArrayList 是“读多写少”场景的利器:它以牺牲写性能和内存为代价,换取读操作的极致简单与安全。只要理解其写时复制和快照迭代特性,就能在监听器、配置、白名单等业务中放心使用,并在面试中讲清楚其底层机制与适用边界。