1、简介
ArrayList 的一种线程安全的变体,其中所有的可变操作(添加、设置等)都是通过对基础数组的一个新拷贝来实现的。
拷贝就意味着空间代价会很高,特别是当集合中元素非常多的时候进行可变操作,在高并发非常大的项目中往往非常致命,分分钟钟挂机。
在读写环境中其实现的逻辑是读写分离,读操作的是原始数组本身,而写操作则会从原始数组中复制一份副本出来,所有的修改操作只作用在这个副本上,对原始数组则没有任何影响,最后修改完数据之后才会将修改完成的副本替换掉原始数组,完成一次数据的修改操作。这里会出现的问题就是不能保证数据实时修改并实时读取,即不能保证数据的实时一致性,只能保证数据的最终一致性。
由读写分离,则对其循环迭代时修改数据也不会出现 ConcurrentModificationException 异常。
2、属性分析
public class CopyOnWriteArrayList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable { private static final long serialVersionUID = 8673264195747942595L; // 修改数据时,依赖的锁对象 final transient Object lock = new Object(); // 存储元素的数组 private transient volatile Object[] array; }
CopyOnWriteArrayList 对象的属性非常少,就两个:
lock:修改数据时要获取的锁对象,被 transient 关键字修饰说明其内部有自己的一套序列化逻辑。在 JDK8 的时候锁对象不是 lock 而是 ReentrantLock 锁,其原因不是本次关注的点,不做分析。
array:被 volatile 修饰的数组类型属性,在多线程环境下保证一处修改,其它线程立马可见。
3、构造器
public CopyOnWriteArrayList() { // 无参构造器,直接设置一个长度为 0 的数组给 array setArray(new Object[0]); } // 直接构造一个指定集合数据的 CopyOnWriteArrayList 对象 public CopyOnWriteArrayList(Collection<? extends E> c) { Object[] es; if (c.getClass() == CopyOnWriteArrayList.class) es = ((CopyOnWriteArrayList<?>)c).getArray(); else { es = c.toArray(); if (c.getClass() != java.util.ArrayList.class) es = Arrays.copyOf(es, es.length, Object[].class); } setArray(es); } // 直接构造一个指定数组的 CopyOnWriteArrayList 对象 public CopyOnWriteArrayList(E[] toCopyIn) { setArray(Arrays.copyOf(toCopyIn, toCopyIn.length, Object[].class)); }
这几个构造器最终都是调用 setArray 方法,代码如下:
final void setArray(Object[] a) { // 给数组赋值,替换 array 指向的引用 array = a; }
至于 Arrays.copyOf 方法的解析我在这篇文章中有详细说明:👉《非专业解读人士的ArrayList源码深度解析》
4、set 方法
public E set(int index, E element) { // 锁 synchronized (lock) { // 获取数组 Object[] es = getArray(); // 根据下表找到数据元素 E oldValue = elementAt(es, index); // 判断下标处元素和江要设置的元素是否相等 if (oldValue != element) { // 不相等,复制一份数组出来 es = es.clone(); // 在对应下标出设置元素 es[index] = element; } // Ensure volatile write semantics even when oldvalue == element // 将操作过的数组副本设置回去 setArray(es); // 返回 index 出的旧指 return oldValue; // 解锁退出 } }
方法通过 synchronized 关键字进行加锁解锁,在 JDK1.8 的时候用的并不是这个,而是 ReentrantLock 可重入锁,至于原因我想应该是 JDK11 的 synchronized 性能优于 ReentrantLock 。
set 方法实现逻辑非常简单,先找到对应下标处的元素,判断设置元素与旧元素是否相等,不相等则进行数组的拷贝,操作副本并将操作完成的副本赋值给 array 数组;反之则不进行设置操作,直接返回 index 处元素,结束方法。
值得注意的一个点就是复制方法,这是一个比较浪费内存和时间的问题,如果集合元素比较多且频繁的进行 set 方法,则非常不建议使用 CopyOnWriteArrayList。
5、add 方法
public boolean add(E e) { // 加锁 synchronized (lock) { // 获取数组对象 Object[] es = getArray(); // 获取数组长度 int len = es.length; // 复制数组,新数组长度为 len + 1 es = Arrays.copyOf(es, len + 1); // 在数组最后位置添加元素 es[len] = e; // 设置操作后的数组 setArray(es); // 返回成功 return true; } }
通过 synchronized 关键字加锁,添加数据时都是复制一份比原数组长 1 的新数组出来,在最后添加数据完成添加元素操作。
6、add(int, E) 方法
public void add(int index, E element) { // 加锁 synchronized (lock) { // 获取数组 Object[] es = getArray(); // 获取数组长度 int len = es.length; // 判断下标是否非法 if (index > len || index < 0) throw new IndexOutOfBoundsException(outOfBounds(index, len)); // 新数组 Object[] newElements; // 需要移动的元素个数 int numMoved = len - index; // 如果插入的正好是数组尾部,直接和 add 方法一样 if (numMoved == 0) newElements = Arrays.copyOf(es, len + 1); else { // 在中间部分插入元素 // 创建一个新数组大小只比原数组大1 newElements = new Object[len + 1]; // 将旧数组中的元素从 0 - index 开始复制到新数组中 System.arraycopy(es, 0, newElements, 0, index); // 将旧数组中的元素从 index - length 开始复制到新数组中 System.arraycopy(es, index, newElements, index + 1, numMoved); } // 将新元素放到 index 下标处 newElements[index] = element; // 设置数组 setArray(newElements); // 解锁,完成元素添加 } }
在指定下标处添加元素,不可避免的就是检查下标是否非法。
其次还有一个点就是,元素复制那块代码,可以说有点新颖(本人觉得)。它虽然还是复制,但是却尽可能的减少了复制和移动的次数就完成了一个元素的插入并回写到 array 属性上,具体运行图如下。
7、remove 删除元素
public E remove(int index) { // 加锁 synchronized (lock) { // 获取数组 Object[] es = getArray(); // 获取数组长度 int len = es.length; // 没有判断 index 直接从数组中获取元素,不怕出现下标越界错误吗????? E oldValue = elementAt(es, index); // 计算需要移动的元素个数 int numMoved = len - index - 1; // 定义一个新数组 Object[] newElements; if (numMoved == 0) // 这个情况就是移除了最后一个元素 newElements = Arrays.copyOf(es, len - 1); else { // 创建一个长度为 len -1 的数组 newElements = new Object[len - 1]; // 和 add 方法一样的复制方式 System.arraycopy(es, 0, newElements, 0, index); System.arraycopy(es, index + 1, newElements, index, numMoved); } // 设置数组 setArray(newElements); // 返回移除的值 return oldValue; // 解锁 } }
按下标移除元素,根据源码发现并不复杂,根据下标找到元素,然后复制除要被移除元素以外到新的数组中即完成元素移除。
不过有点比较奇怪,居然没有下标检查,而是直接就根据传入进来的下标去数组中获取元素,这…?妥妥的会报 ArrayIndexOutOfBoundsException 错。
再来分析一个根据元素移除的方法:
public boolean remove(Object o) { // 获取数组 Object[] snapshot = getArray(); // 循环遍历数组,找到 o 对应的下标,没有找到则返回 -1 int index = indexOfRange(o, snapshot, 0, snapshot.length); // 判断下标,开始移除元素 return index >= 0 && remove(o, snapshot, index); } private boolean remove(Object o, Object[] snapshot, int index) { // 加锁 synchronized (lock) { // 获取数组 Object[] current = getArray(); // 获取数组长度 int len = current.length; // 数组是否被改变过(是否被其他线程替换过 array) if (snapshot != current) findIndex: { // 被改变过,走这个逻辑 // 找到最小的数组长度 int prefix = Math.min(index, len); // 开始遍历 for (int i = 0; i < prefix; i++) { // i 下标元素的地址在 current 和 snapshot 数组中的地址不同,但值相同,则条件成立 if (current[i] != snapshot[i] && Objects.equals(o, current[i])) { // 说明移除的元素还是在下标 i 处,赋给 index index = i; // 跳出 if 语句 break findIndex; } } // 如果 index 大于 len 没必要移除,移除失败 if (index >= len) return false; // current 数组 index 下标处元素就等于 o 那就跳出 if 分支,往下执行移除 if (current[index] == o) break findIndex; // index 还满足 current 数组下标范围,那就再次从 current 数组中找到 o 元素下标 index = indexOfRange(o, current, index, len); // 判断在 current 数组中是否找到下标 if (index < 0) return false; } // 没有被改变过,走下面的逻辑 // 创建一个新数组 Object[] newElements = new Object[len - 1]; // 和 add 方法一样的复制逻辑 System.arraycopy(current, 0, newElements, 0, index); System.arraycopy(current, index + 1, newElements, index, len - index - 1); // 设置数组 setArray(newElements); // 移除成功 return true; // 解锁 } }
这个方法比较复杂,原因则是为了防止并发问题。
说一下其大致思路:
- 先第一次获取数组对象,和要被移除的元素下标。
- 加锁,再一次获取数组对象
- 判断两次获取的数组对象是否为同一个
情况一:不一样,被其他线程替换过了,那将第一次获取到的元素下标和第二次获取的数组长度进行对比取较小的那个值 prefix 。接着从 0 开始循环到 prefix,对比找到两次获取的数组对象是否有需要移除的元素,有则记录其下标,跳出步骤 4 执行步骤 5;否则判断下标是否符合第二次数组对象的长度,不符合移除失败,反之记录下标,执行步骤 5。
情况二:一样,说明数组没有替换,那就按正常的移除流程,创建一个长度减一的新数组,和 add 方法一样的复制元素到新数组并将最终的新数组设置给 array 则完成元素移除。
重点在第四步骤,结合我的代码注释和思路总结,完全理解还是不成问题的。
对于 CopyOnWriteArrayList 的添加方法我不打算分析,因为原理实在是非常简单,我不看源码都知道其如何实现获取元素的。
8、源码分析总结
本篇不说对 CopyOnWriteArrayList 介绍的有多透彻,但可以说重点源码是肯定分析到的,其实现的思路也是有详细介绍,那下面根据本文的分析对 CopyOnWriteArrayList 做个源码总结:
- CopyOnWriteArrayList 在 JDK11 使用 synchronized 关键字加锁,保证线程安全。
- CopyOnWriteArrayList 的写操作都要先拷贝一份新数组,在新数组照中做修改,修改完了再用新数组替换老数组,所以空间复杂度是 O(n) ,性能相对低下。
- CopyOnWriteArrayList 的读操作支持随机访问,时间复杂度 O(1)。
- CopyOnWriteArrayList 采用读写分离的思想,读操作不加锁,写操作加锁,且写操作占用较大内存空间,所以适用于读多写少的场合。
- CopyOnWriteArrayList 只能保证最终一致性,不能保证实时一致性。
好了,今天的内容到这里就结束了,关注我,我们下期见。