Java并发包(JUC)中提供的CopyOnWriteArrayList是一个独特的线程安全列表实现。它以其“写时复制”的核心思想,巧妙地解决了读多写少场景下的并发性能瓶颈。
从一个尴尬的场景说起
设想一个常见的协作场景:公司有一块公共白板,上面记录着任务安排。多数同事需要频繁查看(读操作),而经理偶尔会更新任务(写操作)。
若采用最直接的方式——每次访问都锁住白板,那么当多人同时查看时,效率会变得非常低下。这类似于JDK早期提供的Vector类,虽然保证了线程安全,但代价是性能损耗。而普通的ArrayList则完全不处理并发访问,在并发读写下极易出现数据不一致的问题。
那么,是否存在一种机制,既能允许多人并发读取,又能在写入时不阻塞读取操作呢?答案就是CopyOnWriteArrayList。
什么是CopyOnWriteArrayList?
CopyOnWriteArrayList,顾名思义,其核心机制在于“写时复制”。任何修改操作(增、删、改)都不是在原数据上直接进行,而是先将底层数组完整复制一份,在副本上执行修改,最后再将副本的引用原子性地替换掉原有数组。
// 关键代码展示:add方法
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); // 1. 复制新数组
newElements[len] = e; // 2. 在副本上修改
setArray(newElements); // 3. 替换原数组引用
return true;
} finally {
lock.unlock();
}
}
代码简洁,但其背后蕴含的设计思想却非常精妙。
核心原理:写时复制机制
读写分离的艺术
CopyOnWriteArrayList的核心是彻底的读写分离:
- 读操作:完全无锁,多个线程可以并发读取,性能极高。
- 写操作:通过
ReentrantLock保证线程安全,每次写操作都会触发一次全量数组复制。
这完美对应了白板的比喻:经理修改任务时,并非擦掉原白板重写,而是将内容誊抄到一张新白板上并进行修改,完成后再将新白板替换上去。在此期间,其他同事查看的始终是旧白板的内容,完全不受影响。
关键技术实现
- volatile数组保证可见性:内部数组引用由
volatile修饰,确保新数组一旦被设置,对所有线程立即可见。
- ReentrantLock保证写原子性:所有写操作共享同一把锁,确保同一时刻只有一个线程能执行写操作。
- 迭代器的快照特性:其迭代器在创建时捕获了当前数组的快照,因此遍历过程中不会因其他线程的修改而抛出
ConcurrentModificationException,这是Java并发编程中处理迭代安全的一种经典模式。
深入源码:看看实际如何工作
添加元素的过程
以add方法为例,其流程清晰严谨:
- 获取锁:确保写操作的独占性。
- 复制数组:创建原数组的一个完整副本,长度通常+1。
- 修改副本:将新元素添加到副本的末尾。
- 替换引用:将
volatile修饰的数组引用指向新数组。
- 释放锁:写操作完成。
这个过程如同餐厅更新菜单:并非在旧菜单上涂改,而是制作包含新菜品的新菜单并替换旧菜单,食客在更新期间仍可正常点餐。
读取元素的极致简单
与复杂的写操作相比,读操作简单到不可思议:
public E get(int index) {
return get(getArray(), index);
}
private E get(Object[] a, int index) {
return (E) a[index];
}
没有锁!没有同步!这正是其读取性能如此卓越的根本原因,也是高性能编程中“空间换时间”策略的典型体现。
优缺点分析:没有银弹,只有合适场景
优势明显
- 极高的读取性能:读操作完全无锁,支持高并发读取。
- 线程安全:写操作通过锁和复制机制保证了线程安全。
- 遍历安全:迭代器基于快照,不会抛出
ConcurrentModificationException。
缺点不容忽视
- 内存占用大:每次写操作都复制整个数组,在数据量大时内存消耗翻倍,可能引发GC压力。
- 弱数据一致性:读操作可能无法立即读到最新的写入结果,只保证最终一致性。
- 写性能差:数据量越大,复制成本越高,写操作性能越低。
实战应用场景
1. 事件监听器列表
在观察者模式或GUI编程中,监听器管理是典型的读多写少场景。
public class EventManager {
private final CopyOnWriteArrayList<EventListener> listeners = new CopyOnWriteArrayList<>();
// 读多:事件触发时频繁遍历监听器
public void fireEvent(Event event) {
for (EventListener listener : listeners) { // 安全高效的遍历,无需同步
listener.onEvent(event);
}
}
// 写少:监听器的注册和注销相对较少
public void addListener(EventListener listener) {
listeners.add(listener);
}
}
2. 缓存系统
对于读多写少的缓存数据(如商品分类、配置项列表),CopyOnWriteArrayList非常适合。
public class ProductCatalog {
private volatile CopyOnWriteArrayList<Product> hotProducts = new CopyOnWriteArrayList<>();
// 高频读取:用户查询热销商品
public List<Product> getHotProducts() {
return new ArrayList<>(hotProducts); // 无需同步,极速读取
}
// 低频更新:定时任务更新列表
public void updateHotProducts(List<Product> newProducts) {
hotProducts = new CopyOnWriteArrayList<>(newProducts); // 原子性替换
}
}
3. JDBC驱动注册
在java.sql.DriverManager中,就使用了CopyOnWriteArrayList来管理已注册的JDBC驱动,这是一个非常经典的Java标准库应用案例。
// JDK源码简化示意
public class DriverManager {
private final static CopyOnWriteArrayList<DriverInfo> registeredDrivers = new CopyOnWriteArrayList<>();
public static synchronized void registerDriver(java.sql.Driver driver) {
// ... 将驱动信息添加到registeredDrivers(写操作)
}
private static void loadInitialDrivers() {
// ... 遍历registeredDrivers寻找驱动(读操作)
}
}
与Vector的对比:为什么选择CopyOnWriteArrayList?
很多开发者会疑惑:Vector同样是线程安全的,为何还要使用CopyOnWriteArrayList?关键在于锁的粒度:
- Vector:所有操作(包括
get)都使用synchronized同步,读写均加锁。
- CopyOnWriteArrayList:仅写操作加锁,读操作完全无锁。
在读多写少的场景下,这种差异带来的性能提升可能是数量级的。下表对比了两者差异:
| 特性 |
Vector |
CopyOnWriteArrayList |
| 读性能 |
差(全程加锁) |
极佳(完全无锁) |
| 写性能 |
一般 |
差(数据量大时) |
| 内存占用 |
正常 |
高(写时复制) |
| 数据一致性 |
强一致性 |
最终一致性 |
| 适用场景 |
读写均衡 |
读多写少 |
使用技巧与注意事项
- 适合数据量小的集合:数据量越大,写操作复制的成本越高。
- 严格用于读多写少场景:写操作越频繁,性能问题和内存压力越突出。
- 理解迭代器的弱一致性:迭代器反映的是创建时刻的快照,而非最新数据。
- 批量写入优化:避免连续多次单元素写入,应尽量合并为一次批量操作。
// 不推荐:多次写操作,多次数组复制
for (String item : items) {
copyOnWriteList.add(item);
}
// 推荐:单次批量操作,只需一次数组复制
copyOnWriteList.addAll(Arrays.asList(items));
总结
CopyOnWriteArrayList通过写时复制技术,以空间和写性能为代价,换取了读操作的无锁极致性能。它巧妙地运用读写分离思想,解决了特定并发场景下的痛点。
然而,没有放之四海而皆准的解决方案。它不适用于写操作频繁或数据量巨大的场景。在并发编程中,深刻理解每种工具的原理与代价,根据实际场景(读多写少 vs 写多读少,数据量大小,一致性要求)做出最合适的选择,是开发者必备的能力。