轉自乒乓狂魔
本文針對jdk1.8的ConcurrentHashMap
1.8的HashMap設計
1.1 整體概覽
HashMap採用的是數組+鏈表+紅黑樹的形式。
數組是可以擴容的,鏈表也是轉化爲紅黑樹的,這2種方式都可以承載更多的數據。
用戶可以設置的參數:初始總容量默認16,默認的加載因子0.75
初始的數組個數默認是16(用戶不能設置的)
容量X加載因子=閾值
一旦目前容量超過該閾值,則執行擴容操作。
什麼時候擴容?
什麼時候鏈表轉化爲紅黑樹?(上面已經提到了)
目前形象的表示數組中的一個元素稱爲一個桶
1.2 put過程
如果該index位置的Node元素不存在,則直接創建一個新的Node
如果該index位置的Node元素是TreeNode類型即紅黑樹類型了,則直接按照紅黑樹的插入方式進行插入
如果該index位置的Node元素是非TreeNode類型則,則按照鏈表的形式進行插入操作
鏈表插入操作完成後,判斷是否超過閾值TREEIFY_THRESHOLD(默認是8),超過則要麼數組擴容要麼鏈表轉化成紅黑樹
源碼如下:
下面來說說這個擴容的過程
1.3 擴容過程
按照2倍擴容的方式,那麼就需要將之前的所有元素全部重新按照2倍桶的長度重新計算所在桶。這裏爲啥是2倍?
因爲2倍的話,更加容易計算他們所在的桶,並且各自不會相互干擾。如原桶長度是4,現在桶長度是8,那麼
桶0中的元素會被分到桶0和桶4中
桶1中的元素會被分到桶1和桶5中
桶2中的元素會被分到桶2和桶6中
桶3中的元素會被分到桶3和桶7中
爲啥是這樣呢?
桶0中的元素的hash值後2位必然是00,這些hash值可以根據後3位000或者100分成2類數據。他們分別&(8-1)即&111,則後3位爲000的在桶0中,後3位爲100的必然在桶4中。其他同理,也就是說桶4和桶0重新瓜分了原來桶0中的元素。
如果換成其他倍數,那麼瓜分就比較混亂了。
這樣在瓜分這些數據的時候,只需要先把這些數據分類,如上述桶0中分成000和100 2類,然後直接構成新的鏈表,分類完畢後,直接將新的鏈表掛在對應的桶下即可,源碼如下:
上述 (e.hash & oldCap) == 0 即可將原桶中的數據分成2類
上述是對於鏈表情況下的重新移動,而針對紅黑樹情況下:
則需要考慮分類之後是否還需要依然保持紅黑樹,如果個數少則直接使用鏈表即可。
1.4 get過程
get過程比較簡單
如果要找的key就是上述數組index位置的元素,直接返回該元素的值
如果該數組index位置元素是TreeNode類型,則按照紅黑樹的查詢方式來進行查找
如果該數組index位置元素非TreeNode類型,則按照鏈表的方式來進行遍歷查詢
源碼如下:
1.7的ConcurrentHashMap設計
ConcurrentHashMap是線程安全,通過分段鎖的方式提高了併發度。分段是一開始就確定的了,後期不能再進行擴容的。
其中的段Segment繼承了重入鎖ReentrantLock,有了鎖的功能,同時含有類似HashMap中的數組加鏈表結構(這裏沒有使用紅黑樹)
雖然Segment的個數是不能擴容的,但是單個Segment裏面的數組是可以擴容的。
2.1 整體概覽
ConcurrentHashMap有3個參數:
initialCapacity:初始總容量,默認16
loadFactor:加載因子,默認0.75
concurrencyLevel:併發級別,默認16
然後我們需要知道的是:
取大於等於併發級別的最小的2的冪次。如concurrencyLevel=16,那麼sszie=16,如concurrencyLevel=10,那麼ssize=16
c=initialCapacity/ssize,並且可能需要+1。如15/7=2,那麼c要取3,如16/8=2,那麼c取2
c可能是一個任意值,那麼同上述一樣,cap取的值就是大於等於c的最下2的冪次。最小值要求是2
cap*loadFactor
所以默認情況下,segment的個數sszie=16,每個segment的初始容量cap=2,單個segment的閾值threshold=1
2.2 put過程
整體代碼邏輯見如下源碼:
其中上述Segment的put過程源碼如下:
2.3 擴容過程
這個擴容是在Segment的鎖的保護下進行擴容的,不需要關注併發問題。
這裏的重點就是:
首先找到一個lastRun,lastRun之後的元素和lastRun是在同一個桶中,所以後面的不需要進行變動。
然後對開始到lastRun部分的元素,重新計算下設置到newTable中,每次都是將當前元素作爲newTable的首元素,之前老的鏈表作爲該首元素的next部分。
2.4 get過程
源碼如下:
1.8的ConcurrentHashMap設計
1.8的ConcurrentHashMap摒棄了1.7的segment設計,而是在1.8HashMap的基礎上實現了線程安全的版本,即也是採用數組+鏈表+紅黑樹的形式。
數組可以擴容,鏈表可以轉化爲紅黑樹
3.1 整體概覽
有一個重要的參數sizeCtl,代表數組的大小(但是還有其他取值及其含義,後面再詳細說到)
用戶可以設置一個初始容量initialCapacity給ConcurrentHashMap
sizeCtl=大於(1.5倍initialCapacity+1)的最小的2的冪次。
即initialCapacity=20,則sizeCtl=32,如initialCapacity=24,則sizeCtl=64。
初始化的時候,會按照sizeCtl的大小創建出對應大小的數組
3.2 put過程
源碼如下所示:
如果桶中第一個元素的hash值大於0,說明是鏈表結構,則對鏈表插入或者更新
如果桶中的第一個元素類型是TreeBin,說明是紅黑樹結構,則按照紅黑樹的方式進行插入或者更新
下面就來重點看看這個擴容過程
3.3 擴容過程
一旦鏈表中的元素個數超過了8個,那麼可以執行數組擴容或者鏈表轉爲紅黑樹,這裏依據的策略跟HashMap依據的策略是一致的。
當數組長度還未達到64個時,優先數組的擴容,否則選擇鏈表轉爲紅黑樹。
源碼如下所示:
重點來看看這個擴容過程,即看下上述tryPresize方法,也可以看到上述是2倍擴容的方式
第一個執行的線程會首先設置sizeCtl屬性爲一個負值,然後執行transfer(tab, null),其他晚進來的線程會檢查當前擴容是否已經完成,沒完成則幫助進行擴容,完成了則直接退出。
該ConcurrentHashMap的擴容操作可以允許多個線程併發執行,那麼就要處理好任務的分配工作。每個線程獲取一部分桶的遷移任務,如果當前線程的任務完成,查看是否還有未遷移的桶,若有則繼續領取任務執行,若沒有則退出。在退出時需要檢查是否還有其他線程在參與遷移工作,如果有則自己什麼也不做直接退出,如果沒有了則執行最終的收尾工作。
問題1:當前線程如何感知其他線程也在參與遷移工作?
靠sizeCtl的值,它初始值是一個負值=(rs << RESIZE_STAMP_SHIFT) + 2),每當一個線程參與進來執行遷移工作,則該值進行CAS自增,該線程的任務執行完畢要退出時對該值進行CAS自減操作,所以當sizeCtl的值等於上述初值則說明了此時未有其他線程還在執行遷移工作,可以去執行收尾工作了。見如下代碼
問題2:任務按照何規則進行分片?
上述stride即是每個分片的大小,目前有最低要求16,即每個分片至少需要16個桶。stride的計算依賴於CPU的核數,如果只有1個核,那麼此時就不用分片,即stride=n。其他情況就是 (n >>> 3) / NCPU。
問題3:如何記錄目前已經分出去的任務?
ConcurrentHashMap含有一個屬性transferIndex(初值爲最後一個桶),表示從transferIndex開始到後面所有的桶的遷移任務已經被分配出去了。所以每次線程領取擴容任務,則需要對該屬性進行CAS的減操作,即一般是transferIndex-stride。
問題4:每個線程如何處理分到的部分桶的遷移工作
第一個獲取到分片的線程會創建一個新的數組,容量是之前的2倍。
遍歷自己所分到的桶:
桶中元素不存在,則通過CAS操作設置桶中第一個元素爲ForwardingNode,其Hash值爲MOVED(-1),同時該元素含有新的數組引用
此時若其他線程進行put操作,發現第一個元素的hash值爲-1則代表正在進行擴容操作(並且表明該桶已經完成擴容操作了,可以直接在新的數組中重新進行hash和插入操作),該線程就可以去參與進去,或者沒有任務則不用參與,此時可以去直接操作新的數組了
桶中元素存在且hash值爲-1,則說明該桶已經被處理了(本不會出現多個線程任務重疊的情況,這裏主要是該線程在執行完所有的任務後會再次進行檢查,再次覈對)
桶中爲鏈表或者紅黑樹結構,則需要獲取桶鎖,防止其他線程對該桶進行put操作,然後處理方式同HashMap的處理方式一樣,對桶中元素分爲2類,分別代表當前桶中和要遷移到新桶中的元素。設置完畢後代表桶遷移工作已經完成,舊數組中該桶可以設置成ForwardingNode了
下面來看下詳細的代碼:
3.4 get過程
如果第一個元素的hash值小於0,則該節點可能爲ForwardingNode或者紅黑樹節點TreeBin
如果是ForwardingNode(表示當前正在進行擴容),使用新的數組來進行查找
如果是紅黑樹節點TreeBin,使用紅黑樹的查找方式來進行查找
如果第一個元素的hash大於等於0,則爲鏈表結構,依次遍歷即可找到對應的元素
詳細代碼如下
至此,ConcurrentHashMap主要的操作都粗略的介紹完畢了,其他一些操作靠各位自行去看了。
下面針對一些問題來進行解答
問題分析
4.1 ConcurrentHashMap讀爲什麼不需要鎖?
我們通常使用讀寫鎖來保護對一堆數據的讀寫操作。讀時加讀鎖,寫時加寫鎖。在什麼樣的情況下可以不需要讀鎖呢?
如果對數據的讀寫是一個原子操作,那麼此時是可以不需要讀鎖的。如ConcurrentHashMap對數據的讀寫,寫操作是不需要分2次寫的(沒有中間狀態),讀操作也是不需要2次讀取的。假如一個寫操作需要分多次寫,必然會有中間狀態,如果讀不加鎖,那麼可能就會讀到中間狀態,那就不對了。
假如ConcurrentHashMap提供put(key1,value1,key2,value2),寫入的時候必然會存在中間狀態即key1寫完成,但是key2還未寫,此時如果讀不加鎖,那麼就可能讀到key1是新數據而key2是老數據的中間狀態。
雖然ConcurrentHashMap的讀不需要鎖,但是需要保證能讀到最新數據,所以必須加volatile。即數組的引用需要加volatile,同時一個Node節點中的val和next屬性也必須要加volatile。
4.2 ConcurrentHashMap是否可以在無鎖的情況下進行遷移?
目前1.8的ConcurrentHashMap遷移是在鎖定舊桶的前提下進行遷移的,然而並沒有去鎖定新桶。那麼就可能提出如下問題:
一旦某個桶在遷移過程中了,必然要獲取該桶的鎖,所以其他線程的put操作要被阻塞,一旦遷移完畢,該桶中第一個元素就會被設置成ForwardingNode節點,所以其他線程put時需要重新判斷下桶中第一個元素是否被更改了,如果被改了重新獲取重新執行邏輯,如下代碼
該線程會首先檢查是否還有未分配的遷移任務,如果有則先去執行遷移任務,如果沒有即全部任務已經分發出去了,那麼此時該線程可以直接對新的桶進行插入操作(映射到的新桶必然已經完成了遷移,所以可以放心執行操作)
從上面看到我們在遷移的時候還是需要對舊桶鎖定的,能否在無鎖的情況下實現遷移?
可以參考參考這篇論文Split-Ordered Lists: Lock-Free Extensible Hash Tables
一旦擴容就涉及到遷移桶中元素的操作,將一個桶中的元素遷移到另一個桶中的操作不是一個原子操作,所以需要在鎖的保護下進行遷移。如果擴容操作是移動桶的指向,那麼就可以通過一個CAS操作來完成擴容操作。上述Split-Ordered Lists就是把所有元素按照一定的順序進行排列。該list被分成一段一段的,每一段都代表某個桶中的所有元素。每個桶中都有一個指向第一個元素的指針,如下圖結構所示:
每一段其實也是分成2類的,如同前面所說的HashMap在擴容是分成2類的情況是一樣的,此時Split-Ordered Lists在擴容時就只需要將新桶的指針指向這2類的分界點即可。
這一塊之後再詳細說明吧。
4.3 ConcurrentHashMap曾經的弱一致性
具體詳見這篇針對老版本的ConcurrentHashMap的說明文章爲什麼ConcurrentHashMap是弱一致的
文中已經解釋到:對數組的引用是volatile來修飾的,但是數組中的元素並不是。即讀取數組的引用總是能讀取到最新的值,但是讀取數組中某一個元素的時候並不一定能讀到最新的值。所以說是弱一致性的。
我覺得這個只需要稍微改動下就可以實現強一致性:
但是現在1.7版本的ConcurrentHashMap對於數組中元素的寫也是加了volatile的,如下代碼
1.8的方式就是:直接將新加入的元素寫入next屬性(含有volatile修飾)中而不是修改桶中的第一個元素。
所以在1.7和1.8版本的ConcurrentHashMap中不再是弱一致性,寫入的數據是可以立馬本讀到的。