以前寫過介紹HashMap的文章,文中提到過HashMap在put的時候,插入的元素超過了容量(由負載因子決定)的範圍就會觸發擴容操作,就是rehash,這個會重新將原數組的內容重新hash到新的擴容數組中,在多線程的環境下,存在同時其他的元素也在進行put操作,如果hash值相同,可能出現同時在同一數組下用鏈表表示,造成閉環,導致在get時會出現死循環,所以HashMap是線程不安全的。
我們來了解另一個鍵值存儲集合HashTable,它是線程安全的,它在所有涉及到多線程操作的都加上了synchronized關鍵字來鎖住整個table,這就意味着所有的線程都在競爭一把鎖,在多線程的環境下,它是安全的,但是無疑是效率低下的。
其實HashTable有很多的優化空間,鎖住整個table這麼粗暴的方法可以變相的柔和點,比如在多線程的環境下,對不同的數據集進行操作時其實根本就不需要去競爭一個鎖,因爲他們不同hash值,不會因爲rehash造成線程不安全,所以互不影響,這就是鎖分離技術,將鎖的粒度降低,利用多個鎖來控制多個小的table,這就是這篇文章的主角ConcurrentHashMap JDK1.7版本的核心思想。
ConcurrentHashMap
JDK1.7的實現
在JDK1.7版本中,ConcurrentHashMap的數據結構是由一個Segment數組和多個HashEntry組成,如下圖所示:
Segment數組的意義就是將一個大的table分割成多個小的table來進行加鎖,也就是上面的提到的鎖分離技術,而每一個Segment元素存儲的是HashEntry數組+鏈表,這個和HashMap的數據存儲結構一樣
初始化
ConcurrentHashMap的初始化是會通過位與運算來初始化Segment的大小,用ssize來表示,如下所示
1 2 3 4 5 6 |
|
如上所示,因爲ssize用位於運算來計算(ssize <<=1),所以Segment的大小取值都是以2的N次方,無關concurrencyLevel的取值,當然concurrencyLevel最大隻能用16位的二進制來表示,即65536,換句話說,Segment的大小最多65536個,沒有指定concurrencyLevel元素初始化,Segment的大小ssize默認爲16
每一個Segment元素下的HashEntry的初始化也是按照位於運算來計算,用cap來表示,如下所示
1 2 3 |
|
如上所示,HashEntry大小的計算也是2的N次方(cap <<=1), cap的初始值爲1,所以HashEntry最小的容量爲2
put操作
對於ConcurrentHashMap的數據插入,這裏要進行兩次Hash去定位數據的存儲位置
1 |
|
從上Segment的繼承體系可以看出,Segment實現了ReentrantLock,也就帶有鎖的功能,當執行put操作時,會進行第一次key的hash來定位Segment的位置,如果該Segment還沒有初始化,即通過CAS操作進行賦值,然後進行第二次hash操作,找到相應的HashEntry的位置,這裏會利用繼承過來的鎖的特性,在將數據插入指定的HashEntry位置時(鏈表的尾端),會通過繼承ReentrantLock的tryLock()方法嘗試去獲取鎖,如果獲取成功就直接插入相應的位置,如果已經有線程獲取該Segment的鎖,那當前線程會以自旋的方式去繼續的調用tryLock()方法去獲取鎖,超過指定次數就掛起,等待喚醒。
get操作
ConcurrentHashMap的get操作跟HashMap類似,只是ConcurrentHashMap第一次需要經過一次hash定位到Segment的位置,然後再hash定位到指定的HashEntry,遍歷該HashEntry下的鏈表進行對比,成功就返回,不成功就返回null。
size操作
計算ConcurrentHashMap的元素大小是一個有趣的問題,因爲他是併發操作的,就是在你計算size的時候,他還在併發的插入數據,可能會導致你計算出來的size和你實際的size有相差(在你return size的時候,插入了多個數據),要解決這個問題,JDK1.7版本用兩種方案。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
|
- 第一種方案他會使用不加鎖的模式去嘗試多次計算ConcurrentHashMap的size,最多三次,比較前後兩次計算的結果,結果一致就認爲當前沒有元素加入,計算的結果是準確的;
- 第二種方案是如果第一種方案不符合,他就會給每個Segment加上鎖,然後計算ConcurrentHashMap的size返回。
JDK1.8的實現
JDK1.8的實現已經摒棄了Segment的概念,而是直接用Node數組+鏈表+紅黑樹的數據結構來實現,併發控制使用Synchronized和CAS來操作,整個看起來就像是優化過且線程安全的HashMap,雖然在JDK1.8中還能看到Segment的數據結構,但是已經簡化了屬性,只是爲了兼容舊版本。
在深入JDK1.8的put和get實現之前要知道一些常量設計和數據結構,這些是構成ConcurrentHashMap實現結構的基礎,下面看一下基本屬性:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
|
基本屬性定義了ConcurrentHashMap的一些邊界以及操作時的一些控制,下面看一些內部的一些結構組成,這些是整個ConcurrentHashMap整個數據結構的核心。
Node
Node是ConcurrentHashMap存儲結構的基本單元,繼承於HashMap中的Entry,用於存儲數據,源代碼如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
|
Node數據結構很簡單,從上可知,就是一個鏈表,但是隻允許對數據進行查找,不允許進行修改。
TreeNode
TreeNode繼承與Node,但是數據結構換成了二叉樹結構,它是紅黑樹的數據的存儲結構,用於紅黑樹中存儲數據,當鏈表的節點數大於8時會轉換成紅黑樹的結構,他就是通過TreeNode作爲存儲結構代替Node來轉換成黑紅樹源代碼如下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
|
TreeBin
TreeBin從字面含義中可以理解爲存儲樹形結構的容器,而樹形結構就是指TreeNode,所以TreeBin就是封裝TreeNode的容器,它提供轉換黑紅樹的一些條件和鎖的控制,部分源碼結構如下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
|
介紹了ConcurrentHashMap主要的屬性與內部的數據結構,現在通過一個簡單的例子以debug的視角看看ConcurrentHashMap的具體操作細節。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
我們先通過new ConcurrentHashMap()來進行初始化
1 2 |
|
由上你會發現ConcurrentHashMap的初始化其實是一個空實現,並沒有做任何事,這裏後面會講到,這也是和其他的集合類有區別的地方,初始化操作並不是在構造函數實現的,而是在put操作中實現,當然ConcurrentHashMap還提供了其他的構造函數,有指定容量大小或者指定負載因子,跟HashMap一樣,這裏就不做介紹了。
put操作
在上面的例子中我們新增個人信息會調用put方法,我們來看下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
|
這個put的過程很清晰,對當前的table進行無條件自循環直到put成功,可以分成以下六步流程來概述。
- 如果沒有初始化就先調用initTable()方法來進行初始化過程
- 如果沒有hash衝突就直接CAS插入
- 如果還在進行擴容操作就先進行擴容
- 如果存在hash衝突,就加鎖來保證線程安全,這裏有兩種情況,一種是鏈表形式就直接遍歷到尾端插入,一種是紅黑樹就按照紅黑樹結構插入,
- 最後一個如果該鏈表的數量大於閾值8,就要先轉換成黑紅樹的結構,break再一次進入循環
- 如果添加成功就調用addCount()方法統計size,並且檢查是否需要擴容
現在我們來對每一步的細節進行源碼分析,在第一步中,符合條件會進行初始化操作,我們來看看initTable()方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
|
在第二步中沒有hash衝突就直接調用Unsafe的方法CAS插入該元素,進入第三步如果容器正在擴容,則會調用helpTransfer()方法幫助擴容,現在我們跟進helpTransfer()方法看看
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
|
其實helpTransfer()方法的目的就是調用多個工作線程一起幫助進行擴容,這樣的效率就會更高,而不是隻有檢查到要擴容的那個線程進行擴容操作,其他線程就要等待擴容操作完成才能工作。
既然這裏涉及到擴容的操作,我們也一起來看看擴容方法transfer()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 |
|
擴容過程有點複雜,這裏主要涉及到多線程併發擴容,ForwardingNode的作用就是支持擴容操作,將已處理的節點和空節點置爲ForwardingNode,併發處理時多個線程經過ForwardingNode就表示已經遍歷了,就往後遍歷,下圖是多線程合作擴容的過程:
介紹完擴容過程,我們再次回到put流程,在第四步中是向鏈表或者紅黑樹里加節點,到第五步,會調用treeifyBin()方法進行鏈表轉紅黑樹的過程。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
|
到第六步表示已經數據加入成功了,現在調用addCount()方法計算ConcurrentHashMap的size,在原來的基礎上加一,現在來看看addCount()方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
|
put的流程現在已經分析完了,你可以從中發現,他在併發處理中使用的是樂觀鎖,當有衝突的時候才進行併發處理,而且流程步驟很清晰,但是細節設計的很複雜,畢竟多線程的場景也複雜。
get操作
我們現在要回到開始的例子中,我們對個人信息進行了新增之後,我們要獲取所新增的信息,使用String name = map.get(“name”)獲取新增的name信息,現在我們依舊用debug的方式來分析下ConcurrentHashMap的獲取方法get()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
|
ConcurrentHashMap的get操作的流程很簡單,也很清晰,可以分爲三個步驟來描述
- 計算hash值,定位到該table索引位置,如果是首節點符合就返回
- 如果遇到擴容的時候,會調用標誌正在擴容節點ForwardingNode的find方法,查找該節點,匹配就返回
- 以上都不符合的話,就往下遍歷節點,匹配就返回,否則最後就返回null
size操作
最後我們來看下例子中最後獲取size的方式int size = map.size();,現在讓我們看下size()方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
在JDK1.8版本中,對於size的計算,在擴容和addCount()方法就已經有處理了,JDK1.7是在調用size()方法纔去計算,其實在併發集合中去計算size是沒有多大的意義的,因爲size是實時在變的,只能計算某一刻的大小,但是某一刻太快了,人的感知是一個時間段,所以並不是很精確。
總結與思考
其實可以看出JDK1.8版本的ConcurrentHashMap的數據結構已經接近HashMap,相對而言,ConcurrentHashMap只是增加了同步的操作來控制併發,從JDK1.7版本的ReentrantLock+Segment+HashEntry,到JDK1.8版本中synchronized+CAS+HashEntry+紅黑樹,相對而言,總結如下思考:
- JDK1.8的實現降低鎖的粒度,JDK1.7版本鎖的粒度是基於Segment的,包含多個HashEntry,而JDK1.8鎖的粒度就是HashEntry(首節點)
- JDK1.8版本的數據結構變得更加簡單,使得操作也更加清晰流暢,因爲已經使用synchronized來進行同步,所以不需要分段鎖的概念,也就不需要Segment這種數據結構了,由於粒度的降低,實現的複雜度也增加了
- JDK1.8使用紅黑樹來優化鏈表,基於長度很長的鏈表的遍歷是一個很漫長的過程,而紅黑樹的遍歷效率是很快的,代替一定閾值的鏈表,這樣形成一個最佳拍檔
- JDK1.8爲什麼使用內置鎖synchronized來代替重入鎖ReentrantLock,我覺得有以下幾點:
- 因爲粒度降低了,在相對而言的低粒度加鎖方式,synchronized並不比ReentrantLock差,在粗粒度加鎖中ReentrantLock可能通過Condition來控制各個低粒度的邊界,更加的靈活,而在低粒度中,Condition的優勢就沒有了
- JVM的開發團隊從來都沒有放棄synchronized,而且基於JVM的synchronized優化空間更大,使用內嵌的關鍵字比使用API更加自然
- 在大量的數據操作下,對於JVM的內存壓力,基於API的ReentrantLock會開銷更多的內存,雖然不是瓶頸,但是也是一個選擇依據