今天發一篇」水文」,可能不少讀者都會表示不理解,不過我想把它做爲併發序列文章中不可缺乏的一塊來介紹。原本覺得花不了多少時間的,不過最終仍是投入了挺多時間來完成這篇文章的。java
網上關於 HashMap 和 ConcurrentHashMap 的文章確實很多,不過缺斤少兩的文章比較多,因此纔想本身也寫一篇,把細節說清楚說透,尤爲像 Java8 中的 ConcurrentHashMap,大部分文章都說不清楚。終歸是但願能下降你們學習的成本,不但願你們處處找各類不是很靠譜的文章,看完一篇又一篇,但是仍是模模糊糊。node
閱讀建議:四節基本上能夠進行獨立閱讀,建議初學者可按照 Java7 HashMap -> Java7 ConcurrentHashMap -> Java8 HashMap -> Java8 ConcurrentHashMap 順序進行閱讀,可適當下降閱讀門檻。數組
閱讀前提:本文分析的是源碼,因此至少讀者要熟悉它們的接口使用,同時,對於併發,讀者至少要知道 CAS、ReentrantLock、UNSAFE 操做這幾個基本的知識,文中不會對這些知識進行介紹。Java8 用到了紅黑樹,不過本文不會進行展開,感興趣的讀者請自行查找相關資料。安全
HashMap 是最簡單的,一來咱們很是熟悉,二來就是它不支持併發操做,因此源碼也很是簡單。多線程
首先,咱們用下面這張圖來介紹 HashMap 的結構。併發
這個僅僅是示意圖,由於沒有考慮到數組要擴容的狀況,具體的後面再說。ssh
大方向上,HashMap 裏面是一個數組,而後數組中每一個元素是一個單向鏈表。ide
上圖中,每一個綠色的實體是嵌套類 Entry 的實例,Entry 包含四個屬性:key, value, hash 值和用於單向鏈表的 next。函數
capacity:當前數組容量,始終保持 2^n,能夠擴容,擴容後數組大小爲當前的 2 倍。源碼分析
loadFactor:負載因子,默認爲 0.75。
threshold:擴容的閾值,等於 capacity * loadFactor
仍是比較簡單的,跟着代碼走一遍吧。
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] 這兩個位置。代碼比較簡單,這裏就不展開了。
相對於 put 過程,get 過程是很是簡單的。
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 |
|
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() 無參構造函數進行初始化的,那麼初始化完成後:
咱們先看 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 操做就結束了,接下來,咱們說一說其中幾步關鍵的操做。
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 失敗不就表明有其餘線程成功了嗎,爲何要再進行判斷?
前面咱們看到,在往某個 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。
重複一下,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 的節點須要克隆。
相對於 put 來講,get 真的不要太簡單。
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 操做。
remove 操做咱們沒有分析源碼,因此這裏說的讀者感興趣的話仍是須要到源碼中去求實一下的。
get 操做須要遍歷鏈表,可是 remove 操做會」破壞」鏈表。
若是 remove 破壞的節點 get 操做已通過去了,那麼這裏不存在任何問題。
若是 remove 先破壞了一個節點,分兩種狀況考慮。 一、若是此節點是頭結點,那麼須要將頭結點的 next 設置爲數組該位置的元素,table 雖然使用了 volatile 修飾,可是 volatile 並不能提供數組內部操做的可見性保證,因此源碼中使用了 UNSAFE 來操做數組,請看方法 setEntryAt。二、若是要刪除的節點不是頭結點,它會將要刪除節點的後繼節點接到前驅節點中,這裏的併發保證就是 next 屬性是 volatile 的。
Java8 對 HashMap 進行了一些修改,最大的不一樣就是利用了紅黑樹,因此其由 數組+鏈表+紅黑樹 組成。
根據 Java7 HashMap 的介紹,咱們知道,查找的時候,根據 hash 值咱們可以快速定位到數組的具體下標,可是以後的話,須要順着鏈表一個個比較下去才能找到咱們須要的,時間複雜度取決於鏈表的長度,爲 O(n)。
爲了下降這部分的開銷,在 Java8 中,當鏈表中的元素超過了 8 個之後,會將鏈表轉換爲紅黑樹,在這些位置進行查找的時候能夠下降時間複雜度爲 O(logN)。
來一張圖簡單示意一下吧:
注意,上圖是示意圖,主要是描述結構,不會達到這個狀態的,由於這麼多數據的時候早就擴容了。
下面,咱們仍是用代碼來介紹吧,我的感受,Java8 的源碼可讀性要差一些,不過精簡一些。
Java7 中使用 Entry 來表明每一個 HashMap 中的數據節點,Java8 中使用 Node,基本沒有區別,都是 key,value,hash 和 next 這四個屬性,不過,Node 只能用於鏈表的狀況,紅黑樹的狀況須要使用 TreeNode。
咱們根據數組元素中,第一個節點數據類型是 Node 仍是 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 59 60 61 62 63 |
|
和 Java7 稍微有點不同的地方就是,Java7 是先擴容後插入新值的,Java8 先插值再擴容,不過這個不重要。
resize() 方法用於初始化數組或數組擴容,每次擴容後,容量爲原來的 2 倍,並進行數據遷移。
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 |
|
相對於 put 來講,get 真的太簡單了。
1 2 3 4 |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
Java7 中實現的 ConcurrentHashMap 說實話仍是比較複雜的,Java8 對 ConcurrentHashMap 進行了比較大的改動。建議讀者能夠參考 Java8 中 HashMap 相對於 Java7 HashMap 的改動,對於 ConcurrentHashMap,Java8 也引入了紅黑樹。
說實話,Java8 ConcurrentHashMap 源碼真心不簡單,最難的在於擴容,數據遷移操做不容易看懂。
咱們先用一個示意圖來描述下其結構:
結構上和 Java8 的 HashMap 基本上同樣,不過它要保證線程安全性,因此在源碼上確實要複雜一些。
1 2 3 4 5 6 7 8 9 10 11 |
|
這個初始化方法有點意思,經過提供初始容量,計算了 sizeCtl,sizeCtl = 【 (1.5 * initialCapacity + 1),而後向上取最近的 2 的 n 次方】。如 initialCapacity 爲 10,那麼獲得 sizeCtl 爲 16,若是 initialCapacity 爲 11,獲得 sizeCtl 爲 32。
sizeCtl 這個屬性使用的場景不少,不過只要跟着文章的思路來,就不會被它搞暈了。
若是你愛折騰,也能夠看下另外一個有三個參數的構造方法,這裏我就不說了,大部分時候,咱們會使用無參構造函數進行實例化,咱們也按照這個思路來進行源碼分析吧。
仔細地一行一行代碼看下去:
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 |
|
put 的主流程看完了,可是至少留下了幾個問題,第一個是初始化,第二個是擴容,第三個是幫助數據遷移,這些咱們都會在後面進行一一介紹。
這個比較簡單,主要就是初始化一個合適大小的數組,而後會設置 sizeCtl。
初始化方法中的併發問題是經過對 sizeCtl 進行一個 CAS 操做來控制的。
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 |
|
前面咱們在 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 30 31 32 33 |
|
若是說 Java8 ConcurrentHashMap 的源碼不簡單,那麼說的就是擴容操做和遷移操做。
這個方法要完徹底全看懂還須要看以後的 transfer 方法,讀者應該提早知道這點。
這裏的擴容也是作翻倍擴容的,擴容後數組容量爲原來的 2 倍。
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 |
|
這個方法的核心在於 sizeCtl 值的操做,首先將其設置爲一個負數,而後執行 transfer(tab, null),再下一個循環將 sizeCtl 加 1,並執行 transfer(tab, nt),以後多是繼續 sizeCtl 加 1,並執行 transfer(tab, nt)。
因此,可能的操做就是執行 1 次 transfer(tab, null) + 屢次 transfer(tab, nt),這裏怎麼結束循環的須要看完 transfer 源碼才清楚。
下面這個方法很點長,將原來的 tab 數組的元素遷移到新的 nextTab 數組中。
雖然咱們以前說的 tryPresize 方法中屢次調用 transfer 不涉及多線程,可是這個 transfer 方法能夠在其餘地方被調用,典型地,咱們以前在說 put 方法的時候就說過了,請往上看 put 方法,是否是有個地方調用了 helpTransfer 方法,helpTransfer 方法會調用 transfer 方法的。
此方法支持多線程執行,外圍調用此方法的時候,會保證第一個發起數據遷移的線程,nextTab 參數爲 null,以後再調用此方法的時候,nextTab 不會爲 null。
閱讀源碼以前,先要理解併發操做的機制。原數組長度爲 n,因此咱們有 n 個遷移任務,讓每一個線程每次負責一個小任務是最簡單的,每作完一個任務再檢測是否有其餘沒作完的任務,幫助遷移就能夠了,而 Doug Lea 使用了一個 stride,簡單理解就是步長,每一個線程每次負責遷移其中的一部分,如每次遷移 16 個小任務。因此,咱們就須要一個全局的調度者來安排哪一個線程執行哪幾個任務,這個就是屬性 transferIndex 的做用。
第一個發起數據遷移的線程會將 transferIndex 指向原數組最後的位置,而後從後往前的 stride 個任務屬於第一個線程,而後將 transferIndex 指向新的位置,再往前的 stride 個任務屬於第二個線程,依此類推。固然,這裏說的第二個線程不是真的必定指代了第二個線程,也能夠是同一個線程,這個讀者應該能理解吧。其實就是將一個大的遷移任務分爲了一個個任務包。
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 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 |
|
說到底,transfer 這個方法並無實現全部的遷移任務,每次調用這個方法只實現了 transferIndex 往前 stride 個位置的遷移工做,其餘的須要由外圍來控制。
這個時候,再回去仔細看 tryPresize 方法可能就會更加清晰一些了。
get 方法歷來都是最簡單的,這裏也不例外:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
|
簡單說一句,此方法的大部份內容都很簡單,只有正好碰到擴容的狀況,ForwardingNode.find(int h, Object k) 稍微複雜一些,不過在瞭解了數據遷移的過程後,這個也就不難了,因此限於篇幅這裏也不展開說了。
其實也不是很難嘛,雖然沒有像以前的 AQS 和線程池同樣一行一行源碼進行分析,但仍是把全部初學者可能會糊塗的地方都進行了深刻的介紹,只要是稍微有點基礎的讀者,應該是很容易就能看懂 HashMap 和 ConcurrentHashMap 源碼了。
看源碼不算是目的吧,深刻地瞭解 Doug Lea 的設計思路,我以爲還挺有趣的,大師就是大師,代碼寫得真的是好啊。
我發現不少人都覺得我寫博客主要是源碼分析,說真的,我對於源碼分析沒有那麼大熱情,主要都是爲了用源碼說事罷了,可能以後的文章仍是會有比較多的源碼分析成分,你們該怎麼看就怎麼看吧。
不要臉地自覺得本文的質量仍是挺高的,信息量比較大,若是你以爲有寫得很差的地方,或者說看完本文你仍是沒看懂它們,那麼請提出來~~~