1. 程式人生 > >【Java併發工具類】Java併發容器

【Java併發工具類】Java併發容器

前言

Java併發包有很大一部分都是關於併發容器的。Java在5.0版本之前執行緒安全的容器稱之為同步容器。同步容器實現執行緒安全的方式:是將每個公有方法都使用synchronized修飾,保證每次只有一個執行緒能訪問容器的狀態。但是這樣的序列度太高,將嚴重降低併發性,當多個執行緒競爭容器的鎖時,吞吐量將嚴重降低。因此,在Java 5.0版本時提供了效能更高的容器來改進之前的同步容器,我們稱其為併發容器。

下面我們先來介紹Java 5.0之前的同步容器,然後再來介紹Java 5.0之後的併發容器。

Java 5.0之前的同步容器

目前,Java中的容器主要可分為四大類,分別為ListMapSetQueue

(Queue是Java5.0新增的新的容器型別),但是並不是所有的Java容器都是執行緒安全的。例如,我們常用的ArrayListHashMap就不是執行緒安全的。執行緒安全的類為VectorStackHashTable

如何將非執行緒安全的類變為執行緒安全的類?
非執行緒安全的容器類可以由Collections類提供的Collections.synchronizedXxx()工廠方法將其包裝為執行緒安全的類。

// 分別將ArrayList、HashMap和HashSet包裝成執行緒安全的List 、Map和Set
List list = Collections.synchronizedList(new ArrayList());
Set set = Collections.synchronizedSet(new HashSet());
Map map = Collections.synchronizedMap(new HashMap());

這些類實現執行緒安全的方式是:將它們的狀態封裝起來,並對每個公有方法進行同步,使得每次只有一個執行緒能訪問容器的狀態。以ArrayList為例,可以使用如下的程式碼來理解如何將非執行緒安全的容器包裝為執行緒安全的容器。

// 包裝 ArrayList 
SafeArrayList<T>{
    List<T> c = new ArrayList<>();
    // 控制訪問路徑,使用synchronized修飾保證執行緒互斥訪問
    synchronized T get(int idx){
        return c.get(idx);
    }
    synchronized void add(int idx, T t) {
        c.add(idx, t);
    }
    synchronized boolean addIfNotExist(T t){
        if(!c.contains(t)) {
            c.add(t);
            return true;
        }
        return false;
    }
}

被包裝出來的執行緒安全的類,都是基於synchronized同步關鍵字實現,於是被成為同步容器。而原本的執行緒安全的容器類Vector等,同樣也是基於synchronized關鍵字實現的。

同步容器在複合操作中的問題

同步容器類都是執行緒安全的,但是複合操作往往都會包含競態條件問題。這時就需要額外的客戶端加鎖來保證複合操作的原子性。

在下例\(^{[2]}\)中,定義了兩個方法getLast()deleteLast(),它們都會執行“先檢查後執行再執行”操作。每個方法首先都獲得陣列的大小,然後通過結果來獲取或刪除最後一個元素。

public class UnsafeVectorHelpers {
    public static Object getLast(Vector list) {
        int lastIndex = list.size() - 1;
        return list.get(lastIndex);
    }

    public static void deleteLast(Vector list) {
        int lastIndex = list.size() - 1;
        list.remove(lastIndex);
    }
}

如果執行緒同時呼叫相同的方法,這將不會產生什麼問題。但是從呼叫者方向看,這將導致非常嚴重的後果。如果執行緒A在包含10個元素的Vector上呼叫getLast,同時執行緒B在同一個Vector上呼叫deleteLast,這些操作的交替若如下所示,那麼getLast將丟擲ArrayIndexOutOfBoundsException異常。

執行緒A在呼叫size()與getLast()這兩個操作之間,Vector變小了,因此在呼叫size時得到的索引值將不再有效。

於是我們便需要在客戶端加鎖實現新操作的原子性。那麼就需要考慮對哪個鎖物件進行加鎖。
同步容器類通過加鎖自身(this)來保護它的每個方法,於是在這裡我們鎖住list物件便可以保證getLast()和deleteLast()成為原子性操作。

public class SafeVectorHelpers {
    public static Object getLast(Vector list) {
        synchronized (list) {
            int lastIndex = list.size() - 1;
            return list.get(lastIndex);
        }
    }

    public static void deleteLast(Vector list) {
        synchronized (list) {
            int lastIndex = list.size() - 1;
            list.remove(lastIndex);
        }
    }
}

在對Vector中元素進行迭代\(^{[2]}\)時,呼叫size()和相應的get()之間Vector的大小可能發生變化的情況也會出現。

for(int i=0; i<vector.size(); i++){
    doSomething(vector.get(i));
}

與getLast()一樣,如果在對Vector進行迭代時,另一個執行緒刪除了一個元素,並且刪除和訪問這兩個操作交替執行,那麼上面的方法將丟擲ArrayIndexOutOfBoundsException異常。
同樣,我們可以通過在客戶端加鎖來防止其他執行緒在迭代期間修改Vector。

synchronized(vector){
    for(int i=0; i<vector.size(); i++){
        doSomething(vector.get(i));
    }
}

有得必有失,以上程式碼將會導致其他執行緒在迭代期間無法訪問vector,因此也降低了併發性。

迭代器與ConcurrentModificationException

無論是使用for迴圈迭代,還是使用Java 5.0引入的for-each迴圈語法,對容器類進行迭代的標準方式都是使用Iterator。同樣,如果在使用迭代器訪問容器期間,有執行緒併發地修改容器的大小,也是需要對迭代操作進行加鎖,即如下\({^{[1]}}\)。

List list = Collections.synchronizedList(new ArrayList());
synchronized (list) {  
    Iterator i = list.iterator();
    while (i.hasNext())
        foo(i.next());
}   

在設計同步容器類的迭代器時沒有考慮到併發修改的問題,當出現如上情況時,它們表現出來的行為是“及時失敗”(fail-fast)的。當它們發現容器在迭代過程中被修改時,就會立即丟擲一個ConcurrentModificationException異常。

這種fail-fast的迭代器並不是一種完備的處理機制,而只是“善意地”捕獲併發錯誤,因此只能作為併發問題的預警指示器。這種機制的實現方式是:使用一個計數器modCount記錄容器大小改變的次數,在進行迭代期間,如果該計數器值與剛進入迭代時不一致,那麼hasNext()或next()將丟擲ConcurrentModificationException異常。

但是,對計數器的值的檢查時是沒有在同步情況下進行的,因此可能會看到失效的計數值,導致迭代器沒有意識到容器已經發生了修改。這是一種設計上的權衡,從而降低併發修改操作的檢測程式碼對程式效能帶來的影響。

更多的時候,我們是不希望在迭代期間對容器加鎖。如果容器規模很大,在加鎖迭代後,那麼在迭代期間其他執行緒都不能訪問該容器。這將降低程式的可伸縮性以及引起激烈的鎖競爭降低吞吐量和CPU利用率。
一種替代加鎖迭代的方法為“克隆”容器,並在副本上迭代。副本是執行緒封閉的,自然也就是安全的。但是克隆的過程也需要對容器加鎖,且也存在一定的開銷,需考慮使用。

隱藏的迭代器

容器的hashCode()equals()等方法也會間接地執行迭代操作,當容器作為另一個容器的元素或鍵值時,就會出現這種情況。同樣,containsAll()removeAll()retainAll()等方法,以及把容器作為引數的建構函式,都會對容器進行迭代。所有這些間接迭代操作都可能丟擲ConcurrentModificationException異常。

Java 5.0的併發容器

在Java 5.0版本時提供了效能更高的容器來改進之前的同步容器,我們稱之為併發容器。併發容器雖然多,但是總結下來依舊為四大類:ListMapSetQueue

List

CopyOnWriteArrayList是用於替代同步List的併發容器,在迭代期間不需要對容器進行加鎖或複製。寫時複製(CopyOnWrite)的執行緒安全性體現在,只要正確地釋出一個事實不可變的物件,那麼在訪問該物件時就不再需要進一步的同步。而在每次進行寫操作時,便會建立一個副本出來,從而實現可變性。“寫時複製”容器返回的迭代器不會丟擲ConcurrentModificationException,因為迭代器在迭代過程中,如果物件會被修改則會建立一個副本被修改,被迭代的物件依舊是原來的。

CopyOnWriteArrayList僅適用於寫操作非常少的場景,而且能夠容忍短暫的不一致。CopyOnWriteArrayList迭代器是隻讀的,不支援增刪改。因為迭代器遍歷的僅僅是一個快照,而對快照進行增刪改是沒有意義的。

Map

ConcurrentHashMapConcurrentSkipListMap的區別為:ConcurrentHashMap的key是無序的,而ConcurrentSkipListMap的key是有序的。使用這兩者時,它們的key和value都不能為空,否則會丟擲NullPointerException異常。

Map有關實現類對於key和value的要求:

集合類 Key Value 是否執行緒安全
HashMap 允許為null 允許為null
TreeMap 不允許為null 允許為null
HashTable 不允許為null 不允許為null
ConcurrentHashMap 不允許為null 不允許為null
ConcurrentSkipListMap 不允許為null 不允許為null

與HashMap一樣,ConcurrentHashMap也是一個基於雜湊的Map,它使用分段鎖實現了更大程度的共享。任意數量的讀取執行緒可以併發地訪問Map,執行讀取的執行緒和執行寫入的執行緒可以併發地訪問Map,並且一定數量的寫入執行緒可以併發地修改Map。ConcurrentHashMap在併發環境下可以實現更高的吞吐量,而在單執行緒環境中只損失非常小的效能。

ConcurrentHashMap返回的迭代器也不會丟擲ConcurrentModificationException,因此不需要在迭代期間對容器加鎖。ConcurrentHashMap返回的迭代器具有弱一致性(Weakly Consistent),而並非fail-fast的。弱一致性的迭代器可以容忍併發的修改,當建立迭代器會遍歷已有的元素,並可以(但是不保證)在迭代器被構建後將修改操作反映給容器。

ConcurrentHahsMap是對Map進行分段加鎖,沒有實現獨佔。所有需要獨佔訪問功能的,應該使用其他併發容器。

ConcurrentSkipListMap裡面的SkipList本身就是一種資料結構,中文翻譯為“跳錶”。跳錶插入、刪除、查詢操作平均時間複雜度為O(log n)。返回的迭代器也是弱一致性的,也不會丟擲ConcurrentModificationException。

Set

Set介面下,兩個併發容器是CopyOnWriteArraySetConcurrentSkipListSet,可參考CopyOnWriteArrayList和ConcurrentSkipListMap理解。

Queue

Java併發包中Queue下的併發容器是最複雜的,可以從下面兩個維度來分類:

  1. 阻塞和非阻塞

    阻塞佇列是指當佇列已滿時,入隊操作阻塞;當佇列為空時,出對操作阻塞。

  2. 單端和雙端

    單端佇列指的是隻能從隊尾入隊,隊首出隊;雙端指的是隊首隊尾皆可出隊。

在Java併發包中,阻塞佇列都有Blocking標識,單端佇列是用Queue標識,而雙端佇列是Deque標識。以上兩個維度可組合,於是分為四類併發容器:單端阻塞佇列、雙端阻塞佇列、單端非阻塞佇列、雙端非阻塞佇列。

在使用佇列時,需要格外注意佇列是否為有界佇列(內部的佇列是否容量有限),無界佇列在資料量大時,會導致OOM即記憶體溢位。
在有Queue的具體實現的併發容器中,只有ArrayBlockingQueue和LinkedBlockingQueue是支援有界的,其餘都是無界佇列。

小結

這篇文章從巨集觀層面介紹了Java併發包中的併發工具類,對每個容器類僅做了簡單介紹,後續將附文介紹每一個容器類。

參考:
[1]極客時間專欄王寶令《Java併發程式設計實戰》
[2]Brian Goetz.Tim Peierls. et al.Java併發程式設計實戰[M].北京:機械工業出版社,2