併發容器之CopyOnWriteArrayList

你聽___發表於2018-05-06

原創文章&經驗總結&從校招到 A 廠一路陽光一路滄桑

詳情請戳www.codercc.com

1. CopyOnWriteArrayList 的簡介

java 學習者都清楚 ArrayList 並不是執行緒安全的,在讀執行緒在讀取 ArrayList 的時候如果有寫執行緒在寫資料的時候,基於 fast-fail 機制,會丟擲ConcurrentModificationException異常,也就是說 ArrayList 並不是一個執行緒安全的容器,當然您可以用 Vector,或者使用 Collections 的靜態方法將 ArrayList 包裝成一個執行緒安全的類,但是這些方式都是採用 java 關鍵字 synchronzied 對方法進行修飾,利用獨佔式鎖來保證執行緒安全的。但是,由於獨佔式鎖在同一時刻只有一個執行緒能夠獲取到物件監視器,很顯然這種方式效率並不是太高。

回到業務場景中,有很多業務往往是讀多寫少的,比如系統配置的資訊,除了在初始進行系統配置的時候需要寫入資料,其他大部分時刻其他模組之後對系統資訊只需要進行讀取,又比如白名單,黑名單等配置,只需要讀取名單配置然後檢測當前使用者是否在該配置範圍以內。類似的還有很多業務場景,它們都是屬於讀多寫少的場景。如果在這種情況用到上述的方法,使用 Vector,Collections 轉換的這些方式是不合理的,因為儘管多個讀執行緒從同一個資料容器中讀取資料,但是讀執行緒對資料容器的資料並不會發生發生修改。很自然而然的我們會聯想到 ReenTrantReadWriteLock(關於讀寫鎖可以看這篇文章),通過讀寫分離的思想,使得讀讀之間不會阻塞,無疑如果一個 list 能夠做到被多個讀執行緒讀取的話,效能會大大提升不少。但是,如果僅僅是將 list 通過讀寫鎖(ReentrantReadWriteLock)進行再一次封裝的話,由於讀寫鎖的特性,當寫鎖被寫執行緒獲取後,讀寫執行緒都會被阻塞。如果僅僅使用讀寫鎖對 list 進行封裝的話,這裡仍然存在讀執行緒在讀資料的時候被阻塞的情況,如果想 list 的讀效率更高的話,這裡就是我們的突破口,如果我們保證讀執行緒無論什麼時候都不被阻塞,效率豈不是會更高?

Doug Lea 大師就為我們提供 CopyOnWriteArrayList 容器可以保證執行緒安全,保證讀讀之間在任何時候都不會被阻塞,CopyOnWriteArrayList 也被廣泛應用於很多業務場景之中,CopyOnWriteArrayList 值得被我們好好認識一番。

2. COW 的設計思想

回到上面所說的,如果簡單的使用讀寫鎖的話,在寫鎖被獲取之後,讀寫執行緒被阻塞,只有當寫鎖被釋放後讀執行緒才有機會獲取到鎖從而讀到最新的資料,站在讀執行緒的角度來看,即讀執行緒任何時候都是獲取到最新的資料,滿足資料實時性。既然我們說到要進行優化,必然有 trade-off,我們就可以犧牲資料實時性滿足資料的最終一致性即可。而 CopyOnWriteArrayList 就是通過 Copy-On-Write(COW),即寫時複製的思想來通過延時更新的策略來實現資料的最終一致性,並且能夠保證讀執行緒間不阻塞。

COW 通俗的理解是當我們往一個容器新增元素的時候,不直接往當前容器新增,而是先將當前容器進行 Copy,複製出一個新的容器,然後新的容器裡新增元素,新增完元素之後,再將原容器的引用指向新的容器。對 CopyOnWrite 容器進行併發的讀的時候,不需要加鎖,因為當前容器不會新增任何元素。所以 CopyOnWrite 容器也是一種讀寫分離的思想,延時更新的策略是通過在寫的時候針對的是不同的資料容器來實現的,放棄資料實時性達到資料的最終一致性。

3. CopyOnWriteArrayList 的實現原理

現在我們來通過看原始碼的方式來理解 CopyOnWriteArrayList,實際上 CopyOnWriteArrayList 內部維護的就是一個陣列

/** The array, accessed only via getArray/setArray. */
private transient volatile Object[] array;
複製程式碼

並且該陣列引用是被 volatile 修飾,注意這裡僅僅是修飾的是陣列引用,其中另有玄機,稍後揭曉。關於 volatile 很重要的一條性質是它能夠夠保證可見性,關於 volatile 的詳細講解可以看這篇文章。對 list 來說,我們自然而然最關心的就是讀寫的時候,分別為 get 和 add 方法的實現。

3.1 get 方法實現原理

get 方法的原始碼為:

public E get(int index) {
    return get(getArray(), index);
}
/**
 * Gets the array.  Non-private so as to also be accessible
 * from CopyOnWriteArraySet class.
 */
final Object[] getArray() {
    return array;
}
private E get(Object[] a, int index) {
    return (E) a[index];
}
複製程式碼

可以看出來 get 方法實現非常簡單,幾乎就是一個“單執行緒”程式,沒有對多執行緒新增任何的執行緒安全控制,也沒有加鎖也沒有 CAS 操作等等,原因是,所有的讀執行緒只是會讀取資料容器中的資料,並不會進行修改。

3.2 add 方法實現原理

再來看下如何進行新增資料的?add 方法的原始碼為:

public boolean add(E e) {
    final ReentrantLock lock = this.lock;
	//1. 使用Lock,保證寫執行緒在同一時刻只有一個
    lock.lock();
    try {
		//2. 獲取舊陣列引用
        Object[] elements = getArray();
        int len = elements.length;
		//3. 建立新的陣列,並將舊陣列的資料複製到新陣列中
        Object[] newElements = Arrays.copyOf(elements, len + 1);
		//4. 往新陣列中新增新的資料
		newElements[len] = e;
		//5. 將舊陣列引用指向新的陣列
        setArray(newElements);
        return true;
    } finally {
        lock.unlock();
    }
}
複製程式碼

add 方法的邏輯也比較容易理解,請看上面的註釋。需要注意這麼幾點:

  1. 採用 ReentrantLock,保證同一時刻只有一個寫執行緒正在進行陣列的複製,否則的話記憶體中會有多份被複制的資料;
  2. 前面說過陣列引用是 volatile 修飾的,因此將舊的陣列引用指向新的陣列,根據 volatile 的 happens-before 規則,寫執行緒對陣列引用的修改對讀執行緒是可見的。
  3. 由於在寫資料的時候,是在新的陣列中插入資料的,從而保證讀寫實在兩個不同的資料容器中進行操作。

4. 總結

我們知道 COW 和讀寫鎖都是通過讀寫分離的思想實現的,但兩者還是有些不同,可以進行比較:

COW vs 讀寫鎖

相同點:1. 兩者都是通過讀寫分離的思想實現;2.讀執行緒間是互不阻塞的

不同點:對讀執行緒而言,為了實現資料實時性,在寫鎖被獲取後,讀執行緒會等待或者當讀鎖被獲取後,寫執行緒會等待,從而解決“髒讀”等問題。也就是說如果使用讀寫鎖依然會出現讀執行緒阻塞等待的情況。而 COW 則完全放開了犧牲資料實時性而保證資料最終一致性,即讀執行緒對資料的更新是延時感知的,因此讀執行緒不會存在等待的情況

對這一點從文字上還是很難理解,我們來通過 debug 看一下,add 方法核心程式碼為:

1.Object[] elements = getArray();
2.int len = elements.length;
3.Object[] newElements = Arrays.copyOf(elements, len + 1);
4.newElements[len] = e;
5.setArray(newElements);
複製程式碼

假設 COW 的變化如下圖所示:

最終一致性的分析.png
最終一致性的分析.png

陣列中已有資料 1,2,3,現在寫執行緒想往陣列中新增資料 4,我們在第 5 行處打上斷點,讓寫執行緒暫停。讀執行緒依然會“不受影響”的能從陣列中讀取資料,可是還是隻能讀到 1,2,3。如果讀執行緒能夠立即讀到新新增的資料的話就叫做能保證資料實時性。當對第 5 行的斷點放開後,讀執行緒才能感知到資料變化,讀到完整的資料 1,2,3,4,而保證資料最終一致性,儘管有可能中間間隔了好幾秒才感知到。

這裡還有這樣一個問題: 為什麼需要複製呢? 如果將 array 陣列設定為 volitile 的, 對 volatile 變數寫 happens-before 讀,讀執行緒不是能夠感知到 volatile 變數的變化

原因是,這裡 volatile 的修飾的僅僅只是陣列引用陣列中的元素的修改是不能保證可見性的。因此 COW 採用的是新舊兩個資料容器,通過第 5 行程式碼將陣列引用指向新的陣列。

這也是為什麼 concurrentHashMap 只具有弱一致性的原因,關於 concurrentHashMap 的弱一致性可以看這篇文章

COW 的缺點

CopyOnWrite 容器有很多優點,但是同時也存在兩個問題,即記憶體佔用問題和資料一致性問題。所以在開發的時候需要注意一下。

  1. 記憶體佔用問題:因為 CopyOnWrite 的寫時複製機制,所以在進行寫操作的時候,記憶體裡會同時駐紮兩個對 象的記憶體,舊的物件和新寫入的物件(注意:在複製的時候只是複製容器裡的引用,只是在寫的時候會建立新對 象新增到新容器裡,而舊容器的物件還在使用,所以有兩份物件記憶體)。如果這些物件佔用的記憶體比較大,比 如說 200M 左右,那麼再寫入 100M 資料進去,記憶體就會佔用 300M,那麼這個時候很有可能造成頻繁的 minor GC 和 major GC。

  2. 資料一致性問題:CopyOnWrite 容器只能保證資料的最終一致性,不能保證資料的實時一致性。所以如果你希望寫入的的資料,馬上能讀到,請不要使用 CopyOnWrite 容器。

參考資料

  1. 《java 併發程式設計的藝術》
  2. COW 講解

相關文章