敞开成长之旅!这是我参与「日新方案 12 月更文挑战」的第7天,点击查看活动概况

前几天刷博客时,无意中看到一篇名为《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