剖析 CopyOnWriteArrayList

原文连接:https://www.changxuan.top/?p=1252java


CopyOnWriteArrayList 是 JUC 中惟一一个支持并发的 List。数据库

CopyOnWriteArrayList 的修改操做都是在底层的一个复制的数组上进行,即写时复制策略,从而实现了线程安全。其实原理和数据库的读写分离十分类似。数组

基本构成

底层使用数组 private transient volatile Object[] array; 来存储元素,使用 ReentrantLock 独占锁保证相关操做的安全性。缓存

构造函数

public CopyOnWriteArrayList() {
        setArray(new Object[0]);
}
// 将集合 c 内的元素复制到 list 中
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);
}
// 建立一个内部元素是 toCopyIn 副本的 list
public CopyOnWriteArrayList(E[] toCopyIn) {
        setArray(Arrays.copyOf(toCopyIn, toCopyIn.length, Object[].class));
}

添加元素

CopyOnWriteArrayList 中与添加元素相关的方法有如下几种:安全

  • add(E e)
  • add(int index, E element)
  • addAll(Collection<? extends E> c)
  • addAll(int index, Collection<? extends E> c)
  • addIfAbsent(E e)
  • addAllAbsent(Collection<? extends E> c)

鉴于原理基本类似,下面只分析 add(E e)addIfAbsent(E e) 方法作为例子。并发

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

进入 add 方法的线程首先会去尝试获取独占锁,成功获取的线程会继续执行后续添加元素逻辑,而未获取独占锁的线程在没有异常的状况下则会阻塞挂起。等待独占锁被释放后,再次尝试获取。(ps. 在 CopyOnWriteArrayList 中使用的是 ReentrantLock 的非公平锁模式)性能

这样就能保证,同一时间只有一个线程进行添加元素。ui

addIfAbsent(E e)

源码this

public boolean addIfAbsent(E e) {
    // 获取当前数组
    Object[] snapshot = getArray();
    // 调用 indexOf 判断元素是否已存在 (遍历)
    return indexOf(e, snapshot, 0, snapshot.length) >= 0 ? false :
        addIfAbsent(e, snapshot);
}

private boolean addIfAbsent(E e, Object[] snapshot) {
    // 获取独占锁
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        // 再次获取当前数组
        Object[] current = getArray();
        int len = current.length;
        // 此处判断是检查在当前线程判断元素不存在和获取独占锁之间的这段时间内是否有其它线程对数组进行了更改操做
        if (snapshot != current) {
            // Optimize for lost race to another addXXX operation
            int common = Math.min(snapshot.length, len);
            for (int i = 0; i < common; i++)
                if (current[i] != snapshot[i] && eq(e, current[i]))
                    return false;
            if (indexOf(e, current, common, len) >= 0)
                    return false;
        }
        // 与 add 方法的逻辑相同
        Object[] newElements = Arrays.copyOf(current, len + 1);
        newElements[len] = e;
        setArray(newElements);
        return true;
    } finally {
        lock.unlock();
    }
}

若是当前数组中不存在元素 eaddIfAbsent 则会将 e 添加至数组中返回 true;若是当前数组中存在待添加元素,则会返回 false

addIfAbsent 方法中为了提升性能,设计者把判断“当前数组是否存在待添加元素”和“添加元素”的操做分开了。因为前一个操做没必要要获取独占锁,在遇到每次待添加的元素都已经存在于数组的状况时能够高效的返回 false

由于上面提到的两步操做是非原子性的,因此再第二步操做中还须要再次进行确认以前用来判断不存在元素 e 的数组是否被“掉包”了。若是被“掉包”,那么也不要“嫌弃”。就须要再判断一下“掉包”后的数组还能不能接着用。若是不能用直接返回 false,若是发现能用就继续向下执行,成功后返回 true

这种设计思路,在本身的业务系统中仍是比较值的借鉴的。固然上述场景下“坏”的设计,就是会先尝试获取独占锁,在获取独占锁后再进行“判断元素是否存在和决定是否添加元素的操做”。这样则会致使大大增长线程阻塞挂起概率。相信大多数同窗仍是能写出漂亮的代码的,不至于犯这种小错误。

获取元素

获取元素一共涉及到三个方法,源码以下:

public E get(int index) {
    return get(getArray(), index);
}
// 步骤一
final Object[] getArray() {
    return array;
}
// 步骤二
private E get(Object[] a, int index) {
    return (E) a[index];
}

咱们看到获取元素的操做,全程没有加锁。而且获取元素是由两步操做组合而成的,一获取当前数组,二从当前数据中取出所指定的下标位置的元素。一旦在这两步操做之间,有其它线程更改了 index 下标位置的元素。此时,获取元素的线程所使用的数组则是被废弃掉的,它接收到的值也不是最新的值。这是写时复制策略产生的弱一致性问题

修改元素

可使用 set(int index, E element) 方法修改指定位置的元素。

源码

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
            setArray(elements);
        }
        return oldValue;
    } finally {
        lock.unlock();
    }
}

能够看到,在代码中并无显示的判断 index 是否合法,若是不合法则会抛出 IndexOutOfBoundsException 异常。

主要逻辑也是先尝试获取独占锁,符合条件则进行修改。须要注意的一点是,若是指定索引处的元素值与新值相等,也会调用 setArray(Object[] a) 一次方法,这主要是为了保证 volatile 语义。(线程在写入 volatile 变量时,不会把值缓存在寄存器或者其它地方,而是会把值刷回到主内存,确保内存可见性)

删除元素

删除元素的方法包括:

  • E remove(int index)
  • boolean remove(Object o)
  • boolean removeAll(Collection<?> c)

咱们来看下 remove(int index) 的实现,
源码

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

其实代码逻辑很清楚,获取锁后根据状况复制老数组中的未删除数据到新数组便可。

迭代器

不知道你们有没有在遍历 ArrayList 变量的过程当中想没想过删除其中的某个元素?反正我曾经这么写过,而后就出现了问题 ... 后来使用了 ArrayList 的迭代器以后就没有错误了。

CopyOnWriteArrayList 中也有迭代器,可是也存在着弱一致性问题
源码

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;
    }
    // 是否结束
    public boolean hasNext() {
        return cursor < snapshot.length;
    }

    public boolean hasPrevious() {
        return cursor > 0;
    }
    // 获取元素
    public E next() {
        if (! hasNext())
            throw new NoSuchElementException();
        return (E) snapshot[cursor++];
    }

   ... ...

    public int nextIndex() {
        return cursor;
    }

    public int previousIndex() {
        return cursor-1;
    }

    public void remove() {
        throw new UnsupportedOperationException();
    }

    public void set(E e) {
        throw new UnsupportedOperationException();
    }
    
    public void add(E e) {
        throw new UnsupportedOperationException();
    }

    ... ...
}

能够看到,CopyOnWriteArrayList 的迭代器并不支持 remove 操做。在调用 iterator() 方法时获取了一份当前数组的快照,若是在遍历期间并无其它线程对数据作更改操做就不会出现一致性的问题。一旦有其它线程对数据更改后,将 CopyOnWriteArrayList 中的数组更改成了新数组,此时迭代器所持有的数据就至关于快照了,同时也出现了弱一致性问题。

拓展延申

还记得刚刚提到的 addIfAbsent 方法吗?看到它你有没有联想到什么东西呢?集合 set?

对的,经过 addIfAbsent 方法也能实现集合的功能,CopyOnWriteArraySet 的底层就是使用 CopyOnWriteArrayList 实现的。(PS. HasSet 的底层依赖 HashMap 。)

相关文章
相关标签/搜索