深入解析 CopyOnWriteArrayList

一、详解java中有序集合的并发容器

1. Vector如何实现线程安全

对于并发操作的有序集合容器,相信大部分都会想到非常传统的容器Vector,原因很简单,查看源码时我们非常直观的看到其针对任何读写操作都上了一把synchronized 锁:

java 复制代码
public synchronized E get(int index) {
  //......
      //获取对象实例锁之后,调用elementData返回元素
        return elementData(index);
    }

    public synchronized E set(int index, E element) {
      //......
  //获取实例锁后开始执行更新操作,先获取旧元素
        E oldValue = elementData(index);
  //更新元素
        elementData[index] = element;
        //返回旧的值
        return oldValue;
    }

2. synchronizedList如何保证线程安全

Collections.synchronizedList同理,只不过synchronizedList这个方法是针对原生数组的封装,通过方法内部上一把对象锁来保证线程安全:

arduino 复制代码
public E get(int index) {
            synchronized (mutex) {return list.get(index);}
        }
        public E set(int index, E element) {
            synchronized (mutex) {return list.set(index, element);}
        }

3. Vector和synchronizedList真的可以保证并发操作安全吗?

尽管Vector和synchronizedList都通过加锁的方式完成并发操作的互斥,但是他们真的安全嘛?如下代码所示,在遍历时进行集合清除操作,就会出现ConcurrentModificationException异常:

scss 复制代码
Vector<Integer> vector = new Vector<>();
        vector.add(1);
        vector.add(2);
        vector.add(3);
        vector.add(4);
        vector.add(5);
        //迭代期间一个并发线程清除元素
        for (Integer item : vector) {
            new Thread(vector::clear).start();
            System.out.println(item);
        }

4. 为什么Vector加了synchronized之后在多线程操作下还会出现异常呢?

本质上这是一种fail-fast(快速失败)思想,即针对可能发生的异常进行提前表明故障的一种工作机制,我们都知道util包下的集合默认情况下是不支持线程安全的,所以JDK设计者为了能够提前感知并发操作失败并抛出异常,提出通过检查迭代期间修改次数是否变化来实现fail-fast,由此保证在避免在异常时执行非必要的复杂代码。

在多线程情况下,线程1进行并发修改操作,不断修改当前集合的modCount ,在这期间,另一个线程初始化一个迭代器进行遍历,这是就会出现expectedModCount会初始化为线程1某个操作阶段的modCount不等,进而触发fail-fast告知用户当前非线程安全容器存在线程安全问题,需要注意:

二、详解cow思想

1. 什么是cow思想,如何保证的线程安全

从CopyOnWriteArrayList源码中可知,COW即通过采用写时复制的思想,在迭代时的修改通过复制一份快照数组,并基于该数组完成并发修改操作,完成操作后再原子替换调原来的数组,由此保证线程安全,因为该操作涉及写时复制以及大数组的拷贝操作,这其中的开销还是蛮大的,一般情况下的CopyOnWriteArrayList更适用于一些读多写少的并发场景:

ini 复制代码
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;
            //array指向新的数组
            setArray(newElements);
            return true;
        } finally {
            lock.unlock();
        }
    }

2. 什么是fail-fast和fail-safe

关于fail-fast引用medium中一篇文章关于fail-fast和fail-safe的说法:

Fail-fast systems are designed to immediately stop functioning upon encountering an unexpected condition. This immediate failure helps to catch errors early, making debugging more straightforward.

快速失败的思想即针对可能发生的异常进行提前表明故障并停止运行,通过尽早的发现和停止错误,降低故障系统级联的风险。

我们都知道java.util包下的大部分集合是不支持线程安全的,所以JDK设计者为了能够提前发现并发操作导致线程安全风险,提出通过维护一个modCount记录修改的次数,迭代期间通过比对预期修改次数expectedModCount和modCount是否一致来判断是否存在并发操作,从而实现快速失败,由此保证在避免在异常时执行非必要的复杂代码。

对应的我们给出下面这样一段在示例,我们首先插入100个操作元素,一个线程迭代元素,一个线程删除元素,最终输出结果如愿抛出ConcurrentModificationException:

ini 复制代码
ArrayList<Integer> list = new ArrayList<>();
        CountDownLatch countDownLatch = new CountDownLatch(2);
        //添加几个元素
        for (int i = 0; i < 100; i++) {
            list.add(i);
        }

        Thread t1 = new Thread(() -> {
            //迭代元素
            for (Integer i : list) {
                i++;
            }
            countDownLatch.countDown();
        });


        Thread t2 = new Thread(() -> {
            System.out.println("删除元素1");
            list.remove(1);
            countDownLatch.countDown();
        });

        t1.start();
        t2.start();
        countDownLatch.await();

我们在初始化时插入了100个元素,此时对应的修改modCount次数为100,随后线程2在线程1迭代期间进行元素删除操作,此时对应的modCount就变为101。 线程1在随后foreach第2轮循环发现modCount 为101,与预期的expectedModCount(值为100因为初始化插入了元素100个)不等,判定为并发操作异常,于是便快速失败,抛出ConcurrentModificationException:

对此我们也给出迭代器获取下一个元素时的next方法,可以看到其内部的checkForComodification具有针对修改次数比对的逻辑:

scss 复制代码
public E next() {
    //检查是否存在并发修改
            checkForComodification();
            //......
            //返回下一个元素
            return (E) elementData[lastRet = i];
        }

final void checkForComodification() {
  //当前循环遍历次数和预期修改次数不一致时,就会抛出ConcurrentModificationException
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
        }

而fail-safe也就是安全失败的含义,它旨在即使面对意外情况也能恢复并继续运行,这使得它特别适用于不确定或者不稳定的环境:

Fail-safe systems take a different approach, aiming to recover and continue even in the face of unexpected conditions. This makes them particularly suited for uncertain or volatile environments.

该思想常运用于并发容器,最经典的实现就是CopyOnWriteArrayList的实现,通过写时复制的思想保证在进行修改操作时复制出一份快照,基于这份快照完成添加或者删除操作后,将CopyOnWriteArrayList底层的数组引用指向这个新的数组空间,由此避免迭代时被并发修改所干扰导致线程安全问题,当然这种做法也使得进行遍历操作时无法获得实时结果:

对应我们也给出CopyOnWriteArrayList实现fail-safe的核心代码,可以看到它的实现就是通过getArray获取数组引用然后通过Arrays.copyOf得到一个数组的快照,基于这个快照完成添加操作后,修改底层array变量指向的引用地址由此完成写时复制:

ini 复制代码
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;
            //array指向新的数组
            setArray(newElements);
            return true;
        } finally {
            lock.unlock();
        }
    }

3. 与传统集合的性能比对

与传统集合相比,CopyOnWriteArrayList更适合读多写少的情况,例如:黑名单、配置等相关集合。如下代码所示,我们就能看出写操作CopyOnWriteArrayList确实开销更大。且CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性:

ini 复制代码
long start = System.currentTimeMillis();
        List<Integer> copyOnWriteArrayList = new CopyOnWriteArrayList<>();
        int loopCount = 10_0000;
        //添加10w个元素到copyOnWriteArrayList
        for (int i = 0; i < loopCount; i++) {
            copyOnWriteArrayList.add(1);
        }

        long end = System.currentTimeMillis();
        System.out.println(end - start);
        //添加10w个元素到synchronizedList
        start = System.currentTimeMillis();
        List<Integer> synchronizedList = Collections.synchronizedList(new ArrayList<>());
        for (int i = 0; i < loopCount; i++) {
            synchronizedList.add(1);
        }
        end = System.currentTimeMillis();
        System.out.println(end - start);

输出结果:

yaml 复制代码
3813
4
相关推荐
明明如月学长几秒前
什么你不知道 Cherry Studio 有快捷助手?
算法
云边散步3 分钟前
《校园生活平台从 0 到 1 的搭建》第四篇:微信授权登录前端
前端·javascript·后端
架构师沉默6 分钟前
让我们一起用 DDD,构建更美好的软件世界!
java·后端·架构
Vegetable_Dragon11 分钟前
数论1.01
算法
胖头鱼不吃鱼-12 分钟前
Go 原理之 GMP 并发调度模型
java·jvm·golang
Star在努力16 分钟前
15-C语言:第15天笔记
c语言·笔记·算法
JosieBook20 分钟前
【IDEA】idea怎么修改注册的用户名称?
java·intellij-idea·策略模式
研究司马懿23 分钟前
【Golang】Go语言函数
开发语言·后端·golang
tuokuac40 分钟前
创建的springboot工程java文件夹下还是文件夹而不是包
java·spring boot·后端
笃行35041 分钟前
搭建专属AI聊天网站:NextChat + 蓝耘MaaS平台完整部署指南
后端