2
头图
前几天刷博客时,无意中看到一篇名为《CopyOnWriteArrayList真的完全线程安全吗》博客。心中不禁泛起疑问,它就是线程安全的啊,难道还有啥特殊情况?

我们知道CopyOnWrite的核心思想正如其名:写时复制。在对数据有修改操作时,先复制再操作,最后替换原数组。在这些操作时,是有加锁的了。

1 问题复现

这篇博文中主要提到数组越界异常。场景为:假设现在有一个已存在的列表,线程1尝试去查询列表最后一个元素,而此时线程2要去删除列表最后一个元素。此时线程1由于最开始读取的size()=n,在线程2删除后size()=n-1,再拿原Index方式时,便触发ArrayIndexOutOfBoundsException异常。

其实读到这里,我们就已经知道了问题所在。在读取列表大小根据索引访问两个时间点,列表数据已经发生了改变。这种异常理论上属于可预知的异常。

请看下面的代码,并思考下并发执行会有问题吗

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

while (true) {
    if (!cowList.isEmpty()) {
        cowList.remove(0);
    } else {
        return;
    }
}

我们不妨来试下。

/**
 * @author lpe234
 * @date 2022/12/03
 */
@Slf4j
public class CowalTest {
    public static void main(String[] args) {
        List<String> l = new ArrayList<>();
        for (int i = 0; i < 100; i++) {
            l.add(String.valueOf(i));
        }

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

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

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

程序执行结果如下:

Exception in thread "Thread-1" java.lang.ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0
    at java.base/java.util.concurrent.CopyOnWriteArrayList.elementAt(CopyOnWriteArrayList.java:386)
    at java.base/java.util.concurrent.CopyOnWriteArrayList.remove(CopyOnWriteArrayList.java:478)
    at com.example.other.CowalTest.lambda$main$0(CowalTest.java:25)
    at java.base/java.lang.Thread.run(Thread.java:834)

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

2 源码分析

核心属性及get/set方法。

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) {
        array = a;
    }

可见实现其实很简单。内部使用Object[] array来承载数据。使用volatile来保证多线程下数组的可见性。

再看下isEmptyremove方法。

public int size() {
    return getArray().length;
}

public boolean isEmpty() {
    return size() == 0;
}

public E remove(int index) {
    synchronized (lock) {
        Object[] es = getArray();
        int len = es.length;
        E oldValue = elementAt(es, index);
        int numMoved = len - index - 1;
        Object[] newElements;
        if (numMoved == 0)
            newElements = Arrays.copyOf(es, len - 1);
        else {
            newElements = new Object[len - 1];
            System.arraycopy(es, 0, newElements, 0, index);
            System.arraycopy(es, index + 1, newElements, index,
                             numMoved);
        }
        setArray(newElements);
        return oldValue;
    }
}

可以很清晰的看到,在这俩方法中,均有getArray()调用。如果中间出现其他线程修改数据,这俩数据必然不一致。在看一个add(E e)方法。

public boolean add(E e) {
    synchronized (lock) {
        Object[] es = getArray();
        int len = es.length;
        es = Arrays.copyOf(es, len + 1);
        es[len] = e;
        setArray(es);
        return true;
    }
}

此时我们可以很清晰的看清他的编程逻辑。

  • 凡是对数组有修改的操作,先获取锁。
  • 通过getArray()获取数据。前面已加锁,为最新数据,在释放锁前不会有其他线程修改。
  • 对数据进行相关修改操作,Arrays.copyOf是重点。
  • 通过setArray(es)将修改后的数据赋值给原数组。
  • 释放锁。

3 思考

3.1 通过本例我们能学到什么

  • 类似CopyOnWriteArrayList这种并发安全的类,如果不合理(不规范的、错误的)的使用,也会导致并发安全问题
  • 面对事物,要知其然知其所以然。只有了解内部原理,才能更好的去使用它。
  • CopyOnWriteArrayList代码中可以看到,当遇到修改操作时,基本都离不开Arrays.copyOf,这种拷贝会占用额外一倍的内存空间。如果有大量频繁的修改操作,显然是不太合适的。
  • 在修改相关操作代码逻辑中,可以体会到,整体是有那么一点点的延迟的。即一个线程修改完并setArray后,另外的线程才能获取到最新值。

3.2 其他的呢

  • CopyOnWrite是一种很好的思想,它能够使读、写操作并发执行。在Redis的RDB快照生成时,也使用了该思想。
  • 为什么会有final transient Object lock = new Object()这个锁?如果细心看过源码就能明白,其实就是最大程度的减少锁的范围(粒度)。
public boolean addAll(Collection<? extends E> c) {
    Object[] cs = (c.getClass() == CopyOnWriteArrayList.class) ?
        ((CopyOnWriteArrayList<?>)c).getArray() : c.toArray();
    if (cs.length == 0)
        return false;
    synchronized (lock) {
        // 略...
    }
}

echo '5Y6f5Yib5paH56ugOiDmjpjph5Eo5L2g5oCO5LmI5Zad5aW26Iy25ZWKWzkyMzI0NTQ5NzU1NTA4MF0pL+aAneWQpihscGUyMzQp' | base64 -d

lpe234
4.1k 声望2k 粉丝

路漫漫其修远兮