【轉】Java7/8 中的 HashMap 和 ConcurrentHashMap 全解析
原文出處:https://javadoop.com/post/hashmap
今天發一篇”水文”,可能很多讀者都會表示不理解,不過我想把它作為併發序列文章中不可缺少的一塊來介紹。本來以為花不了多少時間的,不過最終還是投入了挺多時間來完成這篇文章的。
網上關於 HashMap 和 ConcurrentHashMap 的文章確實不少,不過缺斤少兩的文章比較多,所以才想自己也寫一篇,把細節說清楚說透,尤其像 Java8 中的 ConcurrentHashMap,大部分文章都說不清楚。終歸是希望能降低大家學習的成本,不希望大家到處找各種不是很靠譜的文章,看完一篇又一篇,可是還是模模糊糊。
閱讀建議:四節基本上可以進行獨立閱讀,建議初學者可按照 Java7 HashMap -> Java7 ConcurrentHashMap -> Java8 HashMap -> Java8 ConcurrentHashMap 順序進行閱讀,可適當降低閱讀門檻。
閱讀前提:本文分析的是原始碼,所以至少讀者要熟悉它們的介面使用,同時,對於併發,讀者至少要知道 CAS、ReentrantLock、UNSAFE 操作這幾個基本的知識,文中不會對這些知識進行介紹。Java8 用到了紅黑樹,不過本文不會進行展開,感興趣的讀者請自行查詢相關資料。
Java7 HashMap
HashMap 是最簡單的,一來我們非常熟悉,二來就是它不支援併發操作,所以原始碼也非常簡單。
首先,我們用下面這張圖來介紹 HashMap 的結構。
這個僅僅是示意圖,因為沒有考慮到陣列要擴容的情況,具體的後面再說。
大方向上,HashMap 裡面是一個數組,然後陣列中每個元素是一個單向連結串列。
上圖中,每個綠色的實體是巢狀類 Entry 的例項,Entry 包含四個屬性:key, value, hash 值和用於單向連結串列的 next。
capacity:當前陣列容量,始終保持 2^n,可以擴容,擴容後陣列大小為當前的 2 倍。
loadFactor:負載因子,預設為 0.75。
threshold:擴容的閾值,等於 capacity * loadFactor
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 |
|
陣列初始化
在第一個元素插入 HashMap 的時候做一次陣列的初始化,就是先確定初始的陣列大小,並計算陣列擴容的閾值。
1 2 3 4 5 6 7 8 9 10 |
|
這裡有一個將陣列大小保持為 2 的 n 次方的做法,Java7 和 Java8 的 HashMap 和 ConcurrentHashMap 都有相應的要求,只不過實現的程式碼稍微有些不同,後面再看到的時候就知道了。
計算具體陣列位置
這個簡單,我們自己也能 YY 一個:使用 key 的 hash 值對陣列長度進行取模就可以了。
1 2 3 4 |
|
這個方法很簡單,簡單說就是取 hash 值的低 n 位。如在陣列長度為 32 的時候,其實取的就是 key 的 hash 值的低 5 位,作為它在陣列中的下標位置。
新增節點到連結串列中
找到陣列下標後,會先進行 key 判重,如果沒有重複,就準備將新值放入到連結串列的表頭。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
這個方法的主要邏輯就是先判斷是否需要擴容,需要的話先擴容,然後再將這個新的資料插入到擴容後的陣列的相應位置處的連結串列的表頭。
陣列擴容
前面我們看到,在插入新值的時候,如果當前的 size 已經達到了閾值,並且要插入的陣列位置上已經有元素,那麼就會觸發擴容,擴容後,陣列大小為原來的 2 倍。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
擴容就是用一個新的大陣列替換原來的小陣列,並將原來陣列中的值遷移到新的陣列中。
由於是雙倍擴容,遷移過程中,會將原來 table[i] 中的連結串列的所有節點,分拆到新的陣列的 newTable[i] 和 newTable[i + oldLength] 位置上。如原來陣列長度是 16,那麼擴容後,原來 table[0] 處的連結串列中的所有元素會被分配到新陣列中 newTable[0] 和 newTable[16] 這兩個位置。程式碼比較簡單,這裡就不展開了。
get 過程分析
相對於 put 過程,get 過程是非常簡單的。
- 根據 key 計算 hash 值。
- 找到相應的陣列下標:hash & (length – 1)。
- 遍歷該陣列位置處的連結串列,直到找到相等(==或equals)的 key。
1 2 3 4 5 6 7 8 9 |
|
getEntry(key):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
Java7 ConcurrentHashMap
ConcurrentHashMap 和 HashMap 思路是差不多的,但是因為它支援併發操作,所以要複雜一些。
整個 ConcurrentHashMap 由一個個 Segment 組成,Segment 代表”部分“或”一段“的意思,所以很多地方都會將其描述為分段鎖。注意,行文中,我很多地方用了“槽”來代表一個 segment。
簡單理解就是,ConcurrentHashMap 是一個 Segment 陣列,Segment 通過繼承 ReentrantLock 來進行加鎖,所以每次需要加鎖的操作鎖住的是一個 segment,這樣只要保證每個 Segment 是執行緒安全的,也就實現了全域性的執行緒安全。
concurrencyLevel:並行級別、併發數、Segment 數,怎麼翻譯不重要,理解它。預設是 16,也就是說 ConcurrentHashMap 有 16 個 Segments,所以理論上,這個時候,最多可以同時支援 16 個執行緒併發寫,只要它們的操作分別分佈在不同的 Segment 上。這個值可以在初始化的時候設定為其他值,但是一旦初始化以後,它是不可以擴容的。
再具體到每個 Segment 內部,其實每個 Segment 很像之前介紹的 HashMap,不過它要保證執行緒安全,所以處理起來要麻煩些。
初始化
initialCapacity:初始容量,這個值指的是整個 ConcurrentHashMap 的初始容量,實際操作的時候需要平均分給每個 Segment。
loadFactor:負載因子,之前我們說了,Segment 陣列不可以擴容,所以這個負載因子是給每個 Segment 內部使用的。
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 |
|
初始化完成,我們得到了一個 Segment 陣列。
我們就當是用 new ConcurrentHashMap() 無參建構函式進行初始化的,那麼初始化完成後:
- Segment 陣列長度為 16,不可以擴容
- Segment[i] 的預設大小為 2,負載因子是 0.75,得出初始閾值為 1.5,也就是以後插入第一個元素不會觸發擴容,插入第二個會進行第一次擴容
- 這裡初始化了 segment[0],其他位置還是 null,至於為什麼要初始化 segment[0],後面的程式碼會介紹
- 當前 segmentShift 的值為 32 – 4 = 28,segmentMask 為 16 – 1 = 15,姑且把它們簡單翻譯為移位數和掩碼,這兩個值馬上就會用到
put 過程分析
我們先看 put 的主流程,對於其中的一些關鍵細節操作,後面會進行詳細介紹。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
第一層皮很簡單,根據 hash 值很快就能找到相應的 Segment,之後就是 Segment 內部的 put 操作了。
Segment 內部是由 陣列+連結串列 組成的。
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 |
|
整體流程還是比較簡單的,由於有獨佔鎖的保護,所以 segment 內部的操作並不複雜。至於這裡面的併發問題,我們稍後再進行介紹。
到這裡 put 操作就結束了,接下來,我們說一說其中幾步關鍵的操作。
初始化槽: ensureSegment
ConcurrentHashMap 初始化的時候會初始化第一個槽 segment[0],對於其他槽來說,在插入第一個值的時候進行初始化。
這裡需要考慮併發,因為很可能會有多個執行緒同時進來初始化同一個槽 segment[k],不過只要有一個成功了就可以。
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 |
|
總的來說,ensureSegment(int k) 比較簡單,對於併發操作使用 CAS 進行控制。
我沒搞懂這裡為什麼要搞一個 while 迴圈,CAS 失敗不就代表有其他執行緒成功了嗎,為什麼要再進行判斷?
獲取寫入鎖: scanAndLockForPut
前面我們看到,在往某個 segment 中 put 的時候,首先會呼叫 node = tryLock() ? null : scanAndLockForPut(key, hash, value),也就是說先進行一次 tryLock() 快速獲取該 segment 的獨佔鎖,如果失敗,那麼進入到 scanAndLockForPut 這個方法來獲取鎖。
下面我們來具體分析這個方法中是怎麼控制加鎖的。
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 |
|
這個方法有兩個出口,一個是 tryLock() 成功了,迴圈終止,另一個就是重試次數超過了 MAX_SCAN_RETRIES,進到 lock() 方法,此方法會阻塞等待,直到成功拿到獨佔鎖。
這個方法就是看似複雜,但是其實就是做了一件事,那就是獲取該 segment 的獨佔鎖,如果需要的話順便例項化了一下 node。
擴容: rehash
重複一下,segment 陣列不能擴容,擴容是 segment 陣列某個位置內部的陣列 HashEntry\[] 進行擴容,擴容後,容量為原來的 2 倍。
首先,我們要回顧一下觸發擴容的地方,put 的時候,如果判斷該值的插入會導致該 segment 的元素個數超過閾值,那麼先進行擴容,再插值,讀者這個時候可以回去 put 方法看一眼。
該方法不需要考慮併發,因為到這裡的時候,是持有該 segment 的獨佔鎖的。
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 |
|
這裡的擴容比之前的 HashMap 要複雜一些,程式碼難懂一點。上面有兩個挨著的 for 迴圈,第一個 for 有什麼用呢?
仔細一看發現,如果沒有第一個 for 迴圈,也是可以工作的,但是,這個 for 迴圈下來,如果 lastRun 的後面還有比較多的節點,那麼這次就是值得的。因為我們只需要克隆 lastRun 前面的節點,後面的一串節點跟著 lastRun 走就是了,不需要做任何操作。
我覺得 Doug Lea 的這個想法也是挺有意思的,不過比較壞的情況就是每次 lastRun 都是連結串列的最後一個元素或者很靠後的元素,那麼這次遍歷就有點浪費了。不過 Doug Lea 也說了,根據統計,如果使用預設的閾值,大約只有 1/6 的節點需要克隆。
get 過程分析
相對於 put 來說,get 真的不要太簡單。
- 計算 hash 值,找到 segment 陣列中的具體位置,或我們前面用的“槽”
- 槽中也是一個數組,根據 hash 找到陣列中具體的位置
- 到這裡是連結串列了,順著連結串列進行查詢即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
併發問題分析
現在我們已經說完了 put 過程和 get 過程,我們可以看到 get 過程中是沒有加鎖的,那自然我們就需要去考慮併發問題。
新增節點的操作 put 和刪除節點的操作 remove 都是要加 segment 上的獨佔鎖的,所以它們之間自然不會有問題,我們需要考慮的問題就是 get 的時候在同一個 segment 中發生了 put 或 remove 操作。
- put 操作的執行緒安全性。
- 初始化槽,這個我們之前就說過了,使用了 CAS 來初始化 Segment 中的陣列。
- 新增節點到連結串列的操作是插入到表頭的,所以,如果這個時候 get 操作在連結串列遍歷的過程已經到了中間,是不會影響的。當然,另一個併發問題就是 get 操作在 put 之後,需要保證剛剛插入表頭的節點被讀取,這個依賴於 setEntryAt 方法中使用的 UNSAFE.putOrderedObject。
- 擴容。擴容是新建立了陣列,然後進行遷移資料,最後面將 newTable 設定給屬性 table。所以,如果 get 操作此時也在進行,那麼也沒關係,如果 get 先行,那麼就是在舊的 table 上做查詢操作;而 put 先行,那麼 put 操作的可見性保證就是 table 使用了 volatile 關鍵字。
- remove 操作的執行緒安全性。
remove 操作我們