在某些读多写少的多线程环境下,基于 CopyOnWrite 思想的 CopyOnWriteArrayList 容器和 CopyOnWriteArraySet 容器可以保证线程安全,能够确保读操作和写操作之间不会被阻塞。本文将会从 线程不安全的 ArrayList 入手,由 CopyOnWrite 思想引申到 CopyOnWriteArrayList,通过示例说明它是怎么保证线程安全的,适用的场景,最后对其进行总结。

概述

之前的文章《JDK 源码之集合类 ArrayList》介绍了 ArrayList 不是线程安全的容器,在多个线程同时访问同一个 ArrayList 的时候,需要特别注意线程安全的问题。当某个线程 A 在读取 ArrayList 的时候,如果某个线程 B 向 ArrayList 中写数据,由于fail-fast机制,会抛出 ConcurrentModificationException 异常。

fail-fast 机制:

  • fail-fast 即快速失败机制,属于 Java 集合中的错误检测机制,当多个线程在结构上对集合进行改变时,有可能会发生 fail-fast 机制。
  • 一般在遍历集合的时候会使用迭代器,如果在遍历集合元素的过程中集合的结构被改变的话(对集合中的元素进行插入和删除操作属于结构的改变,而对其中某个元素的修改则不属于结构的改变),就会抛出异常,防止继续遍历。

既然 ArrayList 不是线程安全的,那为什么不使用 Vector 或者 Collections 的静态方法将 ArrayList 包装成一个线程安全的类呢

因为这些方法都使用的是 synchronized 关键字实现的线程同步,也就是利用独占锁的方式保证线程安全。因此,在同一时刻只能有一个线程获取到对象锁,效率不是很高。在读多写少的场景下,由于多个读线程从同一个容器中读取数据,而不会对数据进行修改,因此可以想到使用 ReentrantReadWriteLock,通过读写分离的思想,使读读操作之间不会阻塞。但是,如果使用了 ReentrantReadWriteLock 进行线程同步的话,当某个线程进行写操作时,会造成其它线程的读写操作都会阻塞的情况。

因此,如果想要解决以上的问题,并且能够实现读写操作不会发生阻塞的话,可以使用 CopyOnWriteArrayList 或 CopyOnWriteArraySet。

Copy-On-Write 思想

从字面意思上看,CopyOnWrite 表示在写操作的时候进行复制的一种机制,它属于计算机设计领域的一种优化策略。假如在系统内部有多个调用者同时请求相同的资源,那么这些调用者会获得指向该资源的指针,由于资源相同,因此这些调用者的指针也是相同的。如果某个调用者试图修改资源的内容,也就是对资源进行写操作,那么此时系统会复制一份专用的副本给该调用者,而其它调用者所看见的资源仍是不变的。基于这种延迟懒惰策略,其优点在于如果没有调用者修改资源,则就不会有副本的创建,因此多个调用者在进行读取操作时可以共享同一份资源。

如果简单的使用读写锁的话,当某个线程获取了写锁后,其它线程的读操作和写操作将会被阻塞,只有等之前的写锁被释放以后,其它线程才能进行读写操作。从线程的读操作来看,读操作在任务时候都是获取到最新的数据,因此满足数据的实时性。而如果可以稍微牺牲数据的实时性而满足数据的最终一致性的话,那么就可以使用 CopyOnWrite 的写时复制思想 CopyOnWriteArrayList 进行实现。

通俗的理解 CopyOnWrite 思想,当我们在往一个容器中添加元素的时候,不是直接往当前的容器添加数据,而是先将当前容器进行 copy,即复制一个新的容器。往新的容器中添加元素,添加完成以后,再将原容器的引用指向新的容器。对 CopyOnWrite 容器进行并发读操作时不需要加锁,因为当前元素不会添加任何元素。因此,CopyOnWrite 实现的是一种读写分离思想,延迟更新的策略是通过在写操作的时候针对的是不同的数据容器来实现的,放弃数据实时性的同时达到数据的最终一致性。

CopyOnWriteArrayList

通过继承图可以看到,CopyOnWriteArrayList 实现了 List 接口,并且可以被迭代。下面通过 CopyOnWriteArrayList 类中的一些成员变量和成员方法来分析它为什么是线程安全的。

1
2
3
4
5
/** The lock protecting all mutators */
final transient ReentrantLock lock = new ReentrantLock();

/** The array, accessed only via getArray/setArray. */
private transient volatile Object[] array;

可以看到,这里使用了 ReentrantLock 来支持并发操作,而用来存放数组对象的 array 是被 volatile 修饰的,volatile 的作用是一方面可以保证 array 在内存的可见性,另一方面可以禁止指令的重排。详见 Java 中的 volatile。当然,这里最终要的还是 CopyOnWriteArrayList 中读写操作的实现,如下所示:

add(E e) 方法的实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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);
        // 将指定的元素 e 插入到新数组中的末尾
        newElements[len] = e;
        // 设置新数组对象
        setArray(newElements);
        return true;
    } finally {
        // 释放锁
        lock.unlock();
    }
}

add(E e) 方法的作用是将指定的元素 e 插入到新数组的末尾。首先使用 ReentrantLock 保证同一时刻只能有一个写线程正在进行数组的复制,否则会在内存中产生多份被复制的数据。由于数组是被 volatile 修饰的,因此根据 happens-before 规则,写线程对数组引用的修改操作,对读线程是可见的。由于是在新数组中进行写入数据的,因此能够保证读写操作是在两个不同的数据容器中进行的。

需要注意的是,这里的 volatile 所修饰的仅仅是数组的引用,也就是对数组引用的可见性,而对于数组中元素的修改不能保证可见性,通过setArray(newElements);语句可以看出。

get() 方法的实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public E get(int index) {
    return get(getArray(), index);
}

private E get(Object[] a, int index) {
    return (E) a[index];
}

final Object[] getArray() {
    return array;
}

get 方法没有进行加锁或者一些其它的操作,这是因为所有的读操作只是会读取容器中的数据,而不会进行修改。因此读操作是允许多个线程同时访问的。

迭代时的注意事项

看一下例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public static void main(String[] args) {
    CopyOnWriteArrayList<String> writeArrayList = new CopyOnWriteArrayList<>();

    writeArrayList.add("a");
    writeArrayList.add("b");

    Iterator<String> iterator = writeArrayList.iterator();

    writeArrayList.add("c");

    while (iterator.hasNext()) {
        System.out.println(iterator.next());
    }
}

程序会输出 a 和 b,而不会输出 c。这是因为当调用writeArrayList.iterator();时会返回 CopyOnWriteArrayList 下的内部实现类 COWIterator 对象,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public Iterator<E> iterator() {
    return new COWIterator<E>(getArray(), 0);
}


static final class COWIterator<E> implements ListIterator<E> {
    /** Snapshot of the array */
    private final Object[] snapshot;
    /** Index of element to be returned by subsequent call to next.  */
    private int cursor;

    private COWIterator(Object[] elements, int initialCursor) {
        cursor = initialCursor;
        snapshot = elements;
    }
}

也就是说,在遍历的时候首先会获得当前数组对象的一个拷贝,即快照 snapshot,后序的遍历操作会在该快照上进行。因此,如果在获取快照之后再向数组中添加元素的话,则之后添加的元素将不会被遍历到。因此,上面的程序只会输出 a 和 b,而不会再输出 c。

总结

这里没有谈到 CopyOnWriteArraySet,其实 CopyOnWriteArraySet 是 Set 的线程安全版本,它的内部通过 CopyOnWriteArrayList 来代理实现读写操作。区别在于 CopyOnWriteArraySet 不允许将多个相同的元素插入到容器中。

  • CopyOnWrite 容器具有以下特点:
    • 读取容器中的元素时,不加锁;
    • 写操作会加锁,即多个线程进行写操作时会逐个获取锁后才进行写入;
    • 写操作不会影响读操作,即线程在进行读操作时,不会因为有线程正在进行写操作而阻塞。
  • CopyOnWrite 并发容器适用于读多写少的并发场景,例如白名单、黑名单,商品类目的访问和更新场景。
  • CopyOnWrite 的缺点在于:
    • 由于写时复制机制的存在,在进行写操作的时候,内存中会存在两个对象占用内存的情况,即旧的对象和新写入的对象。且在复制的时候只是复制容器里的引用,只是在写的时候会创建新对象添加到新容器里,而旧容器的对象还是在使用的,所以会存在两个对象。如果内存占用比较大的话,有可能造成频繁的 GC 操作,因此会造成内存占用问题。
    • 此外,CopyOnWrite 机制只能保证数据最终的一致性,而不能保证数据的实时性。

参考