在多线程环境下,直接使用ArrayList会引发著名的并发修改异常(并发修改异常)或数据不一致问题。为了解决这个问题,Java 并发包(java.util.concurrent)为我们提供了线程安全的替代方案:CopyOnWriteArrayList。
它的名字已经剧透了它的核心设计思想:写时复制(Copy-On-Write,简称COW) 。今天,我们将通过阅读源码,来看看它是如何通过这种优雅的机制实现高并发读写的。
1. 核心成员变量与锁的定义
在看具体的方法前,我们必须先了解它最核心的两个成员变量:锁和底层数组。
Java
// 1. 独占锁:用于保证写操作(add/set/remove等)的线程安全
// (注:JDK 11 及以后版本改用内置锁 synchronized 配合一个 Object monitor,但互斥原理相同)
final transient ReentrantLock lock = new ReentrantLock();
// 2. 真正存储元素的数组:只能通过 getArray/setArray 方法访问
// 【极其关键】volatile 修饰,保证了写线程修改后,读线程能立刻看到(内存可见性)
private transient volatile Object[] array;
final Object[] getArray() {
return array;
}
final void setArray(Object[] a) {
array = a;
}
解读: 所有的修改操作都需要先获取锁,这保证了同一时刻只有一个写线程在工作。而大批被易挥发的修饰,这是读线程无需加锁就能读到最新数据的基石。
2. 得到()方法:绝对的无锁化读
CopyOnWriteArrayList最大的卖点就是读操作完全不加锁,性能极高。
Java
@SuppressWarnings("unchecked")
private E get(Object[] a, int index) {
return (E) a[index];
}
public E get(int index) {
// 1. 调用 getArray() 获取当前最新的 volatile 数组
// 2. 直接根据下标读取
return get(getArray(), index);
}
解读: 读操作极其简单粗暴,就是拿到当前数组直接取值,没有任何锁的参与。因为读操作不加锁,所以如果有其他线程正在执行写操作(如添加),读线程依然读取的是旧数组的数据。这就是经典的最终一致性(弱一致性)思想,牺牲了强一致性换取了极高的读并发性能。
3. 添加()方法:写时复制的体现
添加元素时,COW 的哲学开始发力了。
Java
public boolean add(E e) {
final ReentrantLock lock = this.lock;
// 1. 进来先加锁,保证同一时刻只有一个线程能进行写操作
lock.lock();
try {
// 2. 获取旧数组
Object[] elements = getArray();
int len = elements.length;
// 3. 【核心逻辑】拷贝出一个新数组,长度为旧数组长度 + 1
Object[] newElements = Arrays.copyOf(elements, len + 1);
// 4. 将新元素放入新数组的末尾
newElements[len] = e;
// 5. 将 volatile 变量 array 指向这个新数组
setArray(newElements);
return true;
} finally {
// 6. 释放锁
lock.unlock();
}
}
解读: 修改操作并没有在原数组上进行!而是先上锁,然后复制出一个容量+1 的新数组,在新数组上添加元素后,最后将旧数组的引用指向新数组。因为大批是易挥发的的,引用的修改对其他线程立即可见。
4. 放()方法:修改指定位置元素
放方法用于修改指定索引处的元素,它同样遵循写时复制的原则。
Java
public E set(int index, E element) {
final ReentrantLock lock = this.lock;
lock.lock(); // 加锁
try {
Object[] elements = getArray();
E oldValue = get(elements, index);
// 如果新旧值不同,才真正执行修改操作
if (oldValue != element) {
int len = elements.length;
// 依然是拷贝新数组
Object[] newElements = Arrays.copyOf(elements, len);
// 修改新数组中对应位置的元素
newElements[index] = element;
// 替换引用
setArray(newElements);
} else {
// Not quite a no-op; ensures volatile write semantics
// 就算新旧值一样,也重新 set 一次,为了维持 volatile 的写语义
setArray(elements);
}
return oldValue;
} finally {
lock.unlock();
}
}
解读: 这里有一个非常严谨的细节:如果发现新传入的值和老值一模一样,源码依然执行了设置数组(元素)。虽然看起来多此一举,但这其实是为了触发易挥发的的写屏障,确保在多线程环境下相关的内存可见性语义不被破坏。
5. 消除()方法:数组的切割与拼接
删除元素的过程稍显复杂,因为需要将被删除元素前后的数据重新拼接到新数组中。
Java
public E remove(int index) {
final ReentrantLock lock = this.lock;
lock.lock(); // 加锁
try {
Object[] elements = getArray();
int len = elements.length;
E oldValue = get(elements, index);
// 计算需要移动的元素个数(即删除点之后的元素个数)
int numMoved = len - index - 1;
if (numMoved == 0) {
// 如果删除的是最后一个元素,直接拷贝前 len-1 个元素即可
setArray(Arrays.copyOf(elements, len - 1));
} else {
// 如果删除的是中间元素,则需要创建长度为 len-1 的新数组
Object[] newElements = new Object[len - 1];
// 拷贝被删除元素之前的半段
System.arraycopy(elements, 0, newElements, 0, index);
// 拷贝被删除元素之后的半段
System.arraycopy(elements, index + 1, newElements, index, numMoved);
// 替换引用
setArray(newElements);
}
return oldValue;
} finally {
lock.unlock();
}
}
解读: 删除操作依然是不可变的哲学。通过计算被删除元素的位置,将旧数组切分成两半,分别利用系统.数组复制这个底层的C++ Native 方法拷贝到新数组中。无论怎么删,都不会影响正在并发读取旧数组的线程。
总结与思考
通过阅读CopyOnWriteArrayList的源码,我们可以得出它的优缺点和适用场景:
- 优点: 彻底实现了读写分离,读取数据完全无锁,非常适合读并发极高的场景。
- 缺点: 1. 内存占用高: 每次写操作都要拷贝一个完整的数组,如果数据量大,会导致大量的内存消耗甚至频繁触发GC。 2.数据一致性: 只能保证数据的最终一致性,不能保证实时一致性。如果你刚写入一个数据,立刻去读,可能读到的还是旧数组的数据。
- 适用场景: 读多写少、且对实时一致性要求不高的场景。例如:黑白名单配置、系统的事件监听器(Listener)列表等。