解析Java并发:CopyOnWriteArrayList线程安全的秘密

295 阅读5分钟

CopyOnWriteArrayList真的线程安全吗?

最近笔者看了一遍关于《CopyOnWriteArrayList真的完全线程安全》的文章,心中不禁疑惑,无论是平常道听途说,还是网上各种资料,包括在面试中都是高频热点话题,所以它到底是不是绝对的程安全呢?

0.前置知识

image.png

1.写入时复制

  • 写入时复制(CopyOnWrite,简称COW)思想是计算机程序设计领域中的一种通用优化策略。其核心思想是,如果有多个调用者(Callers)同时访问相同的资源(如内存或者是磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者修改资源内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这过程对其他的调用者都是透明的(transparently)。此做法主要的优点是如果调用者没有修改资源,就不会有副本(private copy)被创建,因此多个调用者只是读取操作时可以共享同一份资源。
  • 通俗易懂的讲,写入时复制技术就是不同进程在访问同一资源的时候,只有更新操作,才会去复制一份新的数据并更新替换,否则都是访问同一个资源。
  • JDK 的 CopyOnWriteArrayList/CopyOnWriteArraySet 容器正是采用了 COW 思想,它是如何工作的呢?简单来说,就是平时查询的时候,都不需要加锁,随便访问,只有在更新的时候,才会从原来的数据复制一个副本出来,然后修改这个副本,最后把原数据替换成当前的副本。修改操作的同时,读操作不会被阻塞,而是继续读取旧的数据。这点要跟读写锁区分一下。

2.volatile

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

3.transient

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

1.问题复现

  • 假设现在有一个已存在的列表,线程A尝试去查询列表最后一个元素,而此时线程B要去删除列表最后一个元素。此时线程A由于最开始读取的size()=n,在线程B删除后size()=n-1,再拿原Index方式时,便触发ArrayIndexOutOfBoundsException异常。

给出最简单的思路代码:

    CopyOnWriteArrayList<String> cowList = new CopyOnWriteArrayList<>(l);
    while (true) {
        if (!cowList.isEmpty()) {
            cowList.remove(0);
        } else {
            return;
        }
    }

我们来试一下:

/**
 * Editor: hengBao
 * date: 2022/12/5/22:14
 */
@Slf4j
public class COWTest {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        for (int i = 0; i < 100; i++) {
            list.add(String.valueOf(i));
        }

        CopyOnWriteArrayList<String> cowList = new CopyOnWriteArrayList<>(list);

        final Runnable rab = () -> {
            while (true) {
                if (!cowList.isEmpty()) {
                    cowList.remove(0);
                } else {
                    return;
                }
            }
        };

        new Thread(rab).start();
        new Thread(rab).start();
        new Thread(rab).start();
        new Thread(rab).start();
        new Thread(rab).start();
        new Thread(rab).start();
        new Thread(rab).start();
    }
}

结果输出:

Exception in thread "Thread-0" Exception in thread "Thread-3" java.lang.ArrayIndexOutOfBoundsException: 0
	at java.util.concurrent.CopyOnWriteArrayList.get(CopyOnWriteArrayList.java:388)
	at java.util.concurrent.CopyOnWriteArrayList.remove(CopyOnWriteArrayList.java:495)
	at COWTest.lambda$main$0(COWTest.java:26)
	at java.lang.Thread.run(Thread.java:748)

原因就在于cowList.isEmpty()和cowList.remove(0)为两个操作。在这两个操作之间,并没有什么机制来保证cowList不会改变。所以出现异常,是可预见的。

2.源码分析

成员属性

public class CopyOnWriteArrayList<E>
    implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
    private static final long serialVersionUID = 8673264195747942595L;

    /** 所有涉及到array变更操作的锁。(在内置锁和ReentrantLock都可使用时,我们更倾向于内置锁) */
    final transient Object lock = new Object();

    /** 这个数组的所有访问,只会通过getArray/setArray来进行。 */
    private transient volatile Object[] array;

    /**
     * Gets the array.  Non-private so as to also be accessible
     * from CopyOnWriteArraySet class.
     */
    final Object[] getArray() {
        return array;
    }

    /**
     * Sets the array.
     */
    final void setArray(Object[] a) {
        arr

【写】操作肯定是重中之重,包括:增(add)、删(remove)、改(set)。下面看这个,嘿嘿:

add方法

    //获得独占锁
    final ReentrantLock lock = this.lock;
    //加锁
    lock.lock();
    try {
        //获得list底层的数组array
        Object[] elements = getArray();
        //获得数组长度
        int len = elements.length;
        //拷贝到新数组,新数组长度为len+1
        Object[] newElements = Arrays.copyOf(elements, len + 1);
        //给新数组末尾元素赋值
        newElements[len] = e;
        //用新的数组替换掉原来的数组
        setArray(newElements);
        return true; 
    } finally {
        lock.unlock();//释放锁
    }

其实也非常简单,就是在访问的时候加锁,拷贝出来一个副本,先操作这个副本,再把现有的数据替换为这个副本。

【读】,主要是查 get(int index)这个方法,普通的无锁访问

   public E get(int index) {
    return get(getArray(), index);
    }
    final Object[] getArray() {
        return array;
    }
    private E get(Object[] a, int index) {
        return (E) a[index];
    }   

3.优点和缺点

3.1、优点

  • 对于一些读多写少的数据,写入时复制的做法就很不错,例如配置、黑名单、物流地址等变化非常少的数据,这是一种无锁的实现。可以帮我们实现程序更高的并发。
  • CopyOnWriteArrayList 并发安全且性能比 Vector 好。Vector 是增删改查方法都加了synchronized 来保证同步,但是每个方法执行的时候都要去获得锁,性能就会大大下降,而 CopyOnWriteArrayList 只是在增删改上加锁,但是读不加锁,在读方面的性能就好于 Vector。

3.2、缺点

  • 内存占用问题。因为CopyOnWrite的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对象的内存,旧的对象和新写入的对象(注意:在复制的时候只是复制容器里的引用,只是在写的时候会创建新对象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存)
  • 数据一致性问题。CopyOnWrite是最终一致性,在写的过程中,原有的读的数据是不会发生更新的,只有新的读才能读到最新数据。
  • 写的时候不能并发写,需要对写操作进行加锁;

4.总结

  • 面对事物,要知其然知其所以然。只有了解内部原理,才能更好的去使用它。
  • 对于CopyOnWriteArrayList这种并发安全的类,如果不合理(不规范的、错误的)的使用,也会导致并发安全问题。在CopyOnWriteArrayList代码中可以看到,当遇到修改操作时,基本都离不开Arrays.copyOf,这种拷贝会占用额外一倍的内存空间。如果有大量频繁的修改操作,显然是不太合适的。