5分钟从0到1探秘CopyOnWriteArrayList

### 5分钟从0到1探秘CopyOnWriteArrayList

### 前言

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

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

### 并发场景下的ArrayList

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

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

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

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

```java
    public void testCopyOnWriteArrayList() throws InterruptedException {
        List<String> 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);
    }
```

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

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

```java
    public String toString() {
        Iterator<E> 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`字段**

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

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

### 并发场景下的解决方案

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

在远古版本在并发场景下使用`Vector`或`Collections.synchronizedList(new ArrayList<>())`

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

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

但`synchronized`经过锁升级优化后,性能还差吗?如果对其感兴趣的同学可以查看[15000字、6个代码案例、5个原理图让你彻底搞懂Synchronized ](https://juejin.cn/post/7272015112819556412)

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

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

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

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

如果对此不理解的同学,可以查看[5个案例和流程图让你从0到1搞懂volatile关键字](https://juejin.cn/post/7270869041631002636)

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

### CopyOnWrite

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

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

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

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

### CopyOnWriteArrayList

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

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

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

```java
    final void setArray(Object[] a) {
        array = a;
    }

    public CopyOnWriteArrayList() {
        setArray(new Object[0]);
    }
```

再来看看get读操作

```java
    public E get(int index) {
        return get(getArray(), index);
    }

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

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

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

```java
private transient volatile Object[] array;
```

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

接着来看看写操作-添加

```java
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`进行复制,但写操作太多也会导致性能开销太大

```java
    public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> 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;
    }
```

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

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

```java
    public Iterator<E> iterator() {
        return new COWIterator<E>(getArray(), 0);
    }
```

### 使用场景

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

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

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

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

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

### 总结

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

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

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

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

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

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

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

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

本篇文章被收入专栏 [由点到线,由线到面,深入浅出构建Java并发编程知识体系](https://juejin.cn/column/7270046881542766604),感兴趣的同学可以持续关注喔

本篇文章笔记以及案例被收入 [gitee-StudyJava](https://gitee.com/tcl192243051/StudyJava)、 [github-StudyJava](https://github.com/Tc-liang/StudyJava) 感兴趣的同学可以stat下持续关注喔~

案例地址:

[Gitee-JavaConcurrentProgramming/src/main/java/F_Collections](https://gitee.com/tcl192243051/StudyJava/tree/master/深入浅出Java并发编程/JavaConcurrentProgramming/src/main/java/F_Collections)

[Github-JavaConcurrentProgramming/src/main/java/F_Collections](https://github.com/Tc-liang/StudyJava/tree/main/%E6%B7%B1%E5%85%A5%E6%B5%85%E5%87%BAJava%E5%B9%B6%E5%8F%91%E7%BC%96%E7%A8%8B/JavaConcurrentProgramming/src/main/java/F_Collections)

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

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

雷军:小米全新操作系统澎湃 OS 正式版已完成封包 国美 App 抽奖页面弹窗辱骂其创始人 美国政府限制向中国出口 NVIDIA H800 GPU 小米澎湃OS界面曝光 大神用 Scratch 手搓 RISC-V 模拟器,成功运行 Linux 内核 RustDesk 远程桌面 1.2.3 发布,增强 Wayland 支持 拔出罗技 USB 接收器后,Linux 内核竟然崩溃了 DHH 锐评“打包工具”:前端根本不需要构建 (No Build) JetBrains 推出 Writerside,创建技术文档的工具 Node.js 21 正式发布
{{o.name}}
{{m.name}}

猜你喜欢

转载自my.oschina.net/u/6903207/blog/10112324