併發編程系列(五):ConcurrentHashMap的底層原理

一、內容要點
1.通過數組的方式實現併發增加元素的個數(不用加鎖,減少性能消耗)
2.併發擴容,可通過多個線程實現數據遷移
3.採用高低位鏈的方式解決多次hash計算的問題,提升了效率
4.sizeCtl的設計,3種表示狀態
5.resizeStamp的設計,高低位的設計實現唯一性以及多個線程的協助擴容

二、底層設計結構
1.7版

ConcurrentHashMap由一個個Segment組成,其內部也即是一個Segment數組,通過繼承ReentrantLock進行加鎖,通過鎖住單個Segment保證Segment內操作的線程安全,進而實現全局的安全性。

1.8版改進
1.將原來的Segment分段設計改爲Node數組來保存數據,並且採用 Node 數組元素作爲鎖來實現每一行數據進行加鎖來進一步減少併發衝突的概率。
2.將數組+鏈表結構改爲數組+鏈表+紅黑樹。鏈表複雜度爲O(n),紅黑樹複雜度爲O(log n),查詢性能上優化了。

當鏈表長度爲8時,會通過擴容或者將鏈表轉換爲紅黑樹(如果長度沒有64位,則優先擴容)。

三、代碼分析

1.putValue()
計算hash值,如果數組爲空則初始化,默認長度爲16。然後再計算下次擴容臨界值(超過該值則擴容),爲當前容量的0.75倍。
通過(n - 1) & hash 方式取得某個位置的值是否爲null,若爲null則CAS方式將新值封裝成Node插入;若CAS失敗則存在競爭進入下次循環。

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

  • -1,代表正在初始化。
  • -N,代表有 N-1 個線程正在進行擴容操作,這裏不是簡單的理解成 n 個線程,sizeCtl 就是-N。
  • 0,標識 Node 數組還沒有被初始化,正數代表初始化或者下一次擴容的大小。
sizeCtl = sc = n - (n >>> 2); // 計算下次擴容的臨界值大小,實際就是當前容量的0.75倍
             = n * 0.75

2..addCount()
在putVal方法執行完成以後,會通過addCount來增加ConcurrentHashMap中的元素個數,並且還會可能觸發擴容操作。這裏有兩個非常經典的設計:
1)如何保證 addCount 的數據安全性以及性能。

2)高併發下的擴容。

3.CounterCell
使用CounterCell數組,每一個數組元素對應一個節點,記錄每個節點存放元素的個數,最後通過遍歷CounterCell數組元素,將每個元素對應數值相加得到size大小,總值等於數組中每個cell分值之和(分而治之思想)。
baseCount,記錄個數的屬性。

fullAddCount ()分析
fullAddCount 主要是用來初始化 CounterCell,來記錄元素個數,裏面包含擴容,初始化等操作。
cellBusy屬性,標識是否初始化,0說明未開始初始化;初始化長度爲 2 的數組,然後隨機得到指定的一個數組下標,將需要新增的值加入到對應下標位置處。


4.transfer  擴容階段
判斷是否需要擴容,也就是當更新後的鍵值對總數 baseCount >= 閾值 sizeCtl 時,進行rehash,這裏面會有兩個邏輯。
1)如果當前正在處於擴容階段,則當前線程會加入並且協助擴容
2)如果當前沒有在擴容,則直接觸發擴容操作

resizeStamp

resizeStamp 用來生成一個和擴容有關的擴容戳。

static final int resizeStamp(int n) {
    return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}

Integer.numberOfLeadingZeros 這個方法是返回無符號整數 n 最高位非 0 位前面的 0 的個數。
比如 10 的二進制是 0000 0000 0000 0000 0000 0000 0000 1010,那麼這個方法返回的值就是 28。

下面推演擴容的代碼邏輯:

1.假設數組長度n=16,16的二進制
0000 0000 0000 0000 1000 0000 0001 1100

2.U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)
rs 左移 16 位,相當於原本的二進制低位變成了高位 
1000 0000 0001 1100 0000 0000 0000 0000

3.然後再 +2 變爲
1000 0000 0001 1100 0000 00000000 0010
表示有一個線程在擴容

高 16 位代表擴容的標記、低 16 位代表並行擴容的線程數
1)保證每次擴容的擴容戳是唯一的
2)支持併發擴容
可以理解爲在16(數組長度)這個擴容週期內,有n個線程參與擴容。

transfer作用
1)擴大數組長度
2)數據遷移

if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
    stride = MIN_TRANSFER_STRIDE; // subdivide range

將 n>>>3 相當於 n/8,然後除以 CPU 核心數。如果得到的結果小於 16,那麼就使用 16。
這裏的目的是讓每個 CPU 處理的桶一樣多,避免出現轉移任務不均勻的現象,如果桶較少的話,默認一個 CPU(一個線程)處理 16 個桶,也就是長度爲 16 的時候,擴容的時候只會有一個線程來擴容。

5.數據遷移

(bound,i)通過邊界和i去跟蹤該區域處理,逆序從後往前遷移,若i位置爲null,則設置fwd標誌,表示該節點遷移完畢。當某個節點在做遷移時會用鎖鎖住保證遷移正常進行,當遷移完畢設置fwd標誌,下一個進來的線程會跳過該節點。


(16, 31)表示bound邊界爲16,逆序操作i從31下標開始往前執行,(0, 15)同理。

遷移過程遇到某個節點鏈表滿8的情況,採用高低位方式遷移到新的節點隊列中。

高低位擴容原理
通過(n-1) & hash 運算對鏈表分類,分成ln(low node)低位鏈和hn(high node)高位鏈。低位鏈保持不變,高位鏈增加一個數組長度作爲遷移後的位置。
n,當前數組長度。
1)通過高低位分類後,不需要在每次擴容的時候重新計算hash,提升了效率
2)數據遷移後還能通過同樣的操作(n-1) & hash 取得值

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

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