zoukankan      html  css  js  c++  java
  • CopyOnWriteArrayList应用和原理

    CopyOnWriteArrayList出现背景

    在 CopyOnWriteArrayList 出现之前,已经有了 ArrayList 和 LinkedList 作为 List 的数组和链表的实现,而且也有了线程安全的 Vector 和 Collections.synchronizedList() 可以使用,为什么还要新建一个类呐,其实都是出于效率的考量。
    首先就让我们来看下线程安全的 Vector 的 size 和 get 方法的代码:

    public synchronized int size() {
        return elementCount;
    }
    public synchronized E get(int index) {
        if (index >= elementCount)
            throw new ArrayIndexOutOfBoundsException(index);
        return elementData(index);
    }
    

    可以看出,Vector 内部是使用 synchronized 来保证线程安全的,并且锁的粒度比较大,都是方法级别的锁,在并发量高的时候,很容易发生竞争,并发效率相对比较低。在这一点上,Vector 和 Hashtable 很类似。

    并且,前面这几种 List 在迭代期间不允许编辑,如果在迭代期间进行添加或删除元素等操作,则会抛出 ConcurrentModificationException 异常,这样的特点也在很多情况下给使用者带来了麻烦。

    所以从 JDK1.5 开始,Java 并发包里提供了使用 CopyOnWrite 机制实现的并发容器 CopyOnWriteArrayList 作为主要的并发 List,CopyOnWrite 的并发集合还包括 CopyOnWriteArraySet,其底层正是利用 CopyOnWriteArrayList 实现的。

    简单使用演示

    我们知道 ArrayList 在迭代期间如果修改集合的内容,会抛ConcurrentModificationException 异常,但是CopyOnWriteArrayList可以在读期间修改,先演示使用

    public class CopyOnWriteArrayListDemo {
        static CountDownLatch count = new CountDownLatch(4);
        /**
         * 读线程
         */
        private static class ReadTask implements Runnable {
            List<String> list;
    
            public ReadTask(List<String> list) {
                this.list = list;
            }
    
            public void run() {
                for (String str : list) {
                    System.out.println("读取"+str);
                }
                count.countDown();
            }
        }
    
        /**
         * 写线程
         */
        private static class WriteTask implements Runnable {
            List<String> list;
            int index;
    
            public WriteTask(List<String> list, int index) {
                this.list = list;
                this.index = index;
            }
    
            public void run() {
                list.remove(index);
                list.add(index, "write_" + index);
                count.countDown();
            }
        }
    
        public void run() throws InterruptedException {
            final int NUM = 2;
            List<String> list = new CopyOnWriteArrayList<>();//这里如果用普通的ArrayList这种会报错,可以试着运行下
            for (int i = 0; i < NUM; i++) {
                list.add("main_" + i);
            }
            ExecutorService executorService = Executors.newFixedThreadPool(NUM);
            for (int i = 0; i < NUM; i++) {
                executorService.execute(new ReadTask(list));
                executorService.execute(new WriteTask(list, i));
            }
            executorService.shutdown();
            count.await();//这里让主线程等着read和write线程执行完再打印最后的集合状态
            for (String s : list) {
                System.out.println("最终修改后" + s);
            }
        }
    
        public static void main(String[] args) throws InterruptedException {
            new CopyOnWriteArrayListDemo().run();
        }
    }
    

    执行结果如下:
    image

    使用场景

    1.读操作可以尽可能的快,而写即使慢一些也没关系
    在很多应用场景中,读操作可能会远远多于写操作。比如,有些系统级别的信息,往往只需要加载或者修改很少的次数,但是会被系统内所有模块频繁的访问。对于这种场景,我们最希望看到的就是读操作可以尽可能的快,而写即使慢一些也没关系。
    2.读多写少
    黑名单是最典型的场景,假如我们有一个搜索网站,用户在这个网站的搜索框中,输入关键字搜索内容,但是某些关键字不允许被搜索。这些不能被搜索的关键字会被放在一个黑名单中,黑名单并不需要实时更新,可能每天晚上更新一次就可以了。当用户搜索时,会检查当前关键字在不在黑名单中,如果在,则提示不能搜索。这种读多写少的场景也很适合使用 CopyOnWrite 集合。

    读写的规则说明

    1.读写锁的规则
    读写锁的思想是:读读共享、其他都互斥(写写互斥、读写互斥、写读互斥),原因是由于读操作不会修改原有的数据,因此并发读并不会有安全问题;而写操作是危险的,所以当写操作发生时,不允许有读操作加入,也不允许第二个写线程加入。
    2.CopyOnWriteArrayList 对读写锁规则的升级
    CopyOnWriteArrayList 的思想比读写锁的思想又更进一步。为了将读取的性能发挥到极致,CopyOnWriteArrayList 读取是完全不用加锁的,更厉害的是,写入也不会阻塞读取操作,也就是说你可以在写入的同时进行读取,只有写入和写入之间需要进行同步,也就是不允许多个写入同时发生,但是在写入发生时允许读取同时发生。这样一来,读操作的性能就会大幅度提升。

    优缺点

    优点

    1.读读、读写可以同时进行,提升效率
    从 CopyOnWriteArrayList 的名字就能看出它是满足 CopyOnWrite 的 ArrayList,CopyOnWrite 的意思是说,当容器需要被修改的时候,不直接修改当前容器,而是先将当前容器进行 Copy,复制出一个新的容器,然后修改新的容器,完成修改之后,再将原容器的引用指向新的容器。这样就完成了整个修改过程。

    这样做的好处是,CopyOnWriteArrayList 利用了“不变性”原理,因为容器每次修改都是创建新副本,所以对于旧容器来说,其实是不可变的,也是线程安全的,无需进一步的同步操作。我们可以对 CopyOnWrite 容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素,也不会有修改。

    CopyOnWriteArrayList 的所有修改操作(add,set等)都是通过创建底层数组的新副本来实现的,所以 CopyOnWrite 容器也是一种读写分离的思想体现,读和写使用不同的容器。
    2.迭代期间允许修改集合内容
    我们知道 ArrayList 在迭代期间如果修改集合的内容,会抛出 ConcurrentModificationException 异常。让我们来分析一下 ArrayList 会抛出异常的原因。

    在 ArrayList 源码里的 ListItr 的 next 方法中有一个 checkForComodification 方法,代码如下:

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

    这里会首先检查 modCount 是否等于 expectedModCount。modCount 是保存修改次数,每次我们调用 add、remove 或 trimToSize 等方法时它会增加,expectedModCount 是迭代器的变量,当我们创建迭代器时会初始化并记录当时的 modCount。后面迭代期间如果发现 modCount 和 expectedModCount 不一致,就说明有人修改了集合的内容,就会抛出异常。

    和 ArrayList 不同的是,CopyOnWriteArrayList 的迭代器在迭代的时候,如果数组内容被修改了,CopyOnWriteArrayList 不会报 ConcurrentModificationException 的异常,因为迭代器使用的依然是旧数组,只不过迭代的内容可能已经过时了。演示代码:

    /**
    ​
    * 描述: 演示CopyOnWriteArrayList迭代期间可以修改集合的内容
      */
      public class CopyOnWriteArrayListDemo {
    ​
        public static void main(String[] args) {
    ​
        CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>(new Integer[]{1, 2, 3});
    ​
        System.out.println(list); //[1, 2, 3]
    ​
        //Get iterator 1
        Iterator<Integer> itr1 = list.iterator();
    ​
        //Add one element and verify list is updated
        list.add(4);
    ​
        System.out.println(list); //[1, 2, 3, 4]
    ​
        //Get iterator 2
        Iterator<Integer> itr2 = list.iterator();
    ​
        System.out.println("====Verify Iterator 1 content====");
    ​
        itr1.forEachRemaining(System.out::println); //1,2,3
    ​
        System.out.println("====Verify Iterator 2 content====");
    ​
        itr2.forEachRemaining(System.out::println); //1,2,3,4
    ​
        }
    ​
    }
    

    这段代码会首先创建一个 CopyOnWriteArrayList,并且初始值被赋为 [1, 2, 3],此时打印出来的结果很明显就是 [1, 2, 3]。然后我们创建一个叫作 itr1 的迭代器,创建之后再添加一个新的元素,利用 list.add() 方法把元素 4 添加进去,此时我们打印出 List 自然是 [1, 2, 3, 4]。我们再创建一个叫作 itr2 的迭代器,在下方把两个迭代器迭代产生的内容打印出来,这段代码的运行结果是:

    [1, 2, 3]
    [1, 2, 3, 4]
    ====Verify Iterator 1 content====
    1
    2
    3
    ====Verify Iterator 2 content====
    1
    2
    3
    4
    

    可以看出,这两个迭代器打印出来的内容是不一样的。第一个迭代器打印出来的是 [1, 2, 3],而第二个打印出来的是 [1, 2, 3, 4]。虽然它们的打印时机都发生在第四个元素被添加之后,但它们的创建时机是不同的。由于迭代器 1 被创建时的 List 里面只有三个元素,后续无论 List 有什么修改,对它来说都是无感知的。

    以上这个结果说明了,CopyOnWriteArrayList 的迭代器一旦被建立之后,如果往之前的 CopyOnWriteArrayList 对象中去新增元素,在迭代器中既不会显示出元素的变更情况,同时也不会报错,这一点和 ArrayList 是有很大区别的。

    缺点

    1.内存占用问题
    因为 CopyOnWrite 的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对象的内存,这一点会占用额外的内存空间。

    在元素较多或者复杂的情况下,复制的开销很大

    复制过程不仅会占用双倍内存,还需要消耗 CPU 等资源,会降低整体性能。
    2.数据一致性问题
    由于 CopyOnWrite 容器的修改是先修改副本,所以这次修改对于其他线程来说,并不是实时能看到的,只有在修改完之后才能体现出来。如果你希望写入的的数据马上能被其他线程看到,CopyOnWrite 容器是不适用的。

    源码分析

    CopyOnWrite(写入时复制)思想

    CopyOnWrite(简称COW,中文意思是:写入时复制)就是在进行写操作时,先复制要改变的对象,对副本进行写操作,完成对副本的操作后,把原有对象的引用指向副本对象。原理:CopyOnWrite采用了读写分离的思想解决了线程安全且支持读多写少等问题
    image

    CopyOnWriteArrayList的实现原理

    /** The lock protecting all mutators */
        final transient ReentrantLock lock = new ReentrantLock();
    
        /** The array, accessed only via getArray/setArray. */
        private transient volatile Object[] array;
    /**
         * Appends the specified element to the end of this list.
         *
         * @param e element to be appended to this list
         * @return {@code true} (as specified by {@link Collection#add})
         */
        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();
            }
        }
    

    volatile (挥发物、易变的):变量修饰符,只能用来修饰变量。volatile修饰的成员变量在每次被线程访问时,都强迫从共享内存中重读该成员变量的值。而且,当成员变量发生变 化时,强迫线程将变化值回写到共享内存。这样在任何时刻,两个不同的线程总是看到某个成员变量的同一个值。

    transient 只能用来修饰字段。在对象序列化的过程中,标记为transient的变量不会被序列化。

    流程总结:
    在添加的时候首先上锁,并复制一个新数组,增加操作在新数组上完成,然后将 array 指向到新数组,最后解锁。
    上面的步骤实现了 CopyOnWrite 的思想:写操作是在原来容器的拷贝上进行的,并且在读取数据的时候不会锁住 list。而且可以看到,如果对容器拷贝操作的过程中有新的读线程进来,那么读到的还是旧的数据,因为在那个时候对象的引用还没有被更改。

    迭代器 COWIterator 类

    这个迭代器有两个重要的属性,分别是 Object[] snapshot 和 int cursor。其中 snapshot 代表数组的快照,也就是创建迭代器那个时刻的数组情况,而 cursor 则是迭代器的游标。迭代器的构造方法如下:

    private COWIterator(Object[] elements, int initialCursor) {
        cursor = initialCursor;
        snapshot = elements;
    }
    

    可以看出,迭代器在被构建的时候,会把当时的 elements 赋值给 snapshot,而之后的迭代器所有的操作都基于 snapshot 数组进行的,比如:

    public E next() {
        if (! hasNext())
            throw new NoSuchElementException();
        return (E) snapshot[cursor++];
    }
    

    在 next 方法中可以看到,返回的内容是 snapshot 对象,所以,后续就算原数组被修改,这个 snapshot 既不会感知到,也不会受影响,执行迭代操作不需要加锁,也不会因此抛出异常。迭代器返回的结果,和创建迭代器的时候的内容一致。

    艾欧尼亚,昂扬不灭,为了更美好的明天而战(#^.^#)
  • 相关阅读:
    Spring MVC Ajax 嵌套表单数据的提交
    Spring MVC 过滤静态资源访问
    Spring MVC 页面跳转时传递参数
    IDEA Maven 三层架构 2、运行 springMVC
    IDEA Maven 三层架构 1、基本的Archetype 搭建
    EasyUI DataGrid 基于 Ajax 自定义取值(loadData)
    Spring MVC Ajax 复杂参数的批量传递
    Mybatis Sql片段的应用
    在 Tomcat 8 部署多端口项目
    自动升级的设计思路与实现
  • 原文地址:https://www.cnblogs.com/lovelywcc/p/15306229.html
Copyright © 2011-2022 走看看