并发编程第七天----CopyOnWriteArrayList 实现原理

1,100 阅读4分钟

ArrayList 并发缺陷

ArrayList 相信大家都用过,我们来看看它在并发下的缺陷。

public static void main(String[] args) {
    ArrayList<String> names = new ArrayList<>();

    names.add("张三");
    names.add("李四");
    names.add("王五");
    names.add("赵六");

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

    while (iterator.hasNext()){
        String name = iterator.next();
        if (name.equals("王五")){
            names.add("田七");
        }
        System.out.println(name);
    }
}

在遍历集合的过程中,如果对集合做了修改,会抛出 ConcurrentModificationException 异常,在 Java 中,for 循环遍历集合会转换成 Iterator 遍历,我们来看看迭代器遍历时的检验机制。

// ArrayList 中的元素数量
private int size;

// 存储数据
transient Object[] elementData;

// 集合中元素的修改次数
protected transient int modCount = 0;

// 添加方法
public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // modCount + 1
    elementData[size++] = e;
    return true;
}

private void ensureExplicitCapacity(int minCapacity) {
    modCount++; 

    // 扩容,不是我们关注的重点
    if (minCapacity - elementData.length > 0)
        grow(minCapacity);
}


// ArrayList 中的迭代器
private class Itr implements Iterator<E> {
    int cursor;       // 下一个要返回的元素索引
    int lastRet = -1; // 上一个返回的元素的索引; -1 if no such
    int expectedModCount = modCount;

    Itr() {}

    public boolean hasNext() {
        return cursor != size;
    }

    // 主要源码
    public E next() {
        checkForComodification();
        int i = cursor;
        Object[] elementData = ArrayList.this.elementData;        
        cursor = i + 1;
        return (E) elementData[lastRet = i];
    }
	
    // 检验遍历过程中集合元素是否受到了修改
    final void checkForComodification() {
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
    }
}

在遍历过程中,每次调用 next() 方法时,都会进行 checkForComodification(),检验expectedCountmodCount 是否相等,如果不相等则抛出 ConcurrentModificationException 异常。每次集合添加或删除元素时,modCount 就会 +1。

并发环境下,ArrayList 肯定是线程不安全的,当一个线程遍历时,其他线程对集合元素做了修改,就会报错。


CopyOnWriteArrayList

思想

CopyOnWriteArrayList 是一个线程安全的 ArrayList对其进行的修改操作都是在一个复制的数组(快照)中进行,采用了写时复制策略

主要成员变量: array 一个 Object 类型的数组,用来存放具体的元素。ReentrantLock 独占锁对象(后面再详解),保证了同一时间只有一个线程能进入到方法中。

// 独占锁对象
final transient ReentrantLock lock = new ReentrantLock();

private transient volatile Object[] array;

使用

    public static void main(String[] args) {
        CopyOnWriteArrayList<String> names = new CopyOnWriteArrayList<>();

        names.add("张三");
        names.add("李四");
        names.add("王五");
        names.add("赵六");

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

        while (iterator.hasNext()){
            String name = iterator.next();
            if (name.equals("王五")){
                names.add("田七");
            }
            System.out.println(name);
        }
    }

这段代码能正常的执行,下面我们来分析 CopyOnWriteArrayList 的主要方法的源码。


add()方法

    // 获取 array 对象
    final Object[] getArray() {
        return array;
    }
    
    
    final void setArray(Object[] a) {
        array = a;
    }
    
    public boolean add(E e) {
        final ReentrantLock lock = this.lock;
        lock.lock(); // 保证了同一时间只有一个线程能执行下面的代码
        try {
            Object[] elements = getArray();
            
            // 复制 array 到新数组
            int len = elements.length;
            Object[] newElements = Arrays.copyOf(elements, len + 1);
            newElements[len] = e;
            
            // 新数组替换旧数组
            setArray(newElements);
            return true;
        } finally {
            lock.unlock(); // 释放锁
        }
    }

调用 add() 方法的线程先会获取独占锁,获取锁后复制 array 到一个新数组(新数组的大小是原来的大小 +1,可以知道 CopyOnWriteArrayList 是无界集合),最后用新数组替换旧数组,并释放锁。整个 add() 是原子过程,并且添加并不是在原来的数组是添加,而是在快照上添加(写时复制)

修改和删除元素也是采用写时复制的策略,在新数组中进行修改操作,然后将新数组赋值给 array


get()

public E get(int index) { // 没有加锁
    return get(getArray(), index);
}

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

ArrayListget() 方法时直接从数组里取值,但是这里是先把数组复制给变量 a,然后从 a 中取值,这样做有什么好处?

线程 X 调用 get() 方法获取指定元素可以分两步: 获取 array 数组(步骤 A),通过下标访问指定元素的位置(步骤 B )。整个过程没有加锁同步。

加锁在某个时刻,array 中的元素为 1,2,3。线程 X 执行 get(0) 方法。

在线程 X 执行完 A 步骤,执行 B 步骤前,另一个线程 Y 删除元素 1 ,并重写回 array 数组。这时候线程 X 还能取到元素 1 吗? 答案是当然可以,因为线程 X 访问的是临时数组 a,其他线程对 array 的修改对其不会造成影响。


iterator()

static final class COWIterator<E> implements ListIterator<E> {
        // array 的快照
        private final Object[] snapshot;
        
        // 数组下标
        private int cursor;
        
        // 构造函数
        private COWIterator(Object[] elements, int initialCursor) {
            cursor = initialCursor;
            snapshot = elements;
        }

        public boolean hasNext() {
            return cursor < snapshot.length;
        }


        public E next() {
            if (! hasNext())
                throw new NoSuchElementException();
            return (E) snapshot[cursor++];
        }
    }

array 的修改对 snapshot 是不可见的,当 array 修改后,arraysnapshot 就是两个完全不同的数组了( 因为通过前面的源码可知, array 相当于又 new 了一个 )。所以使用迭代器时,其他线程对该集合的修改不可见,保证了线程安全。


检验

public static void main(String[] args) throws InterruptedException {
        CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
        list.add("111");
        list.add("222");
        list.add("333");

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

        Thread t1 = new Thread(()->{
            list.add("444");
            list.add("555");
        });

        t1.start();

        t1.join(); // 等待线程 t1 执行完

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

这段代码再次检验了其他线程对集合的修改对 iterator 对象不可见。