CopyOnWriteArrayList:写时复制与并发机制
CopyOnWriteArrayList是一个线程安全的List,采用“写时复制”实现。它牺牲写性能(每次修改都复制数组)换取极高的读性能(无锁),因此最适用于“读多写少”的并发场景。
我们来详细地讲解一下 Java 中的 CopyOnWriteArrayList。
CopyOnWriteArrayList 是 Java 并发包 java.util.concurrent 下一个非常重要的线程安全的 List 实现。它的名字“Copy-On-Write”(写时复制)已经完美地概括了其核心工作原理。
1. 什么是 Copy-On-Write (写时复制)?
这是一种用于并发编程的优化策略。其核心思想是:
- 读操作:当进行读操作时(如
get(),iterator()),不加锁,直接读取底层数组的内容。因为读操作不修改数据,所以是线程安全的。 - 写操作:当需要对列表进行修改时(如
add(),set(),remove()),它不直接在当前数组上修改,而是:- 加锁:锁定整个列表,确保同一时间只有一个线程在进行写操作。
- 复制:创建一个底层数组的全新副本。
- 修改:在新的副本上进行添加、修改或删除操作。
- 替换:将指向旧数组的引用,切换到指向这个修改后的新数组。
- 解锁。
整个过程就像你编辑一个共享文档时,不直接在原文件上改,而是先“另存为”一个副本,在副本上修改,修改完成后再用副本替换掉原文件。
2. CopyOnWriteArrayList 的主要特点
根据其“写时复制”的原理,我们可以推导出它的几个关键特性:
1. 线程安全 (Thread-Safe)
它通过在写操作时加锁和创建副本来保证线程安全,你可以在多线程环境中直接使用它,无需额外的同步措施。
2. 读操作性能极高
读操作完全不加锁,直接访问内存中的数组,效率非常高。这使得 CopyOnWriteArrayList 特别适合“读多写少”的场景。
3. 写操作性能较低
每次写操作都需要复制整个底层数组,这是一个非常耗时的操作。如果列表很大,或者写操作非常频繁,性能会急剧下降。
4. 数据一致性是“最终一致性”
这是一个非常重要的特性。当一个线程正在修改列表时(即在新的副本上操作),其他线程的读操作仍然在访问旧的、未修改的数组。只有当写操作完成,引用被切换后,后续的读操作才能访问到新的数据。
这意味着读操作可能读到的是“快照”数据(旧数据)。
5. 迭代器是弱一致性的 (Weakly Consistent)
- 当你从
CopyOnWriteArrayList获取一个迭代器(iterator)时,这个迭代器引用的是创建它那个时刻的底层数组快照。 - 之后任何对列表的修改(增、删、改)都与这个迭代器无关。
- 因此,它的迭代器永远不会抛出
ConcurrentModificationException。
3. 适用场景 (Use Cases)
CopyOnWriteArrayList 的设计初衷非常明确,它适用于以下场景:
- 读多写少的并发场景:这是最典型的应用场景。例如:
- 事件监听器 (Listeners/Observers):一个组件的监听器列表,通常在初始化时添加几个,之后很少改变,但在事件发生时会被频繁地遍历和调用。
- 配置信息:系统加载时读取配置,运行时很少修改,但会被各个模块频繁读取。
- 黑白名单:一些需要频繁校验的名单,但名单本身不常变动。
4. 不适用场景 (When to Avoid)
- 写操作频繁的场景:如果你的应用需要频繁地修改列表,使用它会导致大量的数组复制和内存开销,性能会非常差。
- 要求数据实时性的场景:由于读操作读取的是快照,如果你要求一个线程的写操作必须立刻对其他线程可见,
CopyOnWriteArrayList可能不适合,因为它存在短暂的数据不一致。 - 列表数据量非常大:列表越大,单次写操作的复制成本就越高。
5. 与其他线程安全 List 的对比
| 特性 / List | CopyOnWriteArrayList |
Vector |
Collections.synchronizedList(new ArrayList<>()) |
|---|---|---|---|
| 锁机制 | 写操作时加 ReentrantLock |
几乎所有方法都用 synchronized |
所有方法都用 synchronized 块包裹 |
| 锁粒度 | 只锁写操作 | 锁整个对象(方法级别) | 锁整个对象(代码块级别) |
| 读性能 | 极高 (无锁) | 差 (加锁) | 差 (加锁) |
| 写性能 | 差 (需要复制数组) | 一般 (加锁,但无复制成本) | 一般 (加锁,但无复制成本) |
| 迭代器安全 | 绝对安全 (不会抛 ConcurrentModificationException) |
不安全 (需要手动加锁) | 不安全 (需要手动加锁) |
| 数据一致性 | 弱一致性/最终一致性 (读到快照) | 强一致性 | 强一致性 |
| 最佳场景 | 读多写少 | (不推荐使用,性能差) | 需要一个简单的线程安全List,且读写均衡 |
6. 代码示例
下面的例子展示了 CopyOnWriteArrayList 如何在多线程环境中安全工作,并且其迭代器不会抛出异常。
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class CopyOnWriteArrayListExample {
public static void main(String[] args) {
// 1. 创建一个 CopyOnWriteArrayList
List<String> list = new CopyOnWriteArrayList<>();
list.add("A");
list.add("B");
list.add("C");
// 2. 创建一个线程池
ExecutorService executorService = Executors.newFixedThreadPool(5);
// 3. 启动一个写线程,它会不断向列表中添加元素
executorService.submit(() -> {
try {
for (int i = 0; i < 5; i++) {
Thread.sleep(100); // 慢一点,让读线程有机会看到变化
String element = "New-" + i;
list.add(element);
System.out.println(Thread.currentThread().getName() + " added: " + element);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
// 4. 启动多个读线程,它们会遍历列表
for (int i = 0; i < 3; i++) {
executorService.submit(() -> {
try {
// 获取迭代器时,拿到的是当时列表的快照
for (String item : list) {
System.out.println(Thread.currentThread().getName() + " is reading: " + item);
Thread.sleep(50); // 模拟耗时操作
}
// 即使在遍历过程中,写线程添加了新元素,这里也不会抛出 ConcurrentModificationException
System.out.println(Thread.currentThread().getName() + " finished reading.");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
executorService.shutdown();
}
}
运行结果分析:
你会看到,读线程在遍历列表的同时,写线程也在添加新元素。
- 读线程的遍历过程不会被中断,也不会抛出
ConcurrentModificationException。 - 某个读线程开始遍历时,它只能看到那个时刻列表中的元素。即使在它遍历的中途,写线程加入了新元素,这个读线程的当前这次遍历也看不到新加入的元素。这就是“快照”的体现。
总结
CopyOnWriteArrayList 是一个用空间换时间的并发容器。它通过在写操作时付出昂贵的复制成本,来换取读操作的无锁和高性能。
记住它的核心权衡:
为了极致的读性能和无锁的迭代器安全,牺牲了写性能和数据的实时强一致性。
因此,在选择是否使用它时,请务必仔细分析你的业务场景是否符合“读多写少”的特点。