并发包中的并发 List 只有 CopyOnWriteArray List。 CopyOnWri teArray List 是一个线程 安全的 ArrayList,对其进行的修改操作都是在底层的一个复制的数组(快照)上进行的, 也就是使用了写时复制策略(add/set/remove(修改)时复制新数组进行操作)。
原理:采用每add一个元素则复制出一个新数组,此时如果多线程下,add/set/remove操作用新数组,get操作用旧数组,这样就不用对get进行加锁,但add/set/remove还是要加锁的(独占锁lock)。此时就形成了所谓的读写分离的状态。
弱一致性问题
类图结构:
在 CopyOnWriteArrayList 的类图中,每个 CopyOnWriteArray List 对象里面有一个 array 数组对象用来存放具体元素, ReentrantLock 独占锁对象用来保证同时只有-个线程 对 aηay 进行修改。 这里只要记得 ReentrantLock 是独占锁,同时只有一个线程可以获取就 可以了,后面会专门对 JUC 中的锁进行介绍。
如果让我们自己做一个写时复制的线程安全的 list 我们会怎么做,有哪些点需要考虑?
- 何时初始化 list,初始化的 list 元素个数为多少, list 是有限大小吗?
- 如何保证线程安全,比如多个线程进行读写时如何保证是线程安全的 ?
- 如何保证使用法代器遍历 list 时-的数据一致性?
下面看看CopyOnWriteArrayList源码:
1、构造函数:
CopyOnWriteArrayList内部包含一个array:
/** The array, accessed only via getArray/setArray. */ private transient volatile Object[] array;
无参构造函数在内部创建了一个大小为0的object数组作为array的初始值
public CopyOnWriteArrayList() { setArray(new Object[0]); }
下面看有参构造函数:
// 根据传入数组创建array对象 public CopyOnWriteArrayList(E[] toCopyIn) { setArray(Arrays.copyOf(toCopyIn, toCopyIn.length, Object[].class)); //Arrays.copyOf方法是浅拷贝 } // 根据集合创建array对象 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); }
关于“c.toArray might (incorrectly) not return Object[] (see 6260652)”的注释可参考《JDK1.6集合框架bug:c.toArray might (incorrectly) not return Object[] (see 6260652)》
2、add方法:
CopyOnWrit巳ArrayList 中用来添加元素的函数有 add(E e)、 add(int index, E element)、addifAbsent(E e)和 addAllAbsent(Collection<? extends E> c) 等,它们的原理类似,所以本 节以 add(E e)为例来讲解。
public boolean add(E e) { // 获取独占锁 final ReentrantLock lock = this.lock; lock.lock(); try { // 获取array(这里虽然Arrays.copyOf方法是浅拷贝,浅拷贝重新复制的值是基本类型的数据,引用类型还是指向原先的地址) 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后将array复制到一个新数组(从代码可知新数组的长度比原长度大1,所以CopyOnWriteArrayList时无界list),并把新增的元素添加到新数组。(实现和ArrayList差不多都是创建新数组将旧数组浅拷贝过去,就是扩容的大小不同一个是1.5倍(且有扩容条件是数组满扩容)一个是不断+1,一个是加独占锁安全一个不安全)(ArrayList的底层是扩容为原先的1.5倍),一个初始大小是(默认10/指定长度等)一个是(0/添加的数据长度),一个在迭代器迭代过程如果中断(对数组进行修改删除等)会报错另一个安全不会报错(使用迭代器的弱一致性)。
ArrayList的迭代报错是使用了List本身对象进行修改,如果改为对迭代器对象进行修改则会报错
3、get方法:
使用E get(int index)获取下标为index的元素,如果元素不存在则抛出IndexOutOfBoundException异常。
public E get(int index) { return get(getArray(), index); } private E get(Object[] a, int index) { return (E) a[index]; } final Object[] getArray() { return array; }
获取指定位置的元素需要两步:首先获取array,然后通过下标访问指定位置的元素。整个过程没有加锁,在多线程下会出现弱一致性问题。
假设某一时刻CopyOnWriteArrayList中有1,2,3中三个元素,如下图所示:
由于整个过程未加锁,可能导致一个线程x在获取array后,另一个线程y进行了remove操作,假设要删除的元素为3。remove操作首先会获取独占锁,然后进行写时复制操作,也就是复制一份当前array数组,然后再复制的数组里面删除线程x通过get方法要访问的元素3,之后让array指向复制的数组。而这时线程x仍持有对原来的array的引用,导致虽然线程y删除了元素3,线程x仍能获得3这个元素,如图:
这就是弱一致性问题
4、set修改方法
修改指定元素
使用E set(int index, E element)方法修改指定元素的值,如果指定位置的元素不存在则抛出IndexOutOfBoundsException异常:
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(); } }
首先获取独占锁,从而阻止其他线程对array数组进行修改,然后获取当前数组,并调用get方法获取指定位置的元素,如果指定位置的元素值与新值不一致就创建新数组并复制元素,然后在新数组上修改指定位置的元素值并设置新数组到array。即使指定位置的元素值与新值一样,为了保证volatile语义,也需要重新设置array,虽 然 array 的内容并没有改变。
这里为什么如果修改的值和原先值一样还是要setArray(elements)(新数组替换旧数组方法)?
https://blog.youkuaiyun.com/cumtwyc/article/details/52267414
5、remove删除方法
删除list里的元素,可以使用E remove(int index)、boolean remove(Object o)和boolean remove(Object o, Object[] snapshot, int index)等方法,其原理类似,下面以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(); } }
首先获取独占锁以保证线程安全,然后获取要被删除的元素,并把剩余的元素复制到新数组,之后使用新数组替换原来的数组,最后在返回前释放锁。
6、 弱一致性的迭代器
所谓弱一致性是指返回 迭代器后,其他线程对 list 的增删改对迭代器是不可见的。即迭代器中用snapshot 变量来接传入的数组,此时如果其他线程对数组进行增改删,则会复制新的数组,则此时snapshot变量接收的是旧数组,则不会出现迭代器中断报错的情况,因为分别操作的是两个数组。
public Iterator<E> iterator() { return new COWIterator<E>(getArray(), 0); } 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++]; } }
调用iterator()方法时实际上会返回一个COWIterator对象,COWIterator对象的snapshot变量保存了当前list的内容。之所以说snapshot是list的快照(即复制的副本)是因为虽然snapshot获得了array的引用,但当其他线程修改了list时,array会指向新复制出来的数组,而snapshot仍指向原来array指向的数组,两者操作不同的数组,这就是弱一致性。
以下为弱一致性的示例:
public class CopyListTest { private static volatile CopyOnWriteArrayList<String> arrayList = new CopyOnWriteArrayList<>(); public static void main(String[] args) throws InterruptedException { arrayList.add("Java"); arrayList.add("Scala"); arrayList.add("Groovy"); arrayList.add("Kotlin"); Thread threadOne = new Thread(new Runnable() { @Override public void run() { arrayList.set(0, "hello"); arrayList.remove(2); } }); // 在修改之前获取迭代器 Iterator<String> it = arrayList.iterator(); threadOne.start(); // 等待子线程执行完毕 threadOne.join(); // 迭代 while(it.hasNext()) { System.out.println(it.next()); } System.out.println("========================================="); // 再次迭代 it = arrayList.iterator(); // 迭代 while(it.hasNext()) { System.out.println(it.next()); } } }
结果:
主线程在子线程执行完毕后使用获取的迭代器遍历数组元素,从输出结果我们知道, 在子线程里面进行的操作一个都没有生效,这就是选代器弱一致性的体现。 需要注意的是, 获取迭代器的操作必须在子线程操作之前进行。
7、ArrayList和CopyOnWriteArrayList比较
1、两者都是数组实现(底层都是采用数组拷贝),一个安全(独占锁)一个不安全
2、扩容不同:前者默认初始大小10,每当数组满了则进行扩容1.5倍大小(采用新建数组并拷贝);后者默认为0,每次加一个就新建长度+1的新数组然后copy。
3、迭代器:前者多线程下中断迭代器,即其他线程对list进行增改删等操作会报错,而后者利用迭代器的弱一致性解决了该问题。
后者因为使用写时复制的策略,达到了读写分离的状态,则get读取时不用加锁,所以常用在读多写少的场景
Vector是增删改查方法都加了synchronized,保证同步,但是每个方法执行的时候都要去获得锁,性能就会大大下降,而CopyOnWriteArrayList 只是在增删改上加锁,但是读不加锁,在读方面的性能就好于Vector,CopyOnWriteArrayList支持读多写少的并发情况。
总结
CopyOn WriteArrayList 使用写时复制的策略来保证 list 的一致性,而获取一修改一写 入三步操作并不是原子性的,所以在增删改的过程中都使用了独占锁,来保证在某个时间 只有一个线程能对 list 数组进行修改。 另外 CopyOnWriteAn·ayList 提供了弱一致性的法代 器, 从而保证在获取迭代器后,其他线程对 list 的修改是不可见的, 迭代器遍历的数组是 一个快照。 另外, CopyOnWriteArraySet 的底层就是使用它实现的,感兴趣的读者可以查 阅相关源码。优点: CopyOnWriteArrayList经常被用于“读多写少”的并发场景,是因为CopyOnWriteArrayList无需任何同步措施,大大增强了读的性能。在Java中遍历线程非安全的List(如:ArrayList和 LinkedList)的时候,若中途有别的线程对List容器进行修改,那么会抛出ConcurrentModificationException异常。CopyOnWriteArrayList由于其"读写分离",遍历和修改操作分别作用在不同的List容器,所以在使用迭代器遍历的时候,则不会抛出异常。
缺点: 第一个缺点是CopyOnWriteArrayList每次执行写操作都会将原容器进行拷贝了一份,数据量大的时候,内存会存在较大的压力,可能会引起频繁Full GC(ZGC因为没有使用Full GC)。比如这些对象占用的内存比较大200M左右,那么再写入100M数据进去,内存就会多占用300M。
第二个缺点是CopyOnWriteArrayList由于实现的原因,写和读分别作用在不同新老容器上,在写操作执行过程中,读不会阻塞,但读取到的却是老容器的数据。