5分钟从0到1探秘CopyOnWriteArrayList(满满干货~)

2023年 9月 12日 22.2k 0

前言

最近的文章都是围绕并发编程写的,这段时间会写一些并发包下的并发容器,一篇篇文章去解析,彻底搞懂并发包中的并发容器

在探秘CopyOnWriteArrayList前,我们先来聊聊并发场景下为什么不能使用ArrayList

并发场景下的ArrayList

ArrayList数组,支持动态扩容、随机访问...

作为平时工作中最常用到的集合类,相信我们已经很熟悉它,但这种集合在并发场景下是不安全的

当发生并发读写时,JDK提供快速失败fail-fast机制,让其抛出ConcurrentModificationException并发修改异常

比如来看这一段代码:启动十个线程向集合中添加元素再读取,会抛出并发修改异常

     public void testCopyOnWriteArrayList() throws InterruptedException {
         List list =  new ArrayList();
 ​
         for (int i = 0; i  {
                 list.add(UUID.randomUUID().toString().substring(0,5));
                 System.out.println(list);
             },String.valueOf(i)).start();
         }
 ​
         TimeUnit.SECONDS.sleep(3);
     }

向控制台打印时,实际是调用集合的toString方法

其父类AbstractCollection重写toString方法:使用迭代器遍历数组,并用字符串拼接

     public String toString() {
         Iterator it = iterator();
         if (! it.hasNext())
             return "[]";
 ​
         StringBuilder sb = new StringBuilder();
         sb.append('[');
         for (;;) {
             E e = it.next();
             sb.append(e == this ? "(this Collection)" : e);
             if (! it.hasNext())
                 return sb.append(']').toString();
             sb.append(',').append(' ');
         }
     }

那快速失败是如何实现的呢?

在集合进行写操作,比如添加、删除、修改时,会对内部字段modCount进行自增,用来表示修改次数

当获取迭代器时,会将modCount赋值给迭代器内部的expectedModCount字段

当遍历迭代器时,会检查这两个字段是否相同,不同说明其他线程进行写操作,于是抛出并发修改异常,以此来达到快速失败

         final void checkForComodification() {
             if (modCount != expectedModCount)
                 throw new ConcurrentModificationException();
         }

并发场景下的解决方案

说了那么多,ArrayList在并发场景下不可用,那么有什么解决方案呢?

在远古版本在并发场景下使用VectorCollections.synchronizedList(new ArrayList())

它们的读写操作都是由synchronized加锁保证原子性

有的同学会说:是因为synchronized的性能太差才导致不使用它们的

synchronized经过锁升级优化后,性能还差吗?如果对其感兴趣的同学可以查看15000字、6个代码案例、5个原理图让你彻底搞懂Synchronized

其实我更认为是它们锁的粒度太大,在并发场景中,读操作也一定要通过加锁来进行访问吗?

熟悉volatile的同学一定知道,是不需要的

并发读写中,需要解决的问题是线程对集合进行修改,如何让其他线程读时可见,也就是如何保证可见性?

使用volatile保证其可见性,在读场景下是不需要加锁的

如果对此不理解的同学,可以查看5个案例和流程图让你从0到1搞懂volatile关键字

铺垫了这么久,接下来让我们来看看本篇文章的主角CopyOnWriteArrayList

CopyOnWrite

CopyOnWriteArrayList 从名称上来看带着CopyOnWrite 翻译过来就是 写时拷贝

写时拷贝COW 是一种什么思想呢?

COW是在进行写操作时,将原数据拷贝一份进行写操作,写完再将其设置回去

这种思想在并发的场景非常常见,比如Redis持久化RDB、AOF文件就用到了COW;MySQL实现的MVCC版本链

CopyOnWriteArrayList

接下来我们从源码上看看它是如何实现的

在构造时,初始化了一个长度为0的数组

虽然很奇怪,但想到COW,写时会拷贝一份数据写完再设置回去,一下就正常了

     final void setArray(Object[] a) {
         array = a;
     }
 ​
     public CopyOnWriteArrayList() {
         setArray(new Object[0]);
     }

再来看看get读操作

     public E get(int index) {
         return get(getArray(), index);
     }
 ​
     private E get(Object[] a, int index) {
         return (E) a[index];
     }

读操作就是非常普通的读取某个下标的数据

既然没有使用加锁,那么存储数据的字段一定会使用volatile

 private transient volatile Object[] array;

这种volatile保证可见性,让读操作在并发场景下不用加锁的思想也非常常见,比如AQS的volatile保证同步状态可见性

接着来看看写操作-添加

 public boolean add(E e) {
     final ReentrantLock lock = this.lock;
     //加锁
     lock.lock();
     try {
         //获得原数组
         Object[] elements = getArray();
         int len = elements.length;
         //将原数组中数据复制到新数组
         Object[] newElements = Arrays.copyOf(elements, len + 1);
         //添加新数据
         newElements[len] = e;
         //将新数组重新设置回去
         setArray(newElements);
         return true;
     } finally {
         //解锁
         lock.unlock();
     }
 }

写操作时加锁,保证并发写时只有一个线程能够进行写操作,从而保证线程安全

虽然复制时会使用性能较好的System.arrayCopy进行复制,但写操作太多也会导致性能开销太大

     public static  T[] copyOf(U[] original, int newLength, Class

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论