15-CopyOnWriteArrayList 源码解析和设计思路(并发集合)

注:源码系列文章主要是对某付费专栏的总结记录。如有侵权,请联系删除。

在 ArrayList 的类注释上,JDK 提醒我们,如果要把 ArrayList 作为共享变量的话,是线程不安全的,推荐我们使用 Collections.synchronizedList 方法,其实 JDK 还提供了另外一种线程安全的 List,叫做 CopyOnWriteArrayList,这个 List 具有以下特征:

  1. 线程安全,多线程环境下可以直接使用,无需加锁;
  2. 通过锁 + 数据拷贝 + volatile 关键字保证了线程安全;
  3. 每次数组操作,都会把数组拷贝一份出来,在新数组上进行操作,操作成功之后再赋值回去。

1 整体架构

从整体架构上来说,CopyOnWriteArrayList 数据结构和 ArrayList 是一致的,底层是个数组,支不过 CopyOnWriteArrayList 在对数组进行操作的时候,基本会分四步走:

  1. 加锁;
  2. 从原数组中拷贝出新数组;
  3. 在新数组上进行操作,并把新数组赋值给数组容器;
  4. 解锁。

除了加锁之外,CopyOnWriteArrayList 的底层数组还被 volatile 关键字修饰,意思是一旦数组被修改,其它线程立马能够感知到,如下:

private transient volatile Object[] array;

整体上来说,CopyOnWriteArrayList 就是利用 锁 + 数组拷贝 + volatile 关键字 保证了 List 的线程安全。

1.1 类注释

  1. 所有的操作都是线程安全的,因为曹组都是在新数组上进行的;
  2. 数组的拷贝虽然有一定的成本,但往往比一般的替代方案效率高;
  3. 迭代过程中,不会影响到原来的数组,也不会抛 ConcurrentModificationException 异常。

2 新增

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 {
    	// finally 中释放锁,保证了即使 try 发生了异常,仍然能够释放锁
        lock.unlock();
    }
}

从源码中,我们看到整个 add 过程都是在持有锁的状态下进行的,通过加锁,来保证同一时刻只能有一个线程能够对同一个数组进行 add 操作。

除了加锁之外,还会从老数组中创建出一个新数组,然后把老数组的值拷贝到新数组上,这时候就有一个问题:都已经加锁了,为什么需要拷贝数组,而不是在原来数组上进行操作呢,原因主要为:

  1. volatile 关键字修饰的数组,如果我们简单的在原来数组上修饰其中几个元素的值,是无法触发可见性的,我们必须通过修改数组的内存地址才行,也就是说对数组进行重新赋值才行。
  2. 在新的数组上进行拷贝,对老数组没有任何影响,只有新数组完全拷贝完成之后,外部才能访问到,降低了再赋值过程中,老数组数据变动的影响。

上线 add 操作是添加到数组的尾部,接着我们来看下指定位置添加元素的源码:

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;
        // 等于0表示在数组尾部插入
        if (numMoved == 0)
        	// 直接拷贝即可
            newElements = Arrays.copyOf(elements, len + 1);
        else {
        	// 拷贝 0到index,index+1 到 numMoved
            newElements = new Object[len + 1];
            System.arraycopy(elements, 0, newElements, 0, index);
            System.arraycopy(elements, index, newElements, index + 1,
                             numMoved);
        }
        // 在指定位置 index 处赋值
        newElements[index] = element;
        // 替换掉原来的数组
        setArray(newElements);
    } finally {
        lock.unlock();
    }
}

从源码中看到,当插入的位置正好位于末尾时,只需要拷贝一次,当插入的位置处于中间时,此时我们会把数组一分为二,进行两次拷贝。

2.1 小结

从 add 系列方法看出,CopyOnWriteArrayList 通过 锁 + 数组拷贝 + volatile 来保证正线程安全,每一个要素都有其独特的含义:

  1. 加锁:保证同一时刻数组只能被一个线程操作;
  2. 数组拷贝:保证数组的内存地址被修改,修改后触发 volatile 的可见性,其它线程可以立刻知道数组已经被修改;
  3. volatile:值被修改后,其它线程能够立马感知最新值。

三个要素缺一不可,比如我们只使用 1 和 3,去掉 2,这样当我们修改数组中某个值时,并不会触发 volatile 的可见性,只有当数组内存地址被修改后,才能触发把最新值通知给其它线程。

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)
        	// 则直接拷贝即可头部至尾部-1即可
            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();
    }
}

步骤分为三步:

  1. 加锁;
  2. 判断要删除索引位置,从而进行不同策略的拷贝;
  3. 解锁。

整体代码的结构风格比较统一:锁 + try finally + 数组拷贝,锁被 final 修饰的,保证了在加锁过程中,锁的内存地址肯定不会被修改,finally 保证锁一定能够被释放,数组拷贝是为了删除其中某个位置的元素。

4 批量删除

// 批量删除
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();
    }
}

从源码中,我们可以看到,我们并不会直接对数组中的元素进行挨个删除,而是先对数组中的值进行循环判断,把我们不需要删除的数据放到临时数组中,最后临时数组中的数据就是我们不需要删除的数据。

ArrayList 中批量删除的思想也是和这个类似,所以我们在需要删除多个元素时,最好都使用这种批量删除的思想,而不是采用在 for 循环中使用单个删除的方法,单个删除的话,在每次删除的时候都会进行数组拷贝(删除最后一个元素时拷贝一次,删除其他位置元素时拷贝两次),很消耗性能,也耗时,会导致加锁时间太长,并发大的情况下,会造成大量请求在等待锁,这也会占用一定的内存。

5 其它方法

5.1 indexOf

indexOf 方法的主要用处是查找元素在数组中的下标位置,如果元素存在就返回元素的小标位置,元素不存在的话就返回 -1,不但支持 null 值的搜索,还支持正向和反向的查找,以正向查找为例,源码如下:

// o: 我们要查找的元素
// elements: 搜索的目标数组
// index: 搜索开始位置
// fence: 搜索结束位置
private static int indexOf(Object o, Object[] elements,
                           int index, int fence) {
    if (o == null) {
    	// 为null 则循环挨个判断是否为 null
        for (int i = index; i < fence; i++)
            if (elements[i] == null)
                return i;
    } else {
    	// 不为null 则循环使用 equals 判断相等
        for (int i = index; i < fence; i++)
            if (o.equals(elements[i]))
                return i;
    }
    // 未找到则返回 -1
    return -1;
}

indexOf 方法在 CopyOnWriteArrayList 内部使用也比较广泛,比如在判断元素是否存在(contains),在删除元素方法中校验元素是否存在,都会使用 indexOf 方法,indexOf 方法通过一次 for 循环来查找元素,我们在调用此方法时,需要注意如果找不到元素时,返回的是 -1,所以有可能我们会对这个特殊值进行判断。

5.2 迭代

源码:

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

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

    @SuppressWarnings("unchecked")
    public E previous() {
        if (! hasPrevious())
            throw new NoSuchElementException();
        return (E) snapshot[--cursor];
    }

    ...
}

我们发现迭代时,即使数组的原值被改变了,也不会抛出 ConcurrentModificationException 异常,其根源在于数组的每次改动,都会生成新的数组,不会影响老的数组,这样的话,在迭代过程中,根本不会发生迭代数组的变动。

示例 demo:

CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
for (int i = 0; i < 5; i++) {
    list.add(String.valueOf(i));
}

Iterator<String> iterator = list.iterator();
iterator.next(); // debug 1
list.add("100");
iterator.next(); // debug 2

debug1
debug2

如上示例中,我们发现在 debug1 时集合 list 和其迭代器 iterator 中对数组的引用都是 @832,到 debug2 我们 add 一个新值时,这个时候生成了新的数组,并修改了原集合中数组的内存地址,我们看图 2 可发现集合 list 数组内存地址变为了 @838,而其迭代器中对于数组的引用地址还是 add 之前的旧的数组内存地址 @832:CopyOnWriteArrayList 迭代持有的数老数组的引用,而 CopyOnWriteArrayList 每次数据的变动,都会产生新的数组,对老数组的值不会有影响,所以迭代也可以正常运行。

6 读写分离

上面源码分析时,我们发现在写方法 add 时会获取锁,并且是在原数组的拷贝上进行操作的,操作完之后再将新数组赋值给旧数组,而在读操作 get 中,会直接操作原数组,并且不会加锁。

7 总结

当我们需要在线程不安全的场景下使用 List 时,建议使用 CopyOnWriteArrayList,CopyOnWriteArrayList 通过 锁 + 数组拷贝 + volatile 之间相互配合,实现了 List 的线程安全。

------------------------------------- END -------------------------------------

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值