CopyOnWriteArrayList源码解析

  • 1.整体架构
  • 2.源码解析
  • 新增元素
  • 尾部新增元素
  • 添加至某索引处
  • 批量添加
  • 删除元素
  • 索引位置删除
  • 批量删除
  • 其他方法
  • indexOf方法
  • 迭代
  • 总结



ArrayList在作为共享变量存在时,是线程不安全的。JDK中保证对ArrayList操作是线程安全的方法有如下3种,

  1. 编程过程中对共享变量进行操作的代码自行加锁
  2. 使用Collections.synchronizedList方法
  3. 使用CopyOnWriteArrayList类

1.整体架构

整体架构上,CopyOnWriteArrayList的数据结构与ArrayList是相同的,底层是数组。只不过CopyOnWriteArrayList在对其数组数据进行操作的过程中会分为4步:

  1. 加锁
  2. 对原数组进行拷贝,得到新的数组,新数组除内存地址外其余都与原数组相同
  3. 新数组上进行操作,操作完成后原数组指向新数组的引用
  4. 解锁

除了加锁之外,CopyOnWriteArrayList的数组还被volatile关键字修饰,意思是一旦数组被修改,其余线程可以立刻感知到并更新引用。整体而言,CopyOnWriteArrayList就是利用锁+数组拷贝+volatile关键字保证了List的线程安全。

CopyOnWriteArrayList的构造方法如下,

public class CopyOnWriteArrayList<E>
    implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
    // 内部的锁对象,保证一个时刻仅有一个线程持该锁
    final transient ReentrantLock lock = new ReentrantLock();

    // volatile关键字修饰的数组
    private transient volatile Object[] array;

	// 无参数初始化,初始化一个空数组
	public CopyOnWriteArrayList() {
        setArray(new Object[0]);
    }

	// 使用集合初始化
	public CopyOnWriteArrayList(Collection<? extends E> c) {
        Object[] elements;
        if (c.getClass() == CopyOnWriteArrayList.class)
            elements = ((CopyOnWriteArrayList<?>)c).getArray();
        else {
            elements = c.toArray();
            // c.toArray might (incorrectly) not return Object[] (see 6260652)
            if (elements.getClass() != Object[].class)
                elements = Arrays.copyOf(elements, elements.length, Object[].class);
        }
        setArray(elements);
    }

	// 使用数组初始化
	public CopyOnWriteArrayList(E[] toCopyIn) {
        setArray(Arrays.copyOf(toCopyIn, toCopyIn.length, Object[].class));
    }
    ......
}

CopyOnWriteArrayList的类注释中包含该类的一些信息,

  1. 所有的操作都是线程安全的,因为都是在新拷贝的数组上进行的
  2. 数组的拷贝有一定的成本,但是往往比其他的替代方法要高效
  3. CopyOnWriteArrayList在得到过程中如果原数组被修改,不会抛出ConcurrentModificationException异常

2.源码解析

新增元素

新增元素与ArrayList一样,支持数组尾部、某索引处、批量新增等。

尾部新增元素

尾部新增实现方式最简单,源码如下,

public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    // 加锁
    lock.lock();
    try {
        // 得到底层的原数组
        Object[] elements = getArray();
        int len = elements.length;
        // 原数组元素拷贝到新数组里面,新数组的长度是 原数组长度+1
        Object[] newElements = Arrays.copyOf(elements, len + 1);
        // 在新数组中进行赋值,新元素直接放在数组的尾部
        newElements[len] = e;
        // 替换掉原来的数组
        setArray(newElements);
        return true;
    // finally 里面释放锁,保证即使 try 发生了异常,仍然能够释放锁   
    } finally {
        lock.unlock();
    }
}

源码中可知,整个add过程都是在持锁状态下进行的,保证一个时刻内只有一个线程持锁。

除了加锁之外还会从原数组中创建一个新数组,把原数组的值拷贝到新数组上。此处有一个需要理解的关键点。为什么在上锁的情况下,仍然需要拷贝数组而不是在原数组上进行操作?

  1. volatile关键字修饰的是数组,如果简单的在原数组上修改几个元素的值,是无法触发内存可见性的。只有通过修改数组地址的方式才能触发数组可见性,所以需要重新赋值。
  2. 在新的数组上进行拷贝,对原数组没有任何影响。只有新数组完全拷贝成功后,外部才能访问到,降低了赋值过程中原数组数值变动的影响。

添加至某索引处

public void add(int index, E element) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    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;
        // numMoved相当于添加在末尾
        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);
    } finally {
        lock.unlock();
    }
}

基本原理与add方法相同,只不过在拷贝原数组过程中进行了两次复制。

批量添加

批量添加比使用for循环调用add方法的效率要高很多,

public boolean addAll(Collection<? extends E> c) {
    Object[] cs = (c.getClass() == CopyOnWriteArrayList.class) ?
        ((CopyOnWriteArrayList<?>)c).getArray() : c.toArray();
    if (cs.length == 0)
        return false;
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] elements = getArray();
        int len = elements.length;
        // 如果当前CopyOnWriteArrayList底层是空数组
        if (len == 0 && cs.getClass() == Object[].class)
            setArray(cs);
        else {	
        	// 一次性扩容操作
            Object[] newElements = Arrays.copyOf(elements, len + cs.length);
            System.arraycopy(cs, 0, newElements, len, cs.length);
            setArray(newElements);
        }
        return true;
    } finally {
        lock.unlock();
    }
}

高效的原因在于只进行了一次扩容操作。

删除元素

删除元素的过程与添加元素十分相似,分为3步,

  1. 加锁
  2. 判断索引位置,并对数组进行拷贝移除要删除的元素
  3. 解锁

索引位置删除

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 {
            // 如果删除的数据在数组的中间,分三步走
            // 1. 设置新数组的长度减一,因为是减少一个元素
            // 2. 从 0 拷贝到数组新位置
            // 3. 从新位置拷贝到数组尾部
            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();
    }
}

删除过程与添加过程都是在持锁状态下进行,同时底层数组会指向新的内存地址。

批量删除

批量删除源码如下,

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;
        // 数组有值继续进行,若数组为空直接返回 false
        if (len != 0) {
            // newlen 表示新数组的索引位置,新数组中存在不包含在 c 中的元素
            int newlen = 0;
            // 注意新数组temp与原数组相同长度
            Object[] temp = new Object[len];
            // 循环,把不包含在 c 里的元素放到新数组中
            for (int i = 0; i < len; ++i) {
                Object element = elements[i];
                // 不包含在 c 中的元素,从 0 开始放到新数组中
                if (!c.contains(element))
                    temp[newlen++] = element;
            }
            // 拷贝新数组,变相的删除了不包含在 c 中的元素
            if (newlen != len) {
                setArray(Arrays.copyOf(temp, newlen));
                return true;
            }
        }
        return false;
    } finally {
        lock.unlock();
    }
}

上方代码中,新建的数组初始化长度与原数组相同,主要是考虑到集合C中的元素并非全部是数组中的元素。如果一次性初始化为java for循环删除元素问题_加锁的长度可能会出错。

批量删除操作比使用for循环调用remove方法的效率高的多,主要是避免了重复多次的数组拷贝。

其他方法

indexOf方法

该方法用于查找数组中指定元素下标的位置。如果存在则返回索引值;反之,返回-1。支持null值搜索,还支持正向和反向查找,以正向查找为例,

// o:需要搜索的元素
// elements:底层数组
// index:搜索的开始位置
// fence:搜索的结束位置
private static int indexOf(Object o, Object[] elements,
                           int index, int fence) {
    // 支持对 null 的搜索
    if (o == null) {
        for (int i = index; i < fence; i++)
            // 找到第一个 null 值,返回下标索引的位置
            if (elements[i] == null)
                return i;
    } else {
        // 通过 equals 方法来判断元素是否相等
        // 如果相等,返回元素的下标位置
        for (int i = index; i < fence; i++)
            if (o.equals(elements[i]))
                return i;
    }
    return -1;
}

indexOf方法通过equals方法判断元素是否相等,所以如果传入的是自定义类对象,需要实现equals方法。

迭代

CopyOnWriteArrayList在得到过程中如果原数组被修改,不会抛出ConcurrentModificationException异常的原因在于其迭代器的实现方式。

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;
    }
	......
}

COWIterator类时CopyOnWriteArrayList类中的内部类,在调用Iterator方法时,会创建COWIterator类对象,snapshot引用的是当前底层数组的地址,这一点与ArrayList区分

下面的例子进行说明,在迭代过程中修改原数组是不会影响迭代器的。因为迭代器引用的是原数组,而CopyOnWriteArrayList修改是在新数组上进行的。

java for循环删除元素问题_java for循环删除元素问题_02


对CopyOnWriteArrayList对象进行add操作,

java for循环删除元素问题_java for循环删除元素问题_03


操作后,list对象底层的数组引用是新的内存地址。此时对Iterator对象进行迭代,

java for循环删除元素问题_加锁_04


迭代器对象引用的还是list对象修改之前的数组的内存地址,所以即使list发生变动,不会影响迭代器。

总结

在线程不安全的情况下,建议使用CopyOnWriteArrayList替代ArrayList对象。