CopyOnWriteArrayList源码阅读:并发读写的艺术

21 阅读5分钟

在多线程环境下,直接使用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)列表等。