java集合之CopyOnWriteArrayList源码解析
作者:Colins~
一、基础常量和结构
// 锁 final transient ReentrantLock lock = new ReentrantLock(); // 空数组 private transient volatile Object[] array;
可以看到底层依旧还是数组,但是没了默认大小和容量大小的变量了,而且数组容器被volatile关键字修饰,同时还多了一把锁,这同时说明了CopyOnWriteArrayList是线程安全的设计
为什么没有默认大小了呢?难道不需要判断扩容了?接着往下看
二、构造方法
public CopyOnWriteArrayList() { setArray(new Object[0]); } final void setArray(Object[] a) { array = a; } // 带参构造 public CopyOnWriteArrayList(E[] toCopyIn) { setArray(Arrays.copyOf(toCopyIn, toCopyIn.length, Object[].class)); }
两个构造方法:
- 默认的:直接创建了个空数组然后赋值给了容器数组
- 带参的:把传参的数组数据赋值到了一个新数组上面然后把新数组给了容器数组(对Arrays.copyOf不熟的可以看看 上篇ArrayList介绍 )
为什么带参的方法要搞一个新的数组出来然后在赋值给容器数组呢?
因为数组赋值,不是值传递,传递后依旧会受原数组的影响,如下:(不清楚的了解一下值传递和地址传递)
Object[] aa=new Object[]{1,2}; Object[] aa1=aa; System.out.println("改变前:"+aa1[0]); aa[0]=3; System.out.println("改变后:"+aa1[0]);
//结果如下:
改变前:1
改变后:3
ps:改的是aa数组
三、add操作
默认添加
整个过程逻辑很简单:
- 加锁,获取容器数组
- 创建一个长度+1 的新数组,并把之前数组的数据复制过去
- 然后新数组尾部赋值,并把新数组重新赋值给容器数组
因为每次添加都会创建一个长度+1的新数组,所以并不需要扩容了
线程安全方面: 容器array是volatile修饰的,即set和get方法都是线程安全的,整个添加过程上了锁,所以整体是通过volatile和lock来保证的线程安全
性能方面: 可以看到舍弃了扩容操作,但每次添加都会创建个新的数组并复制数据过去,这个过程是非常耗时的,所以并不合适频繁写入的场景
源代码如下:
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 { lock.unlock(); } }
指定位置插入
同样也可以指定位置插入,流程跟上述差不多一致,但是有个双Copy操作:
- 加锁,获取容器数组
- 下标校验
- 如果正好是尾部插入:创建一个长度+1 的新数组,并把之前数组的数据复制过去
- 不是尾部:需要复制两次,总的来说就是下标前的数据照旧复制,但下标后的数据复制后,整体位置往后移一位
- 然后新数组尾部赋值,并把新数组重新赋值给容器数组
源代码如下:
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; if (numMoved == 0) // 如果正好是尾部 则照旧创建并复制 newElements = Arrays.copyOf(elements, len + 1); else { // 不是尾部则创建一个长度+1 的新数组 newElements = new Object[len + 1]; // 依旧是复制 但这里复制了两次 // 0-index的元素复制一次 位置不变 System.arraycopy(elements, 0, newElements, 0, index); // index-尾部的元素复制一次 整体位置都后移一位 System.arraycopy(elements, index, newElements, index + 1, numMoved); } // 指定位置赋值 newElements[index] = element; // 把新数组重新赋值给容器数组 setArray(newElements); } finally { lock.unlock(); } }
四、get操作
很简单先获取容器数组,然后根据数组下标取值就好
容器数组是volatile修饰的,所以本身get就是线程安全的,始终获取的最新值
源代码如下:
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]; }
五、set操作
这个我在ArrayList里面没有介绍,因为很简单就是替换数组下标原本的值即可
这里提一下是因为此操作也是线程安全的上了锁:
- 加锁,获取下标对应的旧值
- 对比新值和旧值,值一样则不作任何操作
- 值不一样则创建个新的数组,然后再修改下标的值,再把新数组回写
源代码如下:
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 { // 新值旧值一样 就不做任何操作 ,把数组写回去就好了 setArray(elements); } return oldValue; } finally { lock.unlock(); } }
六、remove操作
根据值remove
- 先遍历查找元素下标所在
- 然后加锁,判断下标是否有效,无效需要重新找一次下标,没找到直接返回false
- 找到了下标然后创建长度-1的新数组,双copy,然后回写到容器数组
总的逻辑不复杂,跟常规的list一样,先查再移除,但由于第一次查找的时候并没有加锁,所以第一次找到的下标到移除的过程中数组可能已经发生了修改,下标会失效,所以在真正移除的时候加锁之后又判断了一次下标的有效性
为什么不直接加锁然后在查找下标后移除呢?
当然也可以,但是加锁会阻塞其他的操作,等于是在遍历查找的时候其他操作就全被阻塞了,但是现在这样假设数组没被修改,则直接双copy移除了,相比更优,假设数组被修改,无非就是再重新遍历一次,从效率上来讲多遍历了一次,效率低了,从阻塞上来看都一样是遍历+双copy,所以综合来说这种设计侧重于使用场景
源代码如下:
public boolean remove(Object o) { // 获取容器数组 Object[] snapshot = getArray(); // 遍历查找元素所在的下标索引 int index = indexOf(o, snapshot, 0, snapshot.length); // 根据索引移除 return (index < 0) ? false : remove(o, snapshot, index); } private boolean remove(Object o, Object[] snapshot, int index) { final ReentrantLock lock = this.lock; lock.lock(); try { // 获取当前最新的容器数组 Object[] current = getArray(); int len = current.length; // 判断传入的数组是否与当前数组相同 如果不相同则需要判断传入index下标的有效性 if (snapshot != current) findIndex: { //得到遍历范围最小值,这个范围不一定能找到元素,当元素被后移时 //注意index是索引,len是数组大小。 int prefix = Math.min(index, len); for (int i = 0; i < prefix; i++) { //严格的判断。只有当两个数组相同索引位置的元素不是同一个元素; //且current索引元素和参数o 是相等的时候 则重新获取赋值index 退出分支 if (current[i] != snapshot[i] && eq(o, current[i])) { index = i; break findIndex; } } // 下标已经超过或等于长度 则下标已经无效了 直接返回 if (index >= len) return false; // 下标依旧有效并且值相等则 退出分支 if (current[index] == o) break findIndex; // 上面都不满足则重新查找一次下标 index = indexOf(o, current, index, len); // 不存在了则直接返回 if (index < 0) return false; } // 经过上面一顿操作 已经找到了要移除元素的下标了,所以创建个长度-1的新数组 Object[] newElements = new Object[len - 1]; // 双复制 与之前一样 ,index后面的元素需要往前移 System.arraycopy(current, 0, newElements, 0, index); System.arraycopy(current, index + 1, newElements, index, len - index - 1); // 新数组赋值给容器数组 setArray(newElements); return true; } finally { lock.unlock(); } }
根据下标remove
这个和根据下标add操作有点类似:
- 判断下标是不是尾部,是尾部则直接Arrays.copyOf
- 不是尾部则双arraycopy,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) // 如果是尾部则直接创建长度-1的数组再复制过去 setArray(Arrays.copyOf(elements, len - 1)); else { // 不是尾部 则双copy ,index后的数据整体前移一位 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(); } }
总结
从上面可以看出CopyOnWriteArrayList是线程安全的,是volatile和lock来保证的,但是也很明显的可以看出弊端就是对修改的效率不高,每个修改都涉及到copy操作,甚至还有两次copy的,而且每个修改都是在新的数组中进行的,这也应对了CopyOnWrite这个命名;就因为写的效率不高,所以这个更适合在读多写少的场景中使用
到此这篇关于java集合之CopyOnWriteArrayList源码解析的文章就介绍到这了,更多相关CopyOnWriteArrayList源码内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!