深度解析CopyOnWriteArrayList,線程安全的ArrayList

前言

ArrayList是線程不安全的,這點毋庸置疑。因爲ArrayList的所有方法既沒有加鎖,也沒有進行額外的線程安全處理。而Vector作爲線程安全版的ArrayList,存在感總是比較低。因爲無論是addremove還是get方法都加上了synchronized鎖,所以效率低下。
JDK1.5引入的J.U.C包中,又實現了一個線程安全版的ArrayList——CopyOnWriteArrayList

成員變量

先來看下CopyOnWriteArrayList類的定義和底層數據結構

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

    /** The lock protecting all mutators */
    transient final ReentrantLock lock = new ReentrantLock();

    /** The array, accessed only via getArray/setArray. */
    // 存儲數據的array數組,注意此處是用volatile修飾的
    private volatile transient Object[] array;
}

根據定義來看,比ArrayList多了一個ReentrantLock成員變量,存儲數據的數組用volatile修飾,其餘的並沒有多少區別。存儲數據的結構依然是數組。

構造方法

/**
* Sets the array.
* 語法糖
*/
final void setArray(Object[] a) {
    array = a;
}

/**
* Creates an empty list.
*/
public CopyOnWriteArrayList() {
    setArray(new Object[0]);
}

/**
* Creates a list holding a copy of the given array.
* 創建一個保存給定數組副本的list(把參數給的數組拷貝給成員變量)
*
* @throws NullPointerException if the specified array is null
* 參數數組爲null,拋出NullPointerException
*/
public CopyOnWriteArrayList(E[] toCopyIn) {
    setArray(Arrays.copyOf(toCopyIn, toCopyIn.length, Object[].class));
}

看完構造方法依然有些疑惑,成員變量和構造方法看起來比ArrayList還要簡單,到底是如何保證線程安全的呢。或許add方法會給我們答案。

核心方法

add(E e)

add(E e)方法用於往list尾部添加元素,CopyOnWriteArrayListadd(E e)方法源碼如下:

/**
 * Appends the specified element to the end of this list.
 * 往list尾部添加指定元素
 *
 * @param e element to be appended to this list
 * @return <tt>true</tt> (as specified by {@link Collection#add})
 */
public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    // 加鎖
    lock.lock();
    try {
    	// 獲取成員變量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();
    }
}

從這段代碼中可以得出如下信息:

  • add方法通過ReentrantLock保證同一時刻最多隻有一個線程向list中添加元素,肯定是線程安全的
  • 並不是直接往數組中添加元素,而是開闢新數組,把元素插入新數組,再用新數組替換舊數組

既然ReentrantLock已經保證了線程安全,爲什麼還需要開闢新數組?
因爲volatile修飾數組時,僅能保證數組的引用具有volatile語義。也就是說volatile修飾的數組,即使數組中的元素被改變了,也不會觸發可見性。想要解決這個問題有兩種辦法

  • 使用AtomicIntegerArray或者AtomicLongArray
  • 修改數組的內存地址,也就是對數組進行重新賦值

除了volatile語義的問題,還有一個原因就是爲了get方法,下文會詳細介紹這個方法。

add(int index, E element)

add(int index, E element)方法用於往list指定位置添加元素,源碼如下:

/**
 * 指定位置添加元素
 *
 * @throws IndexOutOfBoundsException {@inheritDoc}
 */
public void add(int index, E element) {
    final ReentrantLock lock = this.lock;
    // 加鎖
    lock.lock();
    try {
    	// 獲取原數組
        Object[] elements = getArray();
        int len = elements.length;
        if (index > len || index < 0)
            throw new IndexOutOfBoundsException("Index: "+index+
                                                ", Size: "+len);
        Object[] newElements;
        // 計算需要移動的元素的個數
        int numMoved = len - index;
        if (numMoved == 0)
        	// 在尾部新增
            newElements = Arrays.copyOf(elements, len + 1);
        else {
        	// 開闢新數組
            newElements = new Object[len + 1];
            // 拷貝index之前的元素到新數組,拷貝前後,元素下標不變
            System.arraycopy(elements, 0, newElements, 0, index);
            // 拷貝index之後的元素到新數組,拷貝之後,下標+1
            // 因爲新數組index處需要空出來留給新增元素
            System.arraycopy(elements, index, newElements, index + 1,
                             numMoved);
        }
        newElements[index] = element;
        // 新數組替換原數組
        setArray(newElements);
    } finally {
    	// 解鎖
        lock.unlock();
    }
}

這兩個add方法完成的功能不一樣,但是實現步驟和原理都差不多,都可以抽象成5步:
1、加鎖
2、開闢新數組
3、拷貝元素
4、新數組替換舊數組
5、解鎖

CopyOnWriteArrayList雖然底部也是數組實現,但是沒有擴容這個說法。因爲每次add都會開闢新的數組。況且每次add都會加鎖,所以效率是比較低的。

remove(int index)

remove(int index)方法用於刪除並返回指定位置的元素,其源碼如下:

/**
 * 刪除並返回指定位置的元素
 *
 * @throws IndexOutOfBoundsException {@inheritDoc}
 */
public E remove(int index) {
    final ReentrantLock lock = this.lock;
    // 加鎖
    lock.lock();
    try {
    	// 獲取原數組
        Object[] elements = getArray();
        int len = elements.length;
        // 獲取指定位置的值,用於返回
        E oldValue = get(elements, index);
        // 需要移動的元素的個數
        int numMoved = len - index - 1;
        if (numMoved == 0)
        	// 刪除的恰好是尾部元素
            setArray(Arrays.copyOf(elements, len - 1));
        else {
        	// 開闢新數組
            Object[] newElements = new Object[len - 1];
            // 拷貝index之前的元素到新數組,拷貝前後下標不變
            System.arraycopy(elements, 0, newElements, 0, index);
            // 拷貝index之後的元素到新數組,拷貝之後下標-1
            System.arraycopy(elements, index + 1, newElements, index,
                             numMoved);
            // 新數組替換原數組
            setArray(newElements);
        }
        // 返回刪除的值
        return oldValue;
    } finally {
    	// 解鎖
        lock.unlock();
    }
}

從源碼可以看出,不管是add也好,還是remove也好。都是通過ReentrantLock + volatile + 數組拷貝來實現線程安全的。
寫到這裏,也並沒有看出來CopyOnWriteArrayListVector高效到哪裏去,況且前者每次add/remove操作都會開闢新數組,相當於浪費了一倍的空間。

那麼,接下來就是見證奇…

咳咳,沒有奇蹟,來看看CopyOnWriteArrayList的優點。
vector效率低就低在get也加上了synchronized鎖,但是CopyOnWriteArrayListget方法就不用了加鎖

get(int index)

get(int index)方法用於獲取指定位置的元素,源碼如下:

/**
 * {@inheritDoc}
 *
 * @throws IndexOutOfBoundsException {@inheritDoc}
 */
public E get(int index) {
	// 調用內部get方法
    return get(getArray(), index);
}

@SuppressWarnings("unchecked")
private E get(Object[] a, int index) {
    return (E) a[index];
}

可以看到get(int index)不需要加鎖,因爲CopyOnWriteArrayListadd/remove操作時,不會修改原數組,所以讀操作不會存在線程安全問題。這其實就是讀寫分離的思想,只有寫入的時候才加鎖,複製副本來進行修改。CopyOnWriteArrayList也叫寫時複製容器。

而且在迭代過程中,即使數組的結構被改變也不會拋出ConcurrentModificationException異常。因爲迭代的始終是原數組,而所有的變化都發生在原數組的副本上。所以對於迭代器來說,迭代的集合結構不會發生改變。

優缺點

CopyOnWriteArrayList的優點主要有兩個:

  • 線程安全
  • 大大的提高了“讀”操作的併發度(相比於Vector

缺點也很明顯:

  • 每次“寫”操作都會開闢新的數組,浪費空間
  • 無法保證實時性,因爲“讀”和“寫”不在同一個數組,且“讀”操作沒有加互斥鎖,所以不能保證強一致性,只能保證最終一致性
  • add/remove操作效率低,既要加鎖,還要拷貝數組

所以CopyOnWriteArrayList比較適合讀多寫少的場景。

注意:千萬千萬不要在循環中對CopyOnWriteArrayList進行add/remove操作,CopyOnWriteArrayList提供了對應的批量處理方法addAllremoveAll
以下是在循環中進行add操作和addAll操作對比:

/**
 * 循環 + add vs addAll
 */
public class CopyOnWriteArrayListDemo {

    private static final int COUNT = 100000;
    private static final List<Integer> list1 = new CopyOnWriteArrayList<>();
    private static final List<Integer> list2 = new CopyOnWriteArrayList<>();

    public static void main(String[] args) {
        List<Integer> dataList = new ArrayList<>(COUNT);
        for (int i = 0; i < COUNT; i++) {
            dataList.add(i);
        }

        testCopyOnWriteArrayList(dataList);
    }

    private static void testCopyOnWriteArrayList(List<Integer> dataList) {
        long time1 = System.currentTimeMillis();
        for (Integer data : dataList) {
            list1.add(data);
        }
        long time2 = System.currentTimeMillis();
        System.out.println("循環+add 耗時:" + (time2 - time1) / 1000.0 + " 秒");
        list2.addAll(dataList);
        long time3 = System.currentTimeMillis();
        System.out.println("addAll  耗時:" + (time3 - time2) / 1000.0 + " 秒");
    }
}

執行結果

循環+add 耗時:2.604 秒
addAll  耗時:0.001 秒

這樣很直觀的看到了兩者的效率差異。

總結

CopyOnWriteArrayList利用ReentrantLock + volatile + 數組拷貝實現了線程安全的ArrayList。在特定的場景下使用CopyOnWriteArrayList既能保證線程安全,又能有較好的表現。

參考

  • https://www.javamex.com/tutorials/volatile_arrays.shtml
  • http://ifeve.com/volatile-array-visiblity/
發佈了55 篇原創文章 · 獲贊 107 · 訪問量 1萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章