• 5分钟从0到1探秘CopyOnWriteArrayList


    5分钟从0到1探秘CopyOnWriteArrayList

    前言

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

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

    并发场景下的ArrayList

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

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

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

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

        public void testCopyOnWriteArrayList() throws InterruptedException {
            List list =  new ArrayList();
    
            for (int i = 0; i < 10; i++) {
                new Thread(()-> {
                    list.add(UUID.randomUUID().toString().substring(0,5));
                    System.out.println(list);
                },String.valueOf(i)).start();
            }
    
            TimeUnit.SECONDS.sleep(3);
        }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    向控制台打印时,实际是调用集合的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(' ');
            }
        }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

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

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

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

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

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

    并发场景下的解决方案

    说了那么多,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]);
        }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    再来看看get读操作

        public E get(int index) {
            return get(getArray(), index);
        }
    
        private E get(Object[] a, int index) {
            return (E) a[index];
        }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

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

    既然没有使用加锁,那么存储数据的字段一定会使用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();
          }
      }
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19

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

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

          public static  T[] copyOf(U[] original, int newLength, Class newType) {
              @SuppressWarnings("unchecked")
              //创建新数组
              T[] copy = ((Object)newType == (Object)Object[].class)
                  ? (T[]) new Object[newLength]
                  : (T[]) Array.newInstance(newType.getComponentType(), newLength);
              //从旧数组复制旧数据到新数组
              System.arraycopy(original, 0, copy, 0,
                               Math.min(original.length, newLength));
              return copy;
          }
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10

      在获取迭代器时,会用原数据去新创建一个对象,后续在这个对象上迭代

      因此就不会出现线程不安全的并发修改问题,这是并发包中提供的安全失败fail-safe机制

          public Iterator iterator() {
              return new COWIterator(getArray(), 0);
          }
      • 1
      • 2

      使用场景

      经过我们的分析,我们可以知道CopyOnWriteArrayList的特点,从而来分析适用于它的使用场景

      使用volatile保证内存可见性,读操作不需要加锁,因此性能非常好,在搭配上数组的随机访问,它非常适合读取场景

      在进行写操作时,使用JUC下的ReentrantLock加锁解锁保证原子性,并且写时要进行数据拷贝,因此它并不适合频繁写操作的场景,因为拷贝数据有开销

      当它的数据量越来越大时,进行写操作拷贝的数据也会越来越多,因此它不适合存放大量数据情况下再进行写操作

      它提供安全失败,在使用迭代器时迭代的不一定是实时最新数据,但不会发生并发修改异常

      总结

      ArrayList提供fail-fast快速失败机制,在并发读写场景下通过此机制来抛出并发修改异常以达到快速失败,在并发场景下不安全

      并发场景下的解决方案有多种,但CopyOnWriteArrayList相对于VectorCollections.synchronizedList(new ArrayList<>())来说,加锁粒度更低,性能较好

      CopyOnWriteArrayList用volatile修饰数组保证其可见性,在读场景下无需加锁

      在写场景下使用ReentrantLock保证写操作原子性,写的同时会先拷贝一份原数据,修改完再设置成新数据

      在获取迭代器时,也是通过原数据去创建一个新对象进行迭代,这样能保证虽然数据可能不是最新的,但不会出现异常

      CopyOnWriteArrayList适合数据量不大、读多写少、迭代时能接受弱一致性的场景

      不要在数据量特别大,写操作特别多的场景使用,这样写时拷贝的开销可能会非常大

      最后(不要白嫖,一键三连求求拉~)

      本篇文章被收入专栏 由点到线,由线到面,深入浅出构建Java并发编程知识体系,感兴趣的同学可以持续关注喔

      本篇文章笔记以及案例被收入 gitee-StudyJavagithub-StudyJava 感兴趣的同学可以stat下持续关注喔~

      案例地址:

      Gitee-JavaConcurrentProgramming/src/main/java/F_Collections

      Github-JavaConcurrentProgramming/src/main/java/F_Collections

      有什么问题可以在评论区交流,如果觉得菜菜写的不错,可以点赞、关注、收藏支持一下~

      关注菜菜,分享更多干货,公众号:菜菜的后端私房菜

      本文由博客一文多发平台 OpenWrite 发布!

    • 相关阅读:
      英语口语学习(19-20)
      不花冤枉钱~希喂、小米、霍尼韦尔宠物空气净化器性价比之战!
      java实现一个简单的监听器
      【Axure】axure rp 导入元件库和使用,主流元件库下载使用
      Java虚拟机(JVM)
      定制 ElementPlus 主题
      DSP篇--C6678功能调试系列之网络调试
      Wireshark在Windows上安装后报错怎么办?
      农产品商城毕业设计,农产品销售系统毕业设计,农产品电商毕业设计论文方案需求分析作品参考
      C/C++ Linux 开发环境
    • 原文地址:https://blog.csdn.net/Tc_lccc/article/details/132866285