併發容器之CopyOnWriteArrayList

1. CopyOnWriteArrayList的簡介

java學習者都清楚ArrayList並非線程安全的,在讀線程在讀取ArrayList的時候若是有寫線程在寫數據的時候,基於fast-fail機制,會拋出ConcurrentModificationException異常,也就是說ArrayList並非一個線程安全的容器,固然您能夠用Vector,或者使用Collections的靜態方法將ArrayList包裝成一個線程安全的類,可是這些方式都是採用java關鍵字synchronzied對方法進行修飾,利用獨佔式鎖來保證線程安全的。可是,因爲獨佔式鎖在同一時刻只有一個線程可以獲取到對象監視器,很顯然這種方式效率並非過高。html

回到業務場景中,有不少業務每每是讀多寫少的,好比系統配置的信息,除了在初始進行系統配置的時候須要寫入數據,其餘大部分時刻其餘模塊以後對系統信息只須要進行讀取,又好比白名單,黑名單等配置,只須要讀取名單配置而後檢測當前用戶是否在該配置範圍之內。相似的還有不少業務場景,它們都是屬於讀多寫少的場景。若是在這種狀況用到上述的方法,使用Vector,Collections轉換的這些方式是不合理的,由於儘管多個讀線程從同一個數據容器中讀取數據,可是讀線程對數據容器的數據並不會發生發生修改。很天然而然的咱們會聯想到ReenTrantReadWriteLock(關於讀寫鎖能夠看這篇文章),經過讀寫分離的思想,使得讀讀之間不會阻塞,無疑若是一個list可以作到被多個讀線程讀取的話,性能會大大提高很多。可是,若是僅僅是將list經過讀寫鎖(ReentrantReadWriteLock)進行再一次封裝的話,因爲讀寫鎖的特性,當寫鎖被寫線程獲取後,讀寫線程都會被阻塞。若是僅僅使用讀寫鎖對list進行封裝的話,這裏仍然存在讀線程在讀數據的時候被阻塞的狀況,若是想list的讀效率更高的話,這裏就是咱們的突破口,若是咱們保證讀線程不管何時都不被阻塞,效率豈不是會更高?java

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方法的源碼爲:app

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操做等等,緣由是,全部的讀線程只是會讀取數據容器中的數據,並不會進行修改。post

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

數組中已有數據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容器。

參考資料

《java併發編程的藝術》

COW講解

相關文章
相關標籤/搜索