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 實現的。

適用場景

讀操作可以盡可能的快,而寫即使慢一些也沒關系

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

讀多寫少

黑名單是最典型的場景,假如我們有一個搜索網站,用戶在這個網站的搜索框中,輸入關鍵字搜索內容,但是某些關鍵字不允許被搜索。這些不能被搜索的關鍵字會被放在一個黑名單中,黑名單並不需要實時更新,可能每天晚上更新一次就可以了。當用戶搜索時,會檢查當前關鍵字在不在黑名單中,如果在,則提示不能搜索。這種讀多寫少的場景也很適合使用 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 既不會感知到,也不會受影響,執行迭代操作不需要加鎖,也不會因此拋出異常。迭代器返回的結果,和創建迭代器的時候的內容一致。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM