暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

Java多线程进阶(二七)—— J.U.C之collections框架:CopyOnWriteArrayList

TPVLOG 2021-06-21
251

本文首发于Ressmix个人站点:https://www.tpvlog.com

一、CopyOnWriteArrayList简介

ArrayList
是一种“列表”数据机构,其底层是通过数组来实现元素的随机访问。JDK1.5之前,如果想要在并发环境下使用“列表”,一般有以下3种方式:

  1. 使用Vector

  2. 使用 Collections.synchronizedList
    返回一个同步代理类;

  3. 自己实现ArrayList的子类,并进行同步/加锁。

前两种方式都相当于加了一把“全局锁”,访问任何方法都需要首先获取锁。第3种方式,需要自己实现,复杂度较高。

JDK1.5时,随着J.U.C引入了一个新的集合工具类—— CopyOnWriteArrayList

  1. public class CopyOnWriteArrayList<E>

  2. implements List<E>, RandomAccess, Cloneable, java.io.Serializable



大多数业务场景都是一种“读多写少”的情形,CopyOnWriteArrayList就是为适应这种场景而诞生的。

CopyOnWriteArrayList,运用了一种“写时复制”的思想。通俗的理解就是当我们需要修改(增/删/改)列表中的元素时,不直接进行修改,而是先将列表Copy,然后在新的副本上进行修改,修改完成之后,再将引用从原列表指向新列表。

这样做的好处是读/写是不会冲突的,可以并发进行,读操作还是在原列表,写操作在新列表。仅仅当有多个线程同时进行写操作时,才会进行同步。

二、CopyOnWriteArrayList原理

2.1 内部结构

CopyOnWriteArrayList的字段很简单:

  1. public class CopyOnWriteArrayList<E>

  2. implements List<E>, RandomAccess, Cloneable, java.io.Serializable {


  3. /**

  4. * 排它锁, 用于同步修改操作

  5. */

  6. final transient ReentrantLock lock = new ReentrantLock();


  7. /**

  8. * 内部数组

  9. */

  10. private transient volatile Object[] array;

  11. }

其中, lock
用于对修改操作进行同步, array
就是内部实际保存数据的数组。


构造器定义

CopyOnWriteArrayList提供了三种不同的构造器,这三种构造器最终都是创建一个数组,并通过 setArray
方法赋给 array
字段:

  1. /**

  2. * 空构造器.

  3. */

  4. public CopyOnWriteArrayList() {

  5. setArray(new Object[0]);

  6. }

  7.  

  8. 仅仅是设置一个了大小为0的数组,并赋给字段array

  9. final void setArray(Object[] a) {

  10. array = a;

  11. }

  1. /**

  2. * 根据已有集合创建

  3. */

  4. public CopyOnWriteArrayList(Collection<? extends E> c) {

  5. Object[] elements;

  6. if (c.getClass() == CopyOnWriteArrayList.class)

  7. elements = ((CopyOnWriteArrayList<?>) c).getArray();

  8. else {

  9. elements = c.toArray();

  10. // c.toArray might (incorrectly) not return Object[] (see 6260652)

  11. if (elements.getClass() != Object[].class)

  12. elements = Arrays.copyOf(elements, elements.length, Object[].class);

  13. }

  14. setArray(elements);

  15. }

  1. /**

  2. * 根据已有数组创建.

  3. *

  4. * @param toCopyIn the array (a copy of this array is used as the

  5. * internal array)

  6. * @throws NullPointerException if the specified array is null

  7. */

  8. public CopyOnWriteArrayList(E[] toCopyIn) {

  9. setArray(Arrays.copyOf(toCopyIn, toCopyIn.length, Object[].class));

  10. }

2.2 核心方法

查询——get方法

  1. public E get(int index) {

  2. return get(getArray(), index);

  3. }


  4. private E get(Object[] a, int index) {

  5. return (E) a[index];

  6. }

可以看到,get方法并没有加锁,直接返回了内部数组对应索引位置的值: array[index]


添加——add方法

  1. public boolean add(E e) {

  2. final ReentrantLock lock = this.lock;

  3. lock.lock();

  4. try {

  5. Object[] elements = getArray(); // 旧数组

  6. int len = elements.length;

  7. Object[] newElements = Arrays.copyOf(elements, len + 1); // 复制并创建新数组

  8. newElements[len] = e; // 将元素插入到新数组末尾

  9. setArray(newElements); // 内部array引用指向新数组

  10. return true;

  11. } finally {

  12. lock.unlock();

  13. }

  14. }

add方法首先会进行加锁,保证只有一个线程能进行修改;然后会创建一个新数组(大小为 n+1
),并将原数组的值复制到新数组,新元素插入到新数组的最后;最后,将字段 array
指向新数组。

上图中,ThreadB对Array的修改由于是在新数组上进行的,所以并不会对ThreadA的读操作产生影响。


删除——remove方法

  1. public E remove(int index) {

  2. final ReentrantLock lock = this.lock;

  3. lock.lock();

  4. try {

  5. Object[] elements = getArray();

  6. int len = elements.length;

  7. E oldValue = get(elements, index); // 获取旧数组中的元素, 用于返回

  8. int numMoved = len - index - 1; // 需要移动多少个元素

  9. if (numMoved == 0) // index位置刚好是最后一个元素

  10. setArray(Arrays.copyOf(elements, len - 1));

  11. else {

  12. Object[] newElements = new Object[len - 1];

  13. System.arraycopy(elements, 0, newElements, 0, index);

  14. System.arraycopy(elements, index + 1, newElements, index, numMoved);

  15. setArray(newElements);

  16. }

  17. return oldValue;

  18. } finally {

  19. lock.unlock();

  20. }

  21. }

删除方法和插入一样,都需要先加锁(所有涉及修改元素的方法都需要先加锁,写-写不能并发),然后构建新数组,复制旧数组元素至新数组,最后将 array
指向新数组。


其它统计方法

  1. public int size() {

  2. return getArray().length;

  3. }


  4. public boolean isEmpty() {

  5. return size() == 0;

  6. }


迭代

CopyOnWriteArrayList对元素进行迭代时,仅仅返回一个当前内部数组的快照,也就是说,如果此时有其它线程正在修改元素,并不会在迭代中反映出来,因为修改都是在新数组中进行的。

  1. public Iterator<E> iterator() {

  2. return new COWIterator<E>(getArray(), 0);

  3. }

  4.  

  5. static final class COWIterator<E> implements ListIterator<E> {

  6. /**

  7. * Snapshot of the array

  8. */

  9. private final Object[] snapshot;

  10. /**

  11. * Index of element to be returned by subsequent call to next.

  12. */

  13. private int cursor;


  14. private COWIterator(Object[] elements, int initialCursor) {

  15. cursor = initialCursor;

  16. snapshot = elements;

  17. }


  18. public boolean hasNext() {

  19. return cursor < snapshot.length;

  20. }


  21. public E next() {

  22. if (!hasNext())

  23. throw new NoSuchElementException();

  24. return (E) snapshot[cursor++];

  25. }


  26. // ...

  27. }

可以看到,上述iterator方法返回一个迭代器对象—— COWIterator
,COWIterator的迭代是在旧数组上进行的,当创建迭代器的那一刻就确定了,所以迭代过程中不会抛出并发修改异常—— ConcurrentModificationException

另外,迭代器对象也不支持修改方法,全部会抛出 UnsupportedOperationException
异常。

三、总结

CopyOnWriteArrayList的思想和实现整体上还是比较简单,它适用于处理“读多写少”的并发场景。通过上述对CopyOnWriteArrayList的分析,读者也应该可以发现该类存在的一些问题:

1. 内存的使用 由于CopyOnWriteArrayList使用了“写时复制”,所以在进行写操作的时候,内存里会同时存在两个array数组,如果数组内存占用的太大,那么可能会造成频繁GC,所以CopyOnWriteArrayList并不适合大数据量的场景。

2. 数据一致性 CopyOnWriteArrayList只能保证数据的最终一致性,不能保证数据的实时一致性——读操作读到的数据只是一份快照。所以如果希望写入的数据可以立刻被读到,那CopyOnWriteArrayList并不适合。


文章转载自TPVLOG,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论