前言
Java併發包有很大一部分都是關於併發容器的。Java在5.0版本以前線程安全的容器稱之爲同步容器。同步容器實現線程安全的方式:是將每一個公有方法都使用synchronized
修飾,保證每次只有一個線程能訪問容器的狀態。可是這樣的串行度過高,將嚴重下降併發性,當多個線程競爭容器的鎖時,吞吐量將嚴重下降。所以,在Java 5.0版本時提供了性能更高的容器來改進以前的同步容器,咱們稱其爲併發容器。html
下面咱們先來介紹Java 5.0以前的同步容器,而後再來介紹Java 5.0以後的併發容器。java
Java 5.0以前的同步容器
目前,Java中的容器主要可分爲四大類,分別爲List
、Map
、Set
、Queue
(Queue是Java5.0添加的新的容器類型),可是並非全部的Java容器都是線程安全的。例如,咱們經常使用的ArrayList
和HashMap
就不是線程安全的。線程安全的類爲Vector
、Stack
和HashTable
。編程
如何將非線程安全的類變爲線程安全的類? 非線程安全的容器類能夠由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()); }
在設計同步容器類的迭代器時沒有考慮到併發修改的問題,當出現如上狀況時,它們表現出來的行爲是<mark>「及時失敗」(fail-fast)</mark>的。當它們發現容器在迭代過程當中被修改時,就會當即拋出一個ConcurrentModificationException
異常。
這種fail-fast的迭代器並非一種完備的處理機制,而只是「善意地」捕獲併發錯誤,所以只能做爲併發問題的預警指示器。這種機制的實現方式是:使用一個計數器modCount
記錄容器大小改變的次數,在進行迭代期間,若是該計數器值與剛進入迭代時不一致,那麼hasNext()或next()將拋出ConcurrentModificationException
異常。
可是,對計數器的值的檢查時是沒有在同步狀況下進行的,所以可能會看到失效的計數值,致使迭代器沒有意識到容器已經發生了修改。這是一種設計上的權衡,從而下降併發修改操做的檢測代碼對程序性能帶來的影響。
更多的時候,咱們是不但願在迭代期間對容器加鎖。若是容器規模很大,在加鎖迭代後,那麼在迭代期間其餘線程都不能訪問該容器。這將下降程序的可伸縮性以及引發激烈的鎖競爭下降吞吐量和CPU利用率。 一種替代加鎖迭代的方法爲**「克隆」**容器,並在副本上迭代。副本是線程封閉的,天然也就是安全的。可是克隆的過程也須要對容器加鎖,且也存在必定的開銷,需考慮使用。
隱藏的迭代器
容器的hashCode()
和equals()
等方法也會間接地執行迭代操做,當容器做爲另外一個容器的元素或鍵值時,就會出現這種狀況。一樣,containsAll()
、removeAll()
和retainAll()
等方法,以及把容器做爲參數的構造函數,都會對容器進行迭代。全部這些間接迭代操做均可能拋出ConcurrentModificationException
異常。
Java 5.0的併發容器
在Java 5.0版本時提供了性能更高的容器來改進以前的同步容器,咱們稱之爲併發容器。併發容器雖然多,可是總結下來依舊爲四大類:List
、Map
、Set
、Queue
。
List
CopyOnWriteArrayList
是用於替代同步List的併發容器,在迭代期間不須要對容器進行加鎖或複製。寫時複製(CopyOnWrite)的線程安全性體如今,只要正確地發佈一個事實不可變的對象,那麼在訪問該對象時就再也不須要進一步的同步。而在每次進行寫操做時,便會建立一個副本出來,從而實現可變性。「寫時複製」容器返回的迭代器不會拋出ConcurrentModificationException,由於迭代器在迭代過程當中,若是對象會被修改則會建立一個副本被修改,被迭代的對象依舊是原來的。
CopyOnWriteArrayList僅適用於寫操做很是少的場景,並且可以容忍短暫的不一致。CopyOnWriteArrayList迭代器是隻讀的,不支持增刪改。由於迭代器遍歷的僅僅是一個快照,而對快照進行增刪改是沒有意義的。
Map
ConcurrentHashMap
和ConcurrentSkipListMap
的區別爲: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接口下,兩個併發容器是CopyOnWriteArraySet
和ConcurrentSkipListSet
,可參考CopyOnWriteArrayList和ConcurrentSkipListMap理解。
Queue
Java併發包中Queue下的併發容器是最複雜的,能夠從下面兩個維度來分類:
-
阻塞和非阻塞
阻塞隊列是指當隊列已滿時,入隊操做阻塞;當隊列爲空時,出對操做阻塞。
-
單端和雙端
單端隊列指的是隻能從隊尾入隊,隊首出隊;雙端指的是隊首隊尾皆可出隊。
在Java併發包中,阻塞隊列都有Blocking標識,單端隊列是用Queue標識,而雙端隊列是Deque標識。以上兩個維度可組合,因而分爲四類併發容器:單端阻塞隊列、雙端阻塞隊列、單端非阻塞隊列、雙端非阻塞隊列。
在使用隊列時,須要格外注意隊列是否爲有界隊列(內部的隊列是否容量有限),無界隊列在數據量大時,會致使OOM即內存溢出。 在有Queue的具體實現的併發容器中,只有ArrayBlockingQueue和LinkedBlockingQueue是支持有界的,其他都是無界隊列。
小結
這篇文章從宏觀層面介紹了Java併發包中的併發工具類,對每一個容器類僅作了簡單介紹,後續將附文介紹每個容器類。
參考: [1]極客時間專欄王寶令《Java併發編程實戰》 [2]Brian Goetz.Tim Peierls. et al.Java併發編程實戰[M].北京:機械工業出版社,2016
原文出處:https://www.cnblogs.com/myworld7/p/12350626.html