「JDK併發包基礎」併發容器詳解

Java.util.concurrent 包是專為 Java併發編程而設計的包,它下有很多編寫好的工具,使用這些更高等的同步工具來編寫代碼,讓我們的程序可以不費力氣就得到優化。

「JDK併發包基礎」併發容器詳解

這篇文章講述容器部分:

  1. ConcurrentHashMap容器
  2. CopyOnWrite容器
  3. 併發Queue

1.ConcurrentHashMap容器

  • 原理

先來說說HashTable(線程安全的HashMap),他們內部採用橫向數組,縱向鏈表的結構來儲存數據。橫向數組的下標為key的hash值,縱向鏈表為hash值相同的元素組成的鏈表:

「JDK併發包基礎」併發容器詳解

Hashtable容器在競爭激烈的併發情況下,所有訪問HashTable的線程都必須使用同一把鎖,導致效率低下。假如容器有多把鎖,每一把鎖用於容器的一部分數據,多線程訪問不同數據段時,線程間就不存在鎖競爭。這就是ConcurrentHashMap的鎖分段技術,它既是線程安全的又支持高併發的HashMap,每一個段就像是一個小的HashTable,它們都有自己的鎖。

按默認16個Segment(段)來講,理論上就允許16個線程併發執行,只要多個修改操作發生在不同的段上,就可以併發進行:

「JDK併發包基礎」併發容器詳解

一個ConcurrentHashMap包含了一個Segments數組,每一個Segment和HashTable類似,是一種數組的鏈表,即每一個Segment維護一個HashEntry數組,每一個HashEntry是一個鏈表結構。

ConcurrentHashMap的使用方式與HashMap一樣:

「JDK併發包基礎」併發容器詳解

  • get操作

ConcurrentHashMap的get操作不需要加鎖,是經過一次再散列,然後使用這個散列值通過散列運算定位到Segment,再通過散列算法定位到元素。

  • put操作

和get操作一樣,先通過key進行兩次hash確定應該去哪個Segment中取數據,鎖定這個Segment。第一步先判斷是否需要對Segment裡的HashEntry數組進行擴容。第二步如果需要擴容,則定位到添加元素的位置,放在HashEntry數組裡。在擴容的時候,首先會創建一個容量是原來兩倍的數組,然後將原數組裡的元素進行再散列插入到新的數組裡。這樣的設計令哈希表即便是在擴容期間,也能保證無鎖的讀。為了高效,ConcurrentHashMap只會對需要擴容的Segment擴容。

  • size操作

ConcurrentHashMap統計size時會比HashMap麻煩的多,因為使用了分段技術,為了高效,ConcurrentHashMap先會嘗試兩次不鎖住全部Segment的方式統計大小。如果統計過程中容器的count大小發生了變化,再採用加鎖的方式統計所有的Segment大小。

另外,JDK1.8的 ConcurrentHashMap已經拋棄segment,直接通過cas+synchronized(Node)實現,比之前版本更高效,鎖的粒度更小,有興趣讀者可以研究一下。

2.CopyOnWrite容器

從CopyOnWrite字面意思理解是在寫時複製。當我們向容器裡添加元素時,不直接往當前容器裡添加,而是先將當前容器複製出一個新的容器,然後往新的容器裡添加元素。添加完元素後,再將原容器的引用指向新的容器,這樣做的好處是可以對容器進行併發的讀,而不需要加鎖。

CopyOnWrite容器是一種讀寫分離的思想。jdk裡的CopyOnWrite容器有兩種:CopyOnWriteArrayList和CopyOnWriteArraySet,它們適用於讀多寫少的場景中。我先看看CopyOnWriteArrayList源碼:

「JDK併發包基礎」併發容器詳解

可以發現在添加的時候是需要加鎖的,否則多線程寫的時候會Copy出N個副本出來,導致最終的數組數據不是我們期望的。CopyOnWriteArraySet內部構造函數中又調用了CopyOnWriteArrayList,它僅僅是不允許重複的Object數組。

「JDK併發包基礎」併發容器詳解

CopyOnWriteArrayList和CopyOnWriteArraySet與ArrayList和HashSet用法一樣,就不在這裡做介紹了。

3.併發Queue

生產者-消費者是一個經典的多線程設計模式,它通常由兩類線程組成:生產者線程負責生產數據,消費者線程負責具體拿到數據處理數據。如果生產者直接給消費者提供數據,則耦合度過高。聰明的程序員想了一個辦法,把數據存在第三方容器那裡,大家都去那裡存取數據。這個過程官方的說法就是生產者和消費者之間通過共享內存緩衝區進行通信。併發包下的Queue接口,有很多基於內存緩衝區的隊列,層次結構如下:

「JDK併發包基礎」併發容器詳解

3.1 高性能隊列ConcurrentLinkedQueue

ConcurrentLinkedQueue是高併發場景下,應用很廣泛的一種隊列。它是一個基於鏈接節點的無界安全隊列,遵循元素先進先出的原則,且元素不允許為null。它有兩個重要的方法:

  • 加入元素的方法:add()和offer()(在ConcurrentLinkedQueue中這倆方法沒有區別),他倆都是Queue接口add和offer方法的實現.因為其他隊列有不同的實現.其他隊列請看下文。
  • 取出元素的方法:poll()取出並刪除頭元素和peek()取出元素。
「JDK併發包基礎」併發容器詳解

3.2 阻塞隊列BlockingQueue接口

使用上文的非阻塞隊列時有一個很大問題:它不會對當前線程產生阻塞,在面對類似消費者-生產者的模型時,就必須額外地實現同步策略以及線程間喚醒策略,這個實現起來就非常麻煩。BlockingQueue很好的解決了多線程中,如何高效安全“傳輸”數據的問題。可以將BlockingQueue隊列應用於生產者--消費者隊列。

「JDK併發包基礎」併發容器詳解

當試圖在空隊列裡讀數據時,讀的線程就會做一個等待,等待另外一個線程往隊列裡寫數據時,等待線程就會喚醒,並且拿到數據。反之當隊列滿時,試圖寫數據得線程就會等待,直到有線程從另一端拿數據時,寫數據的線程將會喚醒。

  • ArrayBlockingQueue

它是基於數組的阻塞隊列實現,屬於有界隊列。可以指定先進先出或先進後出,內部用一個定長的數組緩存隊列中的數據對象。因為沒有實現讀寫分離,所以生產者和消費者不能完全並行。

「JDK併發包基礎」併發容器詳解

  • LinkedBlockingQueue

它是基於鏈表的阻塞隊列實現,屬於無界隊列。內部用一個鏈表緩存隊列中的數據對象,它實現了讀寫分離,所以可以高效的處理併發數據,生產者和消費者也可以完全並行。其內部是使用ReentrantLock和Condition實現生產者和消費者模式。(ReentrantLock和Condition的解讀可以訪問博主的另外一篇文章:【Java併發基礎】concurrent包工具類詳解):

「JDK併發包基礎」併發容器詳解

【生產者-消費者應用】3秒後生產者停止生產數據,則消費者將一直等待隊列裡有數據:

「JDK併發包基礎」併發容器詳解

測試方法:

「JDK併發包基礎」併發容器詳解

代碼執行結果如下:

「JDK併發包基礎」併發容器詳解

  • SynchronousQueue

一種沒有緩衝的隊列,生產者生產的數據會直接被消費者獲取並消費。它裡面不能放數據,向它裡添加元素會報錯:

「JDK併發包基礎」併發容器詳解

  • DelayQueue

帶有延遲隊列,其中元素只有當指定時間到了,才能夠從隊列中獲取元素。DelayQueue能做什麼呢?比如餓了麼在用戶提交訂單後60秒給用戶發送短信通知,比如對緩存超時的對象移除,任務超時處理,空閒鏈接的關閉等等,它的用處很廣泛。ref:Java 之DelayQueue實際運用示例。

到這裡博主介紹完了常用的Java併發容器,博主是個普通的程序猿,水平有限,文章難免有錯誤,歡迎犧牲自己寶貴時間的讀者,就本文內容直抒己見,博主的目的僅僅是希望對讀者有所幫助。

系列:

【JDK併發包基礎】線程池詳解

【JDK併發包基礎】併發容器詳解

【JDK併發包基礎】工具類詳解


分享到:


相關文章: