并发中的ArrayList——CopyOnWriteArrayList源码阅读

前言

本文原载于我的博客,地址:https://blog.guoziyang.top/archives/62/

ArrayList类的注释中,就已经提到了,ArrayList是线程不安全的类,不建议作为线程的共享变量使用。那么,是否有线程安全的List呢?当然有,那就是Vector……才怪

Vector类的方法仅仅是将所有的方法都加上的synchronized关键字,强制将并发转为串行,效率低下。好在,JDK在java.util.concurrent包(即常说的JUC)下,提供了一个线程安全的另一个List,即CopyOnWriteArrayList

PS:并发状态下使用List也可以使用Collections.synchronizedList方法。

概览

这个类的签名如下:

public class CopyOnWriteArrayList<E>
    implements List<E>, RandomAccess, Cloneable, java.io.Serializable

类注释中是这样说的:

这个类是ArrayList的一个线程安全的版本,所有的修改操作,如add、set等,都是通过复制底层数组实现的。

CopyOnWrite,简称COW,即写时复制,如果学过操作系统的同学可能就会知道,在Linux系统底层并发相关的部分,大量使用了这个思想。

在CopyOnWriteArrayList内部,通过锁 + 数组拷贝 + volatile关键字保证了线程安全。而数组拷贝,通常发生在修改时,即CopyOnWrite。

根据类注释,我们就知道,我们只需要关注修改相关的操作即可。

重要的成员变量

CopyOnWriteArrayList类中有两个比较重要的成员变量,如下:

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

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

一个是JUC中的可重入锁,ReentrantLock对象,用于修改操作时的加锁,另一个就是一个Object数组,很显然,它用来实际存储内容,即“底层数组”。

Object数组对象array,被volatile关键字修饰,保证数组在修改时立刻会被其他线程感知到,因为使用该关键字变量在使用时都必须从内存中获取最新的,而不允许使用缓存。

添加元素

add(E e)方法用于向数组中的末尾添加元素。源码如下:

    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();
        }
    }

该方法的第一件事即加锁,注意所有的修改方法的第一件事都是加锁,这样就保证了同一时刻只能有一个线程在修改数组,即修改操作是串行化的。

try子句中,首先获取到了elements,即List的底层数组,接着在第7行,通过Arrays.copyOf方法,将原数组拷贝到了一个新的数组中,这个数组的长度是原数组长度加一。第8行的添加操作也是在原数组上完成的,最终,通过setArray方法使用新数组替换原数组的引用,setArray方法也很简单:

    /**
     * Sets the array.
     */
    final void setArray(Object[] a) {
        array = a;
    }

那么原数组哪去了呢?原数组可能会在下一次垃圾回收的时候被回收掉。

这就是一个很明显的CopyOnWrite的体现,那么,为什么要将数组复制一份,而不是直接在原来的数组上操作呢?有一个很重要的地方,就是volatile关键字。volatile关键字修饰的是数组,即数组的这个引用,如果只是修改数组中的元素时,可见性是无法保证的,所以必须要修改数组的地址,即创建一个新的数组。而且,在新的数组上进行添加等操作,对老数组没有影响,只有拷贝完全后,外界才能访问到,降低了在赋值过程中,老数组数据变动的影响。

这是添加到尾部的代码,而添加到某个固定位置的方法,即add(int index, E element)方法的核心实现是这样的(try子句的内容):

            Object[] elements = getArray();
            int len = elements.length;
            if (index > len || index < 0)
                throw new IndexOutOfBoundsException("Index: "+index+
                                                    ", Size: "+len);
            Object[] newElements;
            int numMoved = len - index;
            if (numMoved == 0)
                newElements = Arrays.copyOf(elements, len + 1);
            else {
                newElements = new Object[len + 1];
                System.arraycopy(elements, 0, newElements, 0, index);
                System.arraycopy(elements, index, newElements, index + 1,
                                 numMoved);
            }
            newElements[index] = element;
            setArray(newElements);

注意,需要判断一下有没有越界。而且,如果numMoved,也就是index后面的元素个数为0的话,只需要复制一次,不为0的话,即向中间插入,就需要分两次复制到新数组了。

有人可能就问了,哎,怎么没有扩容啊?

众所周知,普通的ArrayList最耗时的操作就是扩容了,所以ArrayList在每次扩容的时候都会预留出一部分空间,以尽量减少扩容的次数。那么扩容为什么耗时呢?因为数组拷贝。现在使用CopyOnWriteArrayList,每次add都要进行数组拷贝,即使预留空间了也要数组拷贝,这个预留空间的意义就不大了,不如每次都放到大小正好的数组里了。

所以,CopyOnWriteArrayList的底层数组,在任何时候,都是没有空位置了,不只是添加,删除后也不会有空位置(顺便填上了)。

set与add类似,只是复制到相同大小的数组里就行了。

删除元素

remove方法如下:

	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)
                setArray(Arrays.copyOf(elements, len - 1));
            else {
                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();
        }
    }

方法开头,很常规的加锁。接着计算numMoved,这个值是被删除元素的后面元素的个数。如果这个数为0,也就是删除结尾的元素,只需要进行一次数组复制,复制到长度减1的数组即可。否则,说明是删除了中间的元素,就需要分两次进行复制了。

完成了删除操作后,底层数组的长度减1,即没有产生空位。

List的批量删除操作,即removeAll(),通过传入一个集合,删除原集合中所有存在于这个集合中的元素。实现如下:

    public boolean removeAll(Collection<?> c) {
        if (c == null) throw new NullPointerException();
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            Object[] elements = getArray();
            int len = elements.length;
            if (len != 0) {
                // temp array holds those elements we know we want to keep
                int newlen = 0;
                Object[] temp = new Object[len];
                for (int i = 0; i < len; ++i) {
                    Object element = elements[i];
                    if (!c.contains(element))
                        temp[newlen++] = element;
                }
                if (newlen != len) {
                    setArray(Arrays.copyOf(temp, newlen));
                    return true;
                }
            }
            return false;
        } finally {
            lock.unlock();
        }
    }

这个实现很有趣,它没有查找要删除的元素,然后一个一个删除。而是遍历一遍数组,把所有不需要删除的元素挑出来,放到新的数组中,变相地实现了删除元素。

迭代器

我们通过iterator方法可以获得一个List的迭代器:

    public Iterator<E> iterator() {
        return new COWIterator<E>(getArray(), 0);
    }

这里返回的类名为COWIterator,COW即CopyOnWrite,这是一个专为CopyOnWriteArrayList实现的迭代器。构造方法如下:

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

后续的遍历都是对elements数组进行遍历。

注意,在遍历过程中,如果进行修改的话,和ArrayList不同的是,并不会抛出ConcurrentModification异常。为什么呢?

我们注意到,通过iterator方法创建的迭代器,传入的是底层数组的引用,那么在迭代过程中,如果产生了修改,因为使用了COW技术,是由一个新的数组替换了老的数组的引用,但是此时,迭代器内部仍然使用的是老数组,也就是说,整个迭代期间,迭代器都会使用创建迭代器时的底层数组。如果在迭代过程中进行了多次修改,只有最后一次才会生效。

总结

CopyOnWriteArrayList的实现中,读取时不需要对对象加锁,只有修改时需要加锁,而且修改时需要拷贝数组,性能较差,所以CopyOnWriteArrayList适用于读多写少对情景。而且,由于在修改时是对新数组进行修改,接着替换引用,那么在并发状态下,读线程可能会读取到旧的数据。

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值