ConcurrentHashMap筆記

ConcurrentHashMap 的初步使用及場景

CHM 的使用

ConcurrentHashMap 是 J.U.C 包裏面提供的一個線程安全並且高效的 HashMap,所以 ConcurrentHashMap 在併發編程的場景中使用的頻率比較高,那麼這一節課我們就從 ConcurrentHashMap 的使用上以及源碼層面來分析 ConcurrentHashMap 到底是如何實現安全性的。

api 使用

ConcurrentHashMap 是 Map 的派生類,所以 api 基本和 Hashmap 是類似,主要就是 put、 get 這些方法,接下來基於 ConcurrentHashMap 的 put 和 get 這兩個方法作爲切入點來分 析 ConcurrentHashMap 的源碼實現。

ConcurrentHashMap 的源碼分析

分析的 ConcurrentHashMap 是基於 jdk1.8 的版本。

JDK1.7 和 Jdk1.8 版本的變化

ConcurrentHashMap 和 HashMap 的實現原理是差不多的,但是因爲 ConcurrentHashMap 需要支持併發操作,所以在實現上要比 hashmap 稍微複雜一些。 在 JDK1.7 的 實 現 上 , ConrruentHashMap 由一個個 Segment 組 成 , 簡 單 來 說 , ConcurrentHashMap 是一個 Segment 數組,它通過繼承 ReentrantLock 來進行加鎖,通過 每次鎖住一個 segment 來保證每個 segment 內的操作的線程安全性從而實現全局線程安全。 整個結構圖如下

當每個操作分佈在不同的 segment 上的時候,默認情況下,理論上可以同時支持 16 個線程 的併發寫入。

相比於 1.7 版本,它做了兩個改進

1. 取消了 segment 分段設計,直接使用 Node 數組來保存數據,並且採用 Node 數組元素作 爲鎖來實現每一行數據進行加鎖來進一步減少併發衝突的概率。

2. 將原本數組+單向鏈表的數據結構變更爲了數組+單向鏈表+紅黑樹的結構。爲什麼要引入 紅黑樹呢?在正常情況下,key hash 之後如果能夠很均勻的分散在數組中,那麼 table 數 組中的每個隊列的長度主要爲 0 或者 1.但是實際情況下,還是會存在一些隊列長度過長的 情況。如果還採用單向列表方式,那麼查詢某個節點的時間複雜度就變爲 O(n); 因此對於 隊列長度超過 8 的列表,JDK1.8 採用了紅黑樹的結構,那麼查詢的時間複雜度就會降低到 O(logN),可以提升查找的性能;

這個結構和 JDK1.8 版本中的 Hashmap 的實現結構基本一致,但是爲了保證線程安全性, ConcurrentHashMap 的實現會稍微複雜一下。接下來我們從源碼層面來了解一下它的原理. 我們基於 put 和 get 方法來分析它的實現即可。

  • put 方法第一階段
public V put(K key, V value) {
 return putVal(key, value, false);
}

假如在上面這段代碼中存在兩個線程,在不加鎖的情況下:線程 A 成功執行 casTabAt 操作 後,隨後的線程 B 可以通過 tabAt 方法立刻看到 table[i]的改變。原因如下:線程 A 的 casTabAt 操作,具有 volatile 讀寫相同的內存語義,根據 volatile 的 happens-before 規 則:線程 A 的 casTabAt 操作,一定對線程 B 的 tabAt 操作可見。

  • initTable

數組初始化方法,這個方法比較簡單,就是初始化一個合適大小的數組 。

sizeCtl 這個要單獨說一下,如果沒搞懂這個屬性的意義,可能會被搞暈 。

這個標誌是在 Node 數組初始化或者擴容的時候的一個控制位標識,負數代表正在進行初始 化或者擴容操作。

-1 代表正在初始化

-N 代表有 N-1 有二個線程正在進行擴容操作,這裏不是簡單的理解成 n 個線程,sizeCtl 就 是-N,這塊後續在講擴容的時候會說明

0 標識 Node 數組還沒有被初始化,正數代表初始化或者下一次擴容的大小

  • tabAt

該方法獲取對象中offset偏移地址對應的對象field的值。實際上這段代碼的含義等價於tab[i], 但是爲什麼不直接使用 tab[i]來計算呢?

getObjectVolatile,一旦看到 volatile 關鍵字,就表示可見性。因爲對 volatile 寫操作 happenbefore 於 volatile 讀操作,因此其他線程對 table 的修改均對 get 讀取可見;

雖然 table 數組本身是增加了 volatile 屬性,但是“volatile 的數組只針對數組的引用具有 volatile 的語義,而不是它的元素”。 所以如果有其他線程對這個數組的元素進行寫操作,那 麼當前線程來讀的時候不一定能讀到最新的值。

出於性能考慮,Doug Lea 直接通過 Unsafe 類來對 table 進行操作。

圖解分析

  • put 方法第二階段

在putVal方法執行完成以後,會通過addCount來增加ConcurrentHashMap中的元素個數, 並且還會可能觸發擴容操作。這裏會有兩個非常經典的設計

1. 高併發下的擴容

2. 如何保證 addCount 的數據安全性以及性能

  • addCount

在 putVal 最後調用 addCount 的時候,傳遞了兩個參數,分別是 1 和 binCount(鏈表長度), 看看 addCount 方法裏面做了什麼操作

x 表示這次需要在表中增加的元素個數,check 參數表示是否需要進行擴容檢查,大於等於 0 都需要進行檢查

  • CounterCells 解釋

ConcurrentHashMap 是採用 CounterCell 數組來記錄元素個數的,像一般的集合記錄集合大 小,直接定義一個 size 的成員變量即可,當出現改變的時候只要更新這個變量就行。爲什麼 ConcurrentHashMap 要用這種形式來處理呢?

問題還是處在併發上,ConcurrentHashMap 是併發集合,如果用一個成員變量來統計元素個 數的話,爲了保證併發情況下共享變量的的難全興,勢必會需要通過加鎖或者自旋來實現, 如果競爭比較激烈的情況下,size 的設置上會出現比較大的衝突反而影響了性能,所以在 ConcurrentHashMap 採用了分片的方法來記錄大小,具體什麼意思,我們來分析下

  • fullAddCount 源碼分析

fullAddCount 主要是用來初始化 CounterCell,來記錄元素個數,裏面包含擴容,初始化等 操作

  • CounterCells 初始化圖解

初始化長度爲 2 的數組,然後隨機得到指定的一個數組下標,將需要新增的值加入到對應下 標位置處

  • transfer 擴容階段

判斷是否需要擴容,也就是當更新後的鍵值對總數 baseCount >= 閾值 sizeCtl 時,進行 rehash,這裏面會有兩個邏輯。

1. 如果當前正在處於擴容階段,則當前線程會加入並且協助擴容

2. 如果當前沒有在擴容,則直接觸發擴容操作

  • resizeStamp

這塊邏輯要理解起來,也有一點複雜。

resizeStamp 用來生成一個和擴容有關的擴容戳,具體有什麼作用呢?我們基於它的實現來 做一個分析

 

Integer.numberOfLeadingZeros 這個方法是返回無符號整數 n 最高位非 0 位前面的 0 的個 數

比如 10 的二進制是 0000 0000 0000 0000 0000 0000 0000 1010

那麼這個方法返回的值就是 28

根據 resizeStamp 的運算邏輯,我們來推演一下,假如 n=16,那麼 resizeStamp(16)=32796

轉化爲二進制是

[0000 0000 0000 0000 1000 0000 0001 1100]

接着再來看,當第一個線程嘗試進行擴容的時候,會執行下面這段代碼

U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)

rs 左移 16 位,相當於原本的二進制低位變成了高位 1000 0000 0001 1100 0000 0000 0000 0000

然後再+2 =1000 0000 0001 1100 0000 0000 0000 0000+10=1000 0000 0001 1100 0000 0000 0000 0010

高 16 位代表擴容的標記、低 16 位代表並行擴容的線程數

這樣來存儲有什麼好處呢?

1. 首先在 CHM 中是支持併發擴容的,也就是說如果當前的數組需要進行擴容操作,可以 由多個線程來共同負責,這塊後續會單獨講

2. 可以保證每次擴容都生成唯一的生成戳,每次新的擴容,都有一個不同的 n,這個生成 戳就是根據 n 來計算出來的一個數字,n 不同,這個數字也不同

第一個線程嘗試擴容的時候,爲什麼是+2

因爲 1 表示初始化,2 表示一個線程在執行擴容,而且對 sizeCtl 的操作都是基於位運算的, 所以不會關心它本身的數值是多少,只關心它在二進制上的數值,而 sc + 1 會在 低 16 位上加 1

  • transfer

擴容是 ConcurrentHashMap 的精華之一,擴容操作的核心在於數據的轉移,在單線程環境 下數據的轉移很簡單,無非就是把舊數組中的數據遷移到新的數組。但是這在多線程環境下, 在擴容的時候其他線程也可能正在添加元素,這時又觸發了擴容怎麼辦?可能大家想到的第 一個解決方案是加互斥鎖,把轉移過程鎖住,雖然是可行的解決方案,但是會帶來較大的性 能開銷。因爲互斥鎖會導致所有訪問臨界區的線程陷入到阻塞狀態,持有鎖的線程耗時越長, 其他競爭線程就會一直被阻塞,導致吞吐量較低。而且還可能導致死鎖。

而 ConcurrentHashMap 並沒有直接加鎖,而是採用 CAS 實現無鎖的併發同步策略,最精華 的部分是它可以利用多線程來進行協同擴容

簡單來說,它把 Node 數組當作多個線程之間共享的任務隊列,然後通過維護一個指針來劃 分每個線程鎖負責的區間,每個線程通過區間逆向遍歷來實現擴容,一個已經遷移完的 bucket 會被替換爲一個 ForwardingNode 節點,標記當前 bucket 已經被其他線程遷移完了。

接下來分析一下它的源碼實現

1、fwd:這個類是個標識類,用於指向新表用的,其他線程遇到這個類會主動跳過這個類,因 爲這個類要麼就是擴容遷移正在進行,要麼就是已經完成擴容遷移,也就是這個類要保證線 程安全,再進行操作。

2、advance:這個變量是用於提示代碼是否進行推進處理,也就是當前桶處理完,處理下一個 桶的標識

3、finishing:這個變量用於提示擴容是否結束用的

 

  • 擴容過程圖解

ConcurrentHashMap 支持併發擴容,實現方式是,把 Node 數組進行拆分,讓每個線程處理 自己的區域,假設 table 數組總長度是 64,默認情況下,那麼每個線程可以分到 16 個 bucket。然後每個線程處理的範圍,按照倒序來做遷移。通過 for 自循環處理每個槽位中的鏈表元素,默認 advace 爲真,通過 CAS 設置 transferIndex 屬性值,並初始化 i 和 bound 值,i 指當前處理的槽位序號,bound 指需要處理的槽位邊界, 先處理槽位 31 的節點; (bound,i) =(16,31) 從 31 的位置往前推動。

假設這個時候 ThreadA 在進行 transfer,那麼邏輯圖表示如下

在當前假設條件下,槽位 15 中沒有節點,則通過 CAS 插入在第二步中初始化的 ForwardingNode 節點,用於告訴其它線程該槽位已經處理過了;

  • sizeCtl 擴容退出機制

在擴容操作 transfer 的第 2414 行,代碼如下

if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
 if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)

每存在一個線程執行完擴容操作,就通過 cas 執行 sc-1。

接着判斷(sc-2) !=resizeStamp(n) << RESIZE_STAMP_SHIFT ; 如果相等,表示當前爲整個擴 容操作的 最後一個線程,那麼意味着整個擴容操作就結束了;如果不想等,說明還得繼續 這麼做的目的,一方面是防止不同擴容之間出現相同的 sizeCtl,另外一方面,還可以避免 sizeCtl 的 ABA 問題導致的擴容重疊的情況

  • 數據遷移階段的實現分析

通過分配好遷移的區間之後,開始對數據進行遷移。在看這段代碼之前,先來了解一下原理

  • 高低位原理分析

ConcurrentHashMap 在做鏈表遷移時,會用高低位來實現,這裏有兩個問題要分析一下

1. 如何實現高低位鏈表的區分

假如我們有這樣一個隊列

第 14 個槽位插入新節點之後,鏈表元素個數已經達到了 8,且數組長度爲 16,優先通過擴容 來緩解鏈表過長的問題,擴容這塊的圖解稍後再分析,先分析高低位擴容的原理。

假如當前線程正在處理槽位爲 14 的節點,它是一個鏈表結構,在代碼中,首先定義兩個變量 節點 ln 和 hn,實際就是 lowNode 和 HighNode,分別保存 hash 值的第 x 位爲 0 和不等於 0 的節點。

通過 fn&n 可以把這個鏈表中的元素分爲兩類,A 類是 hash 值的第 X 位爲 0,B 類是 hash 值 的第 x 位爲不等於 0(至於爲什麼要這麼區分,稍後分析),並且通過 lastRun 記錄最後要處 理的節點。最終要達到的目的是,A 類的鏈表保持位置不動,B 類的鏈表爲 14+16(擴容增加 的長度)=30。

我們把 14 槽位的鏈表單獨伶出來,我們用藍色表示 fn&n=0 的節點,假如鏈表的分類是這 樣。

for (Node<K,V> p = f.next; p != null; p = p.next) {
 int b = p.hash & n;
 if (b != runBit) {
 runBit = b;
 lastRun = p;
 }
}

通過上面這段代碼遍歷,會記錄 runBit 以及 lastRun,按照上面這個結構,那麼 runBit 應該 是藍色節點,lastRun 應該是第 6 個節點。

接着,再通過這段代碼進行遍歷,生成 ln 鏈以及 hn 鏈。

接着,通過 CAS 操作,把 hn 鏈放在 i+n 也就是 14+16 的位置,ln 鏈保持原來的位置不動。 並且設置當前節點爲 fwd,表示已經被當前線程遷移完了

setTabAt(nextTab, i, ln);

setTabAt(nextTab, i + n, hn);

setTabAt(tab, i, fwd);

遷移完成以後的數據分佈如下

  • 爲什麼要做高低位的劃分

要想了解這麼設計的目的,我們需要從 ConcurrentHashMap 的根據下標獲取對象的算法來看,在 putVal 方法中 1018 行

(f = tabAt(tab, i = (n - 1) & hash)) == null

通過(n-1) & hash 來獲得在 table 中的數組下標來獲取節點數據,【&運算是二進制運算符,1 & 1=1,其他都爲 0】

假設我們的 table 長度是 16, 二進制是【0001 0000】,減一以後的二進制是 【0000 1111】

假如某個 key 的 hash 值=9,對應的二進制是【0000 1001】,那麼按照(n-1) & hash 的算法

0000 1111 & 0000 1001 =0000 1001 , 運算結果是 9

當我們擴容以後,16 變成了 32,那麼(n-1)的二進制是 【0001 1111】

仍然以 hash 值=9 的二進制計算爲例

0001 1111 & 0000 1001 =0000 1001 ,運算結果仍然是 9

我們換一個數字,假如某個 key 的 hash 值是 20,對應的二進制是【0001 0100】,仍然按照(n-1) & hash 算法,分別在 16 爲長度和 32 位長度下的計算結果

16 位: 0000 1111 & 0001 0100=0000 0100

32 位: 0001 1111 & 0001 0100 =0001 0100

從結果來看,同樣一個 hash 值,在擴容前和擴容之後,得到的下標位置是不一樣的,這種情況當然是 不允許出現的,所以在擴容的時候就需要考慮, 而使用高低位的遷移方式,就是解決這個問題.

大家可以看到,16 位的結果到 32 位的結果,正好增加了 16.

比如 20 & 15=4 、20 & 31=20 ; 4-20 =16

比如 60 & 15=12 、60 & 31=28; 12-28=16

所以對於高位,直接增加擴容的長度,當下次 hash 獲取數組位置的時候,可以直接定位到對應的位置。 這個地方又是一個很巧妙的設計,直接通過高低位分類以後,就使得不需要在每次擴容的時候來重新計 算 hash,極大提升了效率。

  • 擴容結束以後的退出機制

如果線程擴容結束,那麼需要退出,就會執行 transfer 方法的如下代碼

  • put 方法第三階段

如果對應的節點存在,判斷這個節點的 hash 是不是等於 MOVED(-1),說明當前節點是 ForwardingNode 節點, 意味着有其他線程正在進行擴容,那麼當前現在直接幫助它進行擴容,因此調用 helpTransfer 方法

else if ((fh = f.hash) == MOVED)
     tab = helpTransfer(tab, f); 

helpTransfer

從名字上來看,代表當前是去協助擴容

  • put 方法第四階段

這個方法的主要作用是,如果被添加的節點的位置已經存在節點的時候,需要以鏈表的方式加入到節點中,如果當前節點已經是一顆紅黑樹,那麼就會按照紅黑樹的規則將當前節點加入到紅黑樹中。

  • put 方法第五個階段

判斷鏈表的長度是否已經達到臨界值 8. 如果達到了臨界值,這個時候會根據當前數組的長度 來決定是擴容還是將鏈表轉化爲紅黑樹。也就是說如果當前數組的長度小於 64,就會先擴容。 否則,會把當前鏈表轉化爲紅黑樹

if (binCount != 0) {//說明上面在做鏈表操作
     //如果鏈表長度已經達到臨界值 8 就需要把鏈表轉換爲樹結構
    if (binCount >= TREEIFY_THRESHOLD)
     treeifyBin(tab, i);
     if (oldVal != null)//如果 val 是被替換的,則返回替換之前的值
     return oldVal;
 break;
 }
  • treeifyBin

在 putVal 的最後部分,有一個判斷,如果鏈表長度大於 8,那麼就會觸發擴容或者紅黑樹的 轉化操作。

  • tryPresize

tryPresize 裏面部分代碼和 addCount 的部分代碼類似,看起來會稍微簡單一些

資料來源 https://www.gupaoedu.com/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章