CopyOnWriteArrayList原理与使用场景详解(一文秒懂)

CopyOnWriteArrayList原理与使用场景详解(一文秒懂)-mikechen

CopyOnWriteArrayList是Java并发中提供的一个并发容器,它是个线程安全且读操作无锁的ArrayList,写操作则通过创建底层数组的新副本来实现,是一种读写分离的并发策略,我们也可以称这种容器为”写时复制器”。

什么是COW写时复制

CopyOnWrite简称COW,当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。

这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。

所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。

 

CopyOnWriteArrayList简介

CopyOnWriteArrayList是ArrayList的线程安全版本,从它的名字可以推测,CopyOnWriteArrayList是在有写操作的时候会copy一份数据,然后写完再设置成新的数据。

JDK 中提供了 CopyOnWriteArrayList 类,为了将读取的性能发挥到极致,CopyOnWriteArrayList 读取是完全不用加锁的,并且更厉害的是写入也不会阻塞读取操作。

只有写入和写入之间需要进行同步等待,这样一来,读操作的性能就会大幅度提升。

 

CopyOnWriteArrayList使用场景

CopyOnWriteArrayList适合使用在数据读多写少的情况下,如果数据对实时性要求比较高的业务场景则不适合使用CopyOnWriteArrayList。

在很多应用场景中,读操作可能会远远多于写操作。比如,有些系统级别的信息,往往只需要加载或者修改很少的次数,但是会被系统内所有模块频繁的访问。对于这种场景,我们最希望看到的就是读操作可以尽可能的快,而写即使慢一些也没关系。

读多写少

黑名单是最典型的场景,假如我们有一个搜索网站,用户在这个网站的搜索框中,输入关键字搜索内容,但是某些关键字不允许被搜索。这些不能被搜索的关键字会被放在一个黑名单中,黑名单并不需要实时更新,可能每天晚上更新一次就可以了。当用户搜索时,会检查当前关键字在不在黑名单中,如果在,则提示不能搜索。这种读多写少的场景也很适合使用 CopyOnWrite 集合。

 

CopyOnWriteArrayList优缺点

CopyOnWriteArrayList的优点

CopyOnWriteArrayList的优点主要有两个:

  • 线程安全
  • 大大的提高了“读”操作的并发度(相比于Vector)

CopyOnWriteArrayList的缺点

1. 内存占用太大,缺点很明显的就是在wirte操作的时候,我们发现它都是使用Arrays.copyOf复制到一个新数组来完成的,如果有频繁的wirte操作就比较的占用内存,可能引起频繁的JVMYong GC和Full GC操作,write操作效率比较低;

2. 无法保证数据的实时一致性,因为其读和写在不同的array中操作,所以如果多个线程在操作,例如线程A、B,如果想要保证线程A写入线程B马上能读到就不行,但是不适合对实时性要求比较高的场景。

 

CopyOnWriteArrayList实现原理

我们都知道,集合框架中的ArrayList是非线程安全的,Vector虽是线程安全的,但由于简单粗暴的锁同步机制,性能较差,而CopyOnWriteArrayList则提供了另一种不同的并发处理策略(当然是针对特定的并发场景)。

很多时候,我们的系统应对的都是读多写少的并发场景,CopyOnWriteArrayList容器允许并发读,读操作是无锁的,性能较高。

至于写操作,比如向容器中添加一个元素,则首先将当前容器复制一份,然后在新副本上执行写操作,结束之后再将原容器的引用指向新容器。

 

下面我们就来一起看看CopyOnWriteArrayList是如何实现线程安全的。

public class CopyOnWriteArrayList<E>
    implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
    private static final long serialVersionUID = 8673264195747942595L;

    /** The lock protecting all mutators */
    final transient ReentrantLock lock = new ReentrantLock();

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

从源码中我们可以知道CopyOnWriteArrayList这和ArrayList底层实现都是通过一个Object的数组来实现的,只不过 CopyOnWriteArrayList的数组是通过volatile来修饰的,还有新增了ReentrantLock

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);
            newElements[len] = e;
            // 把新数组的值 赋给原数组
            setArray(newElements);
            return true;
        } finally {
            // 释放锁
            lock.unlock();
        }
    }

上述源码我们可以发现比较简单,有几个点需要稍微注意下

  • 增加数据的时候是通过ReentrantLock加锁操作来(在jdk11的时候采用了synchronized来替换ReentrantLock)保证多线程写的时候只有一个线程进行数组的复制,否则的话内存中会有多份被复制的数据,导致数据错乱。
  • 数组是通过volatile 修饰的,根据 volatile 的 happens-before 规则,写线程对数组引用的修改是可以立即对读线程是可见的。
  • 通过写时复制来保证读写在两个不同的数据容器中进行操作。

get方法

get(int index)方法用于获取指定位置的元素,源码如下:

/**
 * {@inheritDoc}
 *
 * @throws IndexOutOfBoundsException {@inheritDoc}
 */
public E get(int index) {
    // 调用内部get方法
    return get(getArray(), index);
}
 
@SuppressWarnings("unchecked")
private E get(Object[] a, int index) {
    return (E) a[index];
}

可以看到get(int index)不需要加锁,因为CopyOnWriteArrayListadd/remove操作时,不会修改原数组,所以读操作不会存在线程安全问题。

这其实就是读写分离的思想,只有写入的时候才加锁,复制副本来进行修改。

 

CopyOnWriteArrayList总结

CopyOnWriteArrayList利用ReentrantLock + volatile + 数组拷贝实现了线程安全的ArrayList,CopyOnWriteArrayList适合读远大于写的场景。

作者简介

陈睿|mikechen,10年+大厂架构经验,BAT资深面试官,就职于阿里巴巴、淘宝、百度等一线互联网大厂。

👇阅读更多mikechen架构文章👇

阿里架构 |双11秒杀 |分布式架构 |负载均衡 |单点登录 |微服务 |云原生 |高并发 |架构师

以上

关注作者「mikechen」公众号,获取更多技术干货!

后台回复架构,即可获取《阿里架构师进阶专题全部合集》,后台回复面试即可获取《史上最全阿里Java面试题总结

评论交流
    说说你的看法