33-CopyOnWriteArrayList 有什麼特點?

敖奕_Nuage發表於2020-12-30

刪除線格式 故事要從誕生 CopyOnWriteArrayList 之前說起。其實在 CopyOnWriteArrayList 出現之前,我們已經有了 ArrayList 和 LinkedList 作為 List 的陣列和連結串列的實現,而且也有了執行緒安全的 Vector 和 Collections.synchronizedList() 可以使用。所以首先就讓我們來看下執行緒安全的 Vector 的 size 和 get 方法的程式碼:

public synchronized int size() {
    return elementCount;
}
public synchronized E get(int index) {
    if (index >= elementCount)
        throw new ArrayIndexOutOfBoundsException(index);
    return elementData(index);
}

可以看出,Vector 內部是使用 synchronized 來保證執行緒安全的,並且鎖的粒度比較大,都是方法級別的鎖,在併發量高的時候,很容易發生競爭,併發效率相對比較低。在這一點上,Vector 和 Hashtable 很類似。

並且,前面這幾種 List 在迭代期間不允許編輯,如果在迭代期間進行新增或刪除元素等操作,則會丟擲 ConcurrentModificationException 異常,這樣的特點也在很多情況下給使用者帶來了麻煩。

所以從 JDK1.5 開始,Java 併發包裡提供了使用 CopyOnWrite 機制實現的併發容器 CopyOnWriteArrayList 作為主要的併發 List,CopyOnWrite 的併發集合還包括 CopyOnWriteArraySet,其底層正是利用 CopyOnWriteArrayList 實現的。所以今天我們以 CopyOnWriteArrayList 為突破口,來看一下 CopyOnWrite 容器的特點。

適用場景
  • 讀操作可以儘可能的快,而寫即使慢一些也沒關係
    在很多應用場景中,讀操作可能會遠遠多於寫操作。比如,有些系統級別的資訊,往往只需要載入或者修改很少的次數,但是會被系統內所有模組頻繁的訪問。對於這種場景,我們最希望看到的就是讀操作可以儘可能的快,而寫即使慢一些也沒關係。

  • 讀多寫少
    黑名單是最典型的場景,假如我們有一個搜尋網站,使用者在這個網站的搜尋框中,輸入關鍵字搜尋內容,但是某些關鍵字不允許被搜尋。這些不能被搜尋的關鍵字會被放在一個黑名單中,黑名單並不需要實時更新,可能每天晚上更新一次就可以了。當使用者搜尋時,會檢查當前關鍵字在不在黑名單中,如果在,則提示不能搜尋。這種讀多寫少的場景也很適合使用 CopyOnWrite 集合。

讀寫規則
  • 讀寫鎖的規則
    讀寫鎖的思想是:讀讀共享、其他都互斥(寫寫互斥、讀寫互斥、寫讀互斥),原因是由於讀操作不會修改原有的資料,因此併發讀並不會有安全問題;而寫操作是危險的,所以當寫操作發生時,不允許有讀操作加入,也不允許第二個寫執行緒加入。

  • 對讀寫鎖規則的升級
    CopyOnWriteArrayList 的思想比讀寫鎖的思想又更進一步。為了將讀取的效能發揮到極致,CopyOnWriteArrayList 讀取是完全不用加鎖的,更厲害的是,寫入也不會阻塞讀取操作,也就是說你可以在寫入的同時進行讀取,只有寫入和寫入之間需要進行同步,也就是不允許多個寫入同時發生,但是在寫入發生時允許讀取同時發生。這樣一來,讀操作的效能就會大幅度提升。

特點
  • CopyOnWrite的含義
    從 CopyOnWriteArrayList 的名字就能看出它是滿足 CopyOnWrite 的 ArrayList,CopyOnWrite 的意思是說,當容器需要被修改的時候,不直接修改當前容器,而是先將當前容器進行 Copy,複製出一個新的容器,然後修改新的容器,完成修改之後,再將原容器的引用指向新的容器。這樣就完成了整個修改過程。

這樣做的好處是,CopyOnWriteArrayList 利用了“不變性”原理,因為容器每次修改都是建立新副本,所以對於舊容器來說,其實是不可變的,也是執行緒安全的,無需進一步的同步操作。我們可以對 CopyOnWrite 容器進行併發的讀,而不需要加鎖,因為當前容器不會新增任何元素,也不會有修改。

CopyOnWriteArrayList 的所有修改操作(add,set等)都是通過建立底層陣列的新副本來實現的,所以 CopyOnWrite 容器也是一種讀寫分離的思想體現,讀和寫使用不同的容器。

  • 迭代期間允許修改集合內容
    我們知道 ArrayList 在迭代期間如果修改集合的內容,會丟擲 ConcurrentModificationException 異常。讓我們來分析一下 ArrayList 會丟擲異常的原因。

在 ArrayList 原始碼裡的 ListItr 的 next 方法中有一個 checkForComodification 方法,程式碼如下:

final void checkForComodification() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}

這裡會首先檢查 modCount 是否等於 expectedModCount。modCount 是儲存修改次數,每次我們呼叫 add、remove 或 trimToSize 等方法時它會增加,expectedModCount 是迭代器的變數,當我們建立迭代器時會初始化並記錄當時的 modCount。後面迭代期間如果發現 modCount 和 expectedModCount 不一致,就說明有人修改了集合的內容,就會丟擲異常。

和 ArrayList 不同的是,CopyOnWriteArrayList 的迭代器在迭代的時候,如果陣列內容被修改了,CopyOnWriteArrayList 不會報 ConcurrentModificationException 的異常,因為迭代器使用的依然是舊陣列,只不過迭代的內容可能已經過時了。演示程式碼如下:

/**
* 描述: 演示CopyOnWriteArrayList迭代期間可以修改集合的內容
*/
public class CopyOnWriteArrayListDemo {
 
    public static void main(String[] args) {
 
        CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>(new Integer[]{1, 2, 3});
 
        System.out.println(list); //[1, 2, 3]
 
        //Get iterator 1
        Iterator<Integer> itr1 = list.iterator();
 
        //Add one element and verify list is updated
        list.add(4);
 
        System.out.println(list); //[1, 2, 3, 4]
 
        //Get iterator 2
        Iterator<Integer> itr2 = list.iterator();
 
        System.out.println("====Verify Iterator 1 content====");
 
        itr1.forEachRemaining(System.out::println); //1,2,3
 
        System.out.println("====Verify Iterator 2 content====");
 
        itr2.forEachRemaining(System.out::println); //1,2,3,4
 
    }
 
}

這段程式碼會首先建立一個 CopyOnWriteArrayList,並且初始值被賦為 [1, 2, 3],此時列印出來的結果很明顯就是 [1, 2, 3]。然後我們建立一個叫作 itr1 的迭代器,建立之後再新增一個新的元素,利用 list.add() 方法把元素 4 新增進去,此時我們列印出 List 自然是 [1, 2, 3, 4]。我們再建立一個叫作 itr2 的迭代器,在下方把兩個迭代器迭代產生的內容列印出來,這段程式碼的執行結果是:

[1, 2, 3]
[1, 2, 3, 4]
====Verify Iterator 1 content====
1
2
3
====Verify Iterator 2 content====
1
2
3
4

可以看出,這兩個迭代器列印出來的內容是不一樣的。第一個迭代器列印出來的是 [1, 2, 3],而第二個列印出來的是 [1, 2, 3, 4]。雖然它們的列印時機都發生在第四個元素被新增之後,但它們的建立時機是不同的。由於迭代器 1 被建立時的 List 裡面只有三個元素,後續無論 List 有什麼修改,對它來說都是無感知的。

以上這個結果說明了,CopyOnWriteArrayList 的迭代器一旦被建立之後,如果往之前的 CopyOnWriteArrayList 物件中去新增元素,在迭代器中既不會顯示出元素的變更情況,同時也不會報錯,這一點和 ArrayList 是有很大區別的。

缺點

這些缺點不僅是針對 CopyOnWriteArrayList,其實同樣也適用於其他的 CopyOnWrite 容器:

  • 記憶體佔用問題
    因為 CopyOnWrite 的寫時複製機制,所以在進行寫操作的時候,記憶體裡會同時駐紮兩個物件的記憶體,這一點會佔用額外的記憶體空間。

  • 在元素較多或者複雜的情況下,複製的開銷很大
    複製過程不僅會佔用雙倍記憶體,還需要消耗 CPU 等資源,會降低整體效能。

  • 資料一致性問題
    由於 CopyOnWrite 容器的修改是先修改副本,所以這次修改對於其他執行緒來說,並不是實時能看到的,只有在修改完之後才能體現出來。如果你希望寫入的的資料馬上能被其他執行緒看到,CopyOnWrite 容器是不適用的。

原始碼分析
  • 資料結構
/** 可重入鎖物件 */
final transient ReentrantLock lock = new ReentrantLock();
 
/** CopyOnWriteArrayList底層由陣列實現,volatile修飾,保證陣列的可見性 */
private transient volatile Object[] array;
 
/**
* 得到陣列
*/
final Object[] getArray() {
    return array;
}
 
/**
* 設定陣列
*/
final void setArray(Object[] a) {
    array = a;
}
 
/**
* 初始化CopyOnWriteArrayList相當於初始化陣列
*/
public CopyOnWriteArrayList() {
    setArray(new Object[0]);
}

在這個類中首先會有一個 ReentrantLock 鎖,用來保證修改操作的執行緒安全。下面被命名為 array 的 Object[] 陣列是被 volatile 修飾的,可以保證陣列的可見性,這正是儲存元素的陣列,同樣,我們可以從 getArray()、setArray 以及它的構造方法看出,CopyOnWriteArrayList 的底層正是利用陣列實現的,這也符合它的名字。

  • add 方法
public boolean add(E e) {
 
    // 加鎖
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
 
        // 得到原陣列的長度和元素
        Object[] elements = getArray();
        int len = elements.length;
 
        // 複製出一個新陣列
        Object[] newElements = Arrays.copyOf(elements, len + 1);
 
        // 新增時,將新元素新增到新陣列中
        newElements[len] = e;
 
        // 將volatile Object[] array 的指向替換成新陣列
        setArray(newElements);
        return true;
    } finally {
        lock.unlock();
    }
}

add 方法的作用是往 CopyOnWriteArrayList 中新增元素,是一種修改操作。首先需要利用 ReentrantLock 的 lock 方法進行加鎖,獲取鎖之後,得到原陣列的長度和元素,也就是利用 getArray 方法得到 elements 並且儲存 length。之後利用 Arrays.copyOf 方法複製出一個新的陣列,得到一個和原陣列內容相同的新陣列,並且把新元素新增到新陣列中。完成新增動作後,需要轉換引用所指向的物件,利用 setArray(newElements) 操作就可以把 volatile Object[] array 的指向替換成新陣列,最後在 finally 中把鎖解除。

總結流程:在新增的時候首先上鎖,並複製一個新陣列,增加操作在新陣列上完成,然後將 array 指向到新陣列,最後解鎖。

上面的步驟實現了 CopyOnWrite 的思想:寫操作是在原來容器的拷貝上進行的,並且在讀取資料的時候不會鎖住 list。而且可以看到,如果對容器拷貝操作的過程中有新的讀執行緒進來,那麼讀到的還是舊的資料,因為在那個時候物件的引用還沒有被更改。

下面我們來分析一下讀操作的程式碼,也就是和 get 相關的三個方法,分別是 get 方法的兩個過載和 getArray 方法,程式碼如下:

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];
}

可以看出,get 相關的操作沒有加鎖,保證了讀取操作的高速。

  • 迭代器 COWIterator 類
    這個迭代器有兩個重要的屬性,分別是 Object[] snapshot 和 int cursor。其中 snapshot 代表陣列的快照,也就是建立迭代器那個時刻的陣列情況,而 cursor 則是迭代器的遊標。迭代器的構造方法如下:
private COWIterator(Object[] elements, int initialCursor) {
    cursor = initialCursor;
    snapshot = elements;
}

可以看出,迭代器在被構建的時候,會把當時的 elements 賦值給 snapshot,而之後的迭代器所有的操作都基於 snapshot 陣列進行的,比如:

public E next() {
    if (! hasNext())
        throw new NoSuchElementException();
    return (E) snapshot[cursor++];
}

在 next 方法中可以看到,返回的內容是 snapshot 物件,所以,後續就算原陣列被修改,這個 snapshot 既不會感知到,也不會受影響,執行迭代操作不需要加鎖,也不會因此丟擲異常。迭代器返回的結果,和建立迭代器的時候的內容一致。

以上我們對 CopyOnWriteArrayList 進行了介紹。我們分別介紹了在它誕生之前的 Vector 和 Collections.synchronizedList() 的特點,CopyOnWriteArrayList 的適用場景、讀寫規則,還介紹了它的兩個特點,分別是寫時複製和迭代期間允許修改集合內容。我們還介紹了它的三個缺點,分別是記憶體佔用問題,在元素較多或者複雜的情況下複製的開銷大問題,以及資料一致性問題。最後我們對於它的重要原始碼進行了解析。

相關文章