并發容器之CopyOnWriteArrayList

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

詳情請戳www.codercc.com

image

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

數組中已有數據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講解

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,825評論 6 546
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,814評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,980評論 0 384
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 64,064評論 1 319
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,779評論 6 414
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,109評論 1 330
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,099評論 3 450
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,287評論 0 291
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,799評論 1 338
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,515評論 3 361
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,750評論 1 375
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,221評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,933評論 3 351
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,327評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,667評論 1 296
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,492評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,703評論 2 380

推薦閱讀更多精彩內容

  • Java8張圖 11、字符串不變性 12、equals()方法、hashCode()方法的區別 13、...
    Miley_MOJIE閱讀 3,726評論 0 11
  • Java SE 基礎: 封裝、繼承、多態 封裝: 概念:就是把對象的屬性和操作(或服務)結合為一個獨立的整體,并盡...
    Jayden_Cao閱讀 2,130評論 0 8
  • 從三月份找實習到現在,面了一些公司,掛了不少,但最終還是拿到小米、百度、阿里、京東、新浪、CVTE、樂視家的研發崗...
    時芥藍閱讀 42,340評論 11 349
  • Java-Review-Note——4.多線程 標簽: JavaStudy PS:本來是分開三篇的,后來想想還是整...
    coder_pig閱讀 1,671評論 2 17
  • Buffer的基本用法 使用Buffer讀寫數據一般遵循以下四個步驟:1、分配空間2、寫入數據到Buffer3、調...
    平凡的小Y閱讀 1,830評論 0 4