【雜談】對CopyOnWriteArrayList的認識

貓毛·波拿巴發表於2018-11-12

前言

  之前看《Java併發程式設計》這本書的時候,有看到這個,只記得"讀多寫少"、"寫入時複製"。書中沒有過多講述,只是一筆帶過(不過現在回頭看,發現講的都是精髓。老外的書大多重理論,喜歡花大篇幅講概念,這點我非常喜歡)記得當時是覺得可能有點難,先跳過了,結果就忘記回頭看了。今天突然想起來,就看了一下,整理一點東西。

非執行緒安全的ArrayList

我們知道原來util包中的ArrayList是不提供同步的,也就是說當多個執行緒讀寫ArrayList的時候可能出現執行緒安全問題。例如,就add操作而言

ArrayList的add方法:

我們把elementData[size++] = e 分為兩步:

  • elementData[size]=e
  • size++

如果呼叫兩次add操作,期待結果應該是這樣的:

 

但是,如果存在兩個執行緒A、B幾乎同時操作add方法,由於無法保證add操作的原子性,實際操作時序可能如下。

 

那麼,對應的結果就會是這樣:

這裡就發生大問題了,e1被後續新增的e2覆蓋。e1丟失,而size卻仍舊遞增兩位。

 

執行緒安全的ArrayList——SynchronizedList

Collections實用類(注意,不是Collection介面)提供同步容器包裝,將普通的集合包裝成執行緒安全的集合。

例如,通過Collections.synchronizedList(List<T>)方法,可以把一個非執行緒安全的List集合變為執行緒安全的集合。

這裡其實是一個裝飾器模式的應用,引數集合List將被裝飾為SynchronizedList。

 

其是Collections的內部類。

通過對每個方法呼叫都進行同步加鎖,使得多個執行緒讀寫ArrayList只能按序進行。這樣的話,資料的安全性和一致性都得到了保證。

缺點也非常明顯,每個執行緒讀寫ArrayList都需進行同步,開銷大。

 

迭代過程中的異常 —— ConcurrentModificationException 

在ArrayList的實現中,其迭代器實現了一個方法checkForComodification 這個方法會檢查迭代期間是否有其他執行緒修改了集合,如果有,則丟擲ConcurrentModificationException

原理

主要跟兩個欄位有關:

  • expectedModCount(來自ArrayList的迭代器Itr)
  • modCount(來自ArrayList的父類AbstractList,初始為0)。

ArrayList實現中,每執行一次新增操作,都會讓modCount+1

注意:addAll也是讓modCount+1,與新增的元素個數無關。remove和set操作不算,其不會讓modCount有所改變。

ArrayList的迭代器中,expectedModCount的初始值被設定為modCount

迭代器在每次遍歷時,會呼叫checkForComodification 檢查狀態,如果此過程中集合發生了改動,則直接丟擲異常。

 

注:如果迭代期間需要修改集合,只能通過迭代器的方法修改集合,這些方法不會觸發異常。因為其會重置expectedModCount的值為當前modCount。

如何防止迭代過程出現異常?

所以,在使用這樣的ArrayList時,如果需要對其進行迭代,則需要對容器進行加鎖(或者拷貝一份),使當前執行緒對其獨佔訪問,以保證其迭代過程能夠正常執行。如下:

public class SomeClass {
    List<E> list;

    public SomeClass(List<E> list) {
        this.list = list;
    }

    //如果這個方法會被多執行緒訪問,那麼最好對list的訪問進行加鎖
    public void function() {
        synchronized(list) {
            for(E e:list) {
                ....
            }
        }
    }
}

 

 

執行緒安全的另一種實現類——CopyOnWriteArrayList

CopyOnWriteArrayList同樣是執行緒安全的ArrayList,但是與SynchronizedList不同的是,它只對寫操作加鎖,對讀操作不加鎖。關鍵是,其在迭代期間不需要對容器進行加鎖或複製。這一切都與"寫入時複製"有關。

寫入時複製的原理

 

CopyOnWriteArrayList的欄位:

  • lock => 鎖,寫操作時需要
  • array => 容器陣列的引用。指向儲存當前元素的陣列。

與容器陣列引用直接相關的兩個方法:

寫入時複製相關程式碼:

public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    //寫操作還是要上鎖的,此鎖是全域性鎖
    lock.lock();
    try {
        //獲取容器陣列
        Object[] elements = getArray();
        //獲得容器長度
        int len = elements.length;
        //建立一個新的儲存空間,容量+1
        Object[] newElements = Arrays.copyOf(elements, len + 1);
        //在新的儲存空間內,加入新元素
        newElements[len] = e;
        //修改當前容器陣列的引用
        setArray(newElements);
        return true;
    } finally {
        //釋放鎖
        lock.unlock();
    }
}

當add操作完成後,array的引用就已經指向另一個儲存空間了。 這裡也暴露了一個缺點如果此容器的寫操作比較頻繁,那麼其開銷就比較大

迭代器實現

CopyOnWriteArray有自己的迭代器,該迭代器不會檢查修改狀態,也無需檢查狀態。因為迭代的陣列是可以說是隻讀的,不會有其他執行緒能夠修改它。

迭代器,引用的陣列變數名就叫snapshot(快照)。也從另一個角度說明,在迭代器迭代過程中,其使用的是容器的過去一個版本,一個快照。不能保證是當前容器的狀態。

這裡也暴露了一個缺點不能保證資料的瞬時一致性。

但是,其有一個顯著的優點那就是讀操作,和遍歷操作不需要同步。多執行緒訪問的時候,速度較高。

 

CopyOnWriteArrayList應用場景

   由以上的優缺點可得,CopyOnWriteArrayList應用的場景,最好是讀操作多,寫操作相對較少的場景("讀多寫少")。也就是說,集合內容不會經常變動的。例如,網上常說的"黑名單"這類東西。

 

相關文章