CopyOnWriteArrayList 使用介绍

一、背景

1.1 线程安全问题复现

如果我们想把一系列执行结果放到 List 集合中,可能会这样实现:

List<Integer> list = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
    // 处理业务
    list.add(i);
}

为了提升执行结果,我们可能会用到多线程:

public static void main(String[] args) throws InterruptedException {
    ThreadPoolExecutor executor = new ThreadPoolExecutor(
            10,
            10,
            1,
            TimeUnit.MINUTES,
            new LinkedBlockingQueue<>(10000),
            r -> new Thread(r, "DemoThread-" + r.hashCode()));

    List<Integer> list = new ArrayList<>();
    CountDownLatch countDownLatch = new CountDownLatch(1000);
    for (int i = 0; i < 1000; i++) {
        int num = i;
        executor.execute(() -> {
            try {
                // 处理业务
                Thread.sleep(100L);
                list.add(num);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            } finally {
                countDownLatch.countDown();
            }
        });
    }
    countDownLatch.await();
    list.sort(Integer::compareTo);
    list.forEach(System.out::println);
    executor.shutdown();
}

执行结果:

在这里插入图片描述

可以看到莫名其妙抛出了 ArrayIndexOutOfBoundsException 异常,而且结果中也出现了null,虽然我们知道 ArrayList<> 是线程不安全的,但是具体是为什么呢?

1.2 问题跟踪

首先看看 ArrayList 类所拥有的部分属性字段:

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
    /**
     * 列表元素集合数组
     * 如果新建ArrayList对象时没有指定大小,那么会将EMPTY_ELEMENTDATA赋值给elementData,
     * 并在第一次添加元素时,将列表容量设置为DEFAULT_CAPACITY 
     */
    transient Object[] elementData; 

    /**
     * 列表大小,elementData中存储的元素个数
     */
    private int size;
}

通过这两个字段我们可以看出,ArrayList 的实现主要就是用了一个 Object[],用来保存所有的元素,以及一个 size 变量用来保存当前数组中已经添加了多少元素。

接着我们看下最重要的 add 操作时的源码:

public boolean add(E e) {

    /**
     * 添加一个元素时,做了如下两步操作
     * 1.判断列表的capacity容量是否足够,是否需要扩容
     * 2.真正将元素放在列表的元素数组里面
     */
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;
    return true;
}
  • ensureCapacityInternal() 这个方法的详细代码我们可以暂时不看,它的作用就是判断如果将当前的新元素添加到列表后面,列表的 elementData 数组的大小是否满足,如果 size+1 的这个需求长度大于 elementData 数组的长度,那么就要对这个数组进行扩容。

由此看到 add 元素时,实际做了两个大的步骤:

  1. 判断 elementData 数组容量是否满足需求;
  2. 在 elementData 对应的位置上设置值。

针对这两个步骤,就出现了两个导致线程不安全的隐患。

线程安全隐患一:数组越界

在第1步 ensureCapacityInternal(size + 1) 中,如果多个线程进行调用可能会导致 elementData 数组越界,具体逻辑如下:

在这里插入图片描述

  1. 列表大小为9,即:size=9。

在这里插入图片描述

  1. 线程 A 开始进入 add 方法,这时它获取到 size 的值为 9,调用 ensureCapacityInternal 方法进行容量判断。
  2. 线程 B 此时也进入 add 方法,它获取到 size 的值也为 9,也开始调用 ensureCapacityInternal 方法。

在这里插入图片描述

  1. 线程 A 发现需求大小为 10,而 elementData 的大小就为 10,可以容纳。于是它不再扩容,返回。
  2. 线程 B 也发现需求大小为 10,也可以容纳,返回。

在这里插入图片描述

  1. 线程 A 开始进行设置值操作,elementData[size++]=e 操作,此时 size 变为 10。
  2. 线程 B 也开始进行设置值操作,它尝试设置 elementData[10]=e,而 elementData 没有进行过扩容,它的下表最大为9。于是此时会爆出一个数组越界的异常 ArrayIndexOutOfBoundsException
线程安全隐患二:值为null

第二步 elementData[size++]=e 设置值的操作同样会导致线程不安全。从这里可以看出,这部操作也不是一个原子操作,它由如下两步操作构成:

  1. elementData[size] = e;
  2. size = size + 1;

在单线程执行这两行代码时,没有任何问题,但是当多线程环境下执行时,可能就会发生一个线程的值覆盖另一个线程添加的值,具体逻辑如下:

在这里插入图片描述

  1. 列表大小为0,即:size=0。

在这里插入图片描述

  1. 线程 A 开始添加一个元素,值为 A,此时它执行第一条操作,将 A 放在了 elementData 下表为 0 的位置上。

在这里插入图片描述

  1. 接着,线程 B 刚好也要开始添加一个值为 B 的元素,且走到了第一步操作。此时线程 B 获取到 size 的值依然为 0,于是它将 B 也放在了 elementData 下标为 0 的位置上。
  2. 线程 A 开始将 size 的值增加为 1。
  3. 线程 B 开始将 size 的值增加为 2。

这样,线程 A、B 执行完毕后,

  • 理想情况下:size=2,elementData[0]=A,elementData[1]=B。
  • 而实际情况变成了:size=2,elementData[0]=B,elementData[1]=null。

在这里插入图片描述

因为线程 A、B 执行完毕后,size=2,所以下一个线程添加元素时,会从下标为 2 的位置上开始:elementData[2]=C。

那么如何解决 ArrayList 的线程安全问题呢?这时候就需要我们的主角 CopyOnWriteArrayList 登场了。


二、定义

2.1 什么是 CopyOnWriteArrayList?

CopyOnWriteArrayList:是 JDK 在并发包(java.util.concurrent)下的一个类,它是 ArrayList 的一个线程安全的变体。CopyOnWrite 即 读写分离,读时共享,写时复制,通俗的理解是:当我们往一个 List 添加元素的时候,不直接往当前 List 添加,而是先将当前 List 进行 Copy,复制出一个新的 List,然后新的容器里添加元素,添加完元素之后,再将原 List 的引用指向新的 List。 这种设计使得读操作可以在原数组上进行,而不需要加锁,从而大大提高了读操作的并发性。

2.2 CopyOnWriteArrayList 的优点

  1. 线程安全: 由于写操作(修改操作),会导致底层数组的复制,因此可以确保在修改过程中不会被其他现场线程干扰,从而保证了线程安全。
  2. 读操作性能高: 由于读操作不需要加锁,因此多个线程可以同时进行读操作,而不会相互阻塞。这使得在高并发场景下,读操作的性能可以得到更好的保障。
  3. 适用于读多写少的场景: 如果一个列表大部分时间都被用来读取,而只有少部分时间被用来修改,那么使用 CopyOnWriteArrayList 是一个很好的选择。因为它可以确保在读取时不会受到修改操作的影响,从而提供稳定的读取性能。

2.3 CopyOnWriteArrayList 的缺点

  1. 内存占用大: 由于每次修改都会导致底层数组的复制,因此如果列表的大小很大,或者修改操作很频繁,那么就会占用大量的内存空间。这可能会导致频繁的垃圾回收,从而影响系统的整体性能。
  2. 数据一致性问题: 由于读取和修改操作是在不同的数组上进行的,因此如果在读取过程中有其他线程进行了修改操作,那么读取到的数据可能不是最新的。也就是说,CopyOnWriteArrayList 只能保证数据的最终一致性,但无法保证数据的实时一致性。
  3. 写操作性能较低: 由于每次修改都需要复制整个底层数组,因此写操作的性能会相对较低。特别是在列表大小很大或者修改操作很频繁的情况下,这种性能下降会更加明显。
  4. 不支持迭代器修改: CopyOnWriteArrayList 的迭代器不支持对列表的修改操作(如 add、remove 等)。如果在迭代过程中尝试修改列表,会抛出 UnsupportedOperationException 异常。这是因为迭代器在迭代过程中持有的是原始数组的引用,而修改操作会导致底层数组的复制和替换,从而导致迭代器的引用失效。因此,如果需要在迭代过程中修改列表,应该使用其他的数据结构或者采取其他的并发控制措施。

三、解决问题

我们再用 CopyOnWriteArrayList 重新实现最开始我们提到的问题:

public static void main(String[] args) throws InterruptedException {
    ThreadPoolExecutor executor = new ThreadPoolExecutor(
            10,
            10,
            1,
            TimeUnit.MINUTES,
            new LinkedBlockingQueue<>(10000),
            r -> new Thread(r, "DemoThread-" + r.hashCode()));

    CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
    CountDownLatch countDownLatch = new CountDownLatch(1000);
    for (int i = 0; i < 1000; i++) {
        int num = i;
        executor.execute(() -> {
            try {
                // 处理业务
                Thread.sleep(100L);
                list.add(num);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            } finally {
                countDownLatch.countDown();
            }
        });
    }
    countDownLatch.await();
    list.forEach(System.out::println);
    executor.shutdown();
}

执行结果:

在这里插入图片描述

可以看到,没有报错,没有 null 值,完美~ 🎉

整理完毕,完结撒花~ 🌻





参考地址:

1.为什么说ArrayList是线程不安全的?https://blog.csdn.net/u012859681/article/details/78206494

  • 21
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

不愿放下技术的小赵

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值