天天看點

并發容器之CopyOnWriteArrayList

本人免費整理了Java進階資料,涵蓋了Java、Redis、MongoDB、MySQL、Zookeeper、Spring Cloud、Dubbo高并發分布式等教程,一共30G,需要自己領取。

傳送門:https://mp.weixin.qq.com/s/JzddfH-7yNudmkjT0IRL8Q

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的變化如下圖所示:

并發容器之CopyOnWriteArrayList

數組中已有資料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容器有很多優點,但是同時也存在兩個問題,即記憶體占用問題和資料一緻性問題。是以在開發的時候需要注意一下。