助力面試之ConcurrentHashMap面試靈魂拷問,你能扛多久


前言

本文從 ConcurrentHashMap 常見的面試問題引入話題,並逐步揭開其設計原理,相信讀完本文,對面試中的相關問題會有很大的幫助。

HashMap 在我們日常的開發中使用頻率最高的一個工具類之一,然而使用 HashMap 最大的問題之一就是它是線程不安全的,如果我們想要線程安全應該怎么辦呢?這時候就可以選擇使用 ConcurrentHashMapConcurrentHashMapHashMap 的功能是基本一樣的,ConcurrentHashMapHashMap 的線程安全版本。

ConcurrentHashMapHashMap 排除線程的安全性方面,所以有很多相同的設計思想本文不會做太多重復介紹,如果大家不了解 HashMap 底層實現原理,建議在閱讀本文可以先閱讀 金三銀四助力面試-手把手輕松讀懂HashMap源碼
了解 HashMap 的設計思想。

ConcurrentHashMap 原理

ConcurrentHashMapHashMap 的線程安全版本,其內部和 HashMap 一樣,也是采用了數組 + 鏈表 + 紅黑樹的方式來實現。

如何實現線程的安全性?加鎖。但是這個鎖應該怎么加呢?在 HashTable 中,是直接在 putget 方法上加上了 synchronized,理論上來說 ConcurrentHashMap 也可以這么做,但是這么做鎖的粒度太大,會非常影響並發性能,所以在 ConcurrentHashMap 中並沒有采用這么直接簡單粗暴的方法,其內部采用了非常精妙的設計,大大減少了鎖的競爭,提升了並發性能。

ConcurrentHashMap 中的初始化和 HashMap 中一樣,而且容量也會調整為 2 的 N 次冪,在這里不做重復介紹這么做的原因。

JDK1.8 版本 ConcurrentHashMap 做了什么改進

JDK1.7 版本中,ConcurrentHashMap 由數組 + Segment + 分段鎖實現,其內部氛圍一個個段(Segment)數組,Segment`` 通過繼承 ReentrantLock 來進行加鎖,通過每次鎖住一個 segment 來降低鎖的粒度而且保證了每個 segment 內的操作的線程安全性,從而實現全局線程安全。下圖就是 JDK1.7 版本中 ConcurrentHashMap 的結構示意圖:

但是這么做的缺陷就是每次通過 hash 確認位置時需要 2 次才能定位到當前 key 應該落在哪個槽:

  1. 通過 hash 值和 段數組長度-1 進行位運算確認當前 key 屬於哪個段,即確認其在 segments 數組的位置。
  2. 再次通過 hash 值和 table 數組(即 ConcurrentHashMap 底層存儲數據的數組)長度 - 1進行位運算確認其所在桶。

為了進一步優化性能,在 jdk1.8 版本中,對 ConcurrentHashMap 做了優化,取消了分段鎖的設計,取而代之的是通過 cas 操作和 synchronized 關鍵字來實現優化,而擴容的時候也利用了一種分而治之的思想來提升擴容效率,在 JDK1.8ConcurrentHashMap 的存儲結構和 HashMap 基本一致,如下圖所示:

為什么 key 和 value 不允許為 null

HashMap 中,keyvalue 都是可以為 null 的,但是在 ConcurrentHashMap 中卻不允許,這是為什么呢?

作者 Doug Lea 本身對這個問題有過回答,在並發編程中,null 值容易引來歧義, 假如先調用 get(key) 返回的結果是 null,那么我們無法確認是因為當時這個 key 對應的 value 本身放的就是 null,還是說這個 key 值根本不存在,這會引起歧義,如果在非並發編程中,可以進一步通過調用 containsKey 方法來進行判斷,但是並發編程中無法保證兩個方法之間沒有其他線程來修改 key 值,所以就直接禁止了 null 值的存在。

而且作者 Doug Lea 本身也認為,假如允許在集合,如 mapset 等存在 null 值的話,即使在非並發集合中也有一種公開允許程序中存在錯誤的意思,這也是 Doug LeaJosh BlochHashMap作者之一) 在設計問題上少數不同意見之一,而 ConcurrentHashMapDoug Lea 一個人開發的,所以就直接禁止了 null 值的存在。

ConcurrentHashMap 如何保證線程的安全性

ConcurrentHashMap 中,采用了大量的分而治之的思想來降低鎖的粒度,提升並發性能。其源碼中大量使用了 cas 操作來保證安全性,而不是和 HashTable 一樣,不論什么方法,直接簡單粗暴的使用 synchronized關鍵字來實現,接下來的原理分析中,部分和 HashMap 類似之處本文就不在重復,本文主要從安全性方面來分析 ConcurrentHashMap 的設計。

如何用 CAS 保證數組初始化的安全

下面就是初始化的方法:

這里面有一個非常重要的變量 sizeCtl,這個變量對理解整個 ConcurrentHashMap 的原理非常重要。

sizeCtl 有四個含義:

  • sizeCtl<-1 表示有 N-1 個線程正在執行擴容操作,如 -2 就表示有 2-1 個線程正在擴容。
  • sizeCtl=-1 占位符,表示當前正在初始化數組。
  • sizeCtl=0 默認狀態,表示數組還沒有被初始化。
  • sizeCtl>0 記錄下一次需要擴容的大小。

知道了這個變量的含義,上面的方法就好理解了,第二個分支采用了 CAS 操作,因為 SIZECTL 默認為 0,所以這里如果可以替換成功,則當前線程可以執行初始化操作,CAS 失敗,說明其他線程搶先一步把 sizeCtl 改為了 -1。擴容成功之后會把下一次擴容的閾值賦值給 sc,即 sizeClt

put 操作如何保證數組元素的可見性

ConcurrentHashMap 中存儲數據采用的 Node 數組是采用了 volatile 來修飾的,但是這只能保證數組的引用在不同線程之間是可用的,並不能保證數組內部的元素在各個線程之間也是可見的,所以這里我們判定某一個桶是否有元素,並不能直接通過下標來訪問,那么應該如何訪問呢?源碼給你答案:

可以看到,這里是通過 tabAt 方法來獲取元素,而 tableAt 方法實際上就是一個 CAS 操作:

如果發現當前節點元素為空,也是通過 CAS 操作(casTabAt)來存儲當前元素。

如果當前節點元素不為空,則會使用 synchronized 關鍵字鎖住當前節點,並進行對應的設值操作:

精妙的計數方式

HashMap 中,調用 put 方法之后會通過 ++size 的方式來存儲當前集合中元素的個數,但是在並發模式下,這種操作是不安全的,所以不能通過這種方式,那么是否可以通過 CAS 操作來修改 size 呢?

直接通過 CAS 操作來修改 size 是可行的,但是假如同時有非常多的線程要修改 size 操作,那么只會有一個線程能夠替換成功,其他線程只能不斷的嘗試 CAS,這會影響到 ConcurrentHashMap 集合的性能,所以作者就想到了一個分而治之的思想來完成計數。

作者定義了一個數組來計數,而且這個用來計數的數組也能擴容,每次線程需要計數的時候,都通過隨機的方式獲取一個數組下標的位置進行操作,這樣就可以盡可能的降低了鎖的粒度,最后獲取 size 時,則通過遍歷數組來實現計數:

//用來計數的數組,大小為2的N次冪,默認為2
private transient volatile CounterCell[] counterCells;

@sun.misc.Contended static final class CounterCell {//數組中的對象
        volatile long value;//存儲元素個數
        CounterCell(long x) { value = x; }
    }

addCount 計數方法

接下來我們看看 addCount 方法:

首先會判斷 CounterCell 數組是不是為空,需要這里的是,這里的 CAS 操作是將 BASECOUNTbaseCount 進行比較,如果相等,則說明當前沒有其他線程過來修改 baseCount(即 CAS 操作成功),此時則不需要使用 CounterCell 數組,而直接采用 baseCount 來計數。

假如 CounterCell 為空且 CAS 失敗,那么就會通過調用 fullAddCount 方法來對 CounterCell 數組進行初始化。

fullAddCount 方法

這個方法也很長,看起來比較復雜,里面包含了對 CounterCell 數組的初始化和賦值等操作。

初始化 CounterCell 數組

我們先不管,直接進入出初始化的邏輯:

這里面有一個比較重要的變量 cellsBusy,默認是 0,表示當前沒有線程在初始化或者擴容,所以這里判斷如果 cellsBusy==0,而 as 其實在前面就是把全局變量 CounterCell 數組的賦值,這里之所以再判斷一次就是再確認有沒有其他線程修改過全局數組 CounterCell,所以條件滿足的話就會通過 CAS 操作修改 cellsBusy1,表示當前自己在初始化了,其他線程就不能同時進來初始化操作了。

最后可以看到,默認是一個長度為 2 的數組,也就是采用了 2 個數組位置進行存儲當前 ConcurrentHashMap 的元素數量。

CounterCell 如何賦值

初始化完成之后,如果再次調用 put 方法,那么就會進入 fullAddCount 方法的另一個分支:

這里面首先判斷了 CounterCell 數組不為空,然后會再次判斷數組中的元素是不是為空,因為如果元素為空,就需要初始化一個 CounterCell 對象放到數組,而如果元素不為空,則只需要 CAS 操作替換元素中的數量即可。

所以這里面的邏輯也很清晰,初始化 CounterCell 對象的時候也需要將 cellBusy0 改成 1

技數數組 CounterCell 也能擴容嗎

最后我們再繼續看其他分支:

主要看上圖紅框中的分支,一旦會進入這個分支,就說明前面所有分支都不滿足,即:

  • 當前 CounterCell 數組已經初始化完成。
  • 當前通過 hash 計算出來的 CounterCell 數組下標中的元素不為 null
  • 直接通過 CAS 操作修改 CounterCell 數組中指定下標位置中對象的數量失敗,說明有其他線程在競爭修改同一個數組下標中的元素。
  • 當前操作不滿足不允許擴容的條件。
  • 當前沒有其他線程創建了新的 CounterCell 數組,且當前 CounterCell 數組的大小仍然小於 CPU 數量。

所以接下來就需要對 CounterCell 數組也進行擴容,這個擴容的方式和 ConcurrentHashMap 的擴容一樣,也是將原有容量乘以 2,所以其實 CounterCell 數組的容量也是滿足 2 的 N 次冪。

ConcurrentHashMap 的擴容

接下來我們需要回到 addCount 方法,因為這個方法在添加元素數量的同時,也會判斷當前 ConcurrentHashMap 的大小是否達到了擴容的閾值,如果達到,需要擴容。

擴容也能支持並發嗎

這里可能令大家有點意外的是,ConcurrentHashMap 擴容也支持多線程同時進行,這又是如何做到的呢?接下來就讓我們回到 addCount 方法一探究竟。

這里 check 是傳進來的鏈表長度,>=0 才開始檢查是否需要擴容,緊挨之后是一個 while 循環,主要是滿足兩個條件:

  • 前面我們提到,sizeCtl在初始化的時候會被賦值為下一次擴容的大小(擴容之后也會),所以 >=sizeCtl 表示的就是是否達到擴容閾值。
  • table 不為 null 且當前數組長度小於最大值 2 的 30 次方。
擴容戳有什么用

當滿足擴容條件之后,首先會先調用一個方法來獲取擴容戳,這個擴容戳比較有意思,要理解擴容戳,必須從二進制的角度來分析。resizeStamp 方法就一句話,其中 RESIZE_STAMP_BITS 是一個默認值 16

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

這里面關鍵就是 Integer.numberOfLeadingZeros(n) 這個方法,這個方法源碼就不貼出來了,實際上這個方法就是做一件事,那就是獲取當前數據轉成二進制后的最高非 0 位前的 0 的個數

這句話有點拗口,我們舉個例子,就以 16 為准,16 轉成二進制是 10000,最高非 0 位是在第 5 位,因為 int 類型是 32 位,所以他前面還有 27 位,而且都是 0,那么這個方法得到的結果就是 271 的前面還有 270)。

然后 1 << (RESIZE_STAMP_BITS - 1) 在當前版本就是 1<<15,也就是得到一個二進制數 1000000000000000,這里也是要做一件事,把這個 1 移動到第 16。最后這兩個數通過 | 操作一定得到的結果就是第 16 位是 1,因為 int32 位,最多也就是 320,而且因為 n 的默認大小是 16ConcurrentHashMap 默認大小),所以實際上最多也就是 2711011)個 0,執行 | 運算最多也就是影響低 5 位的結果。

27 轉成二進制為 0000000000000000000000000011011,然后和 00000000000000001000000000000000 執行 | 運算,最終得到的而結果就是 00000000000000010000000000011011

注意:這里之所以要保證第 16 位為 1,是為了保證 sizeCtl 變量為負數,因為前面我們提到,這個變量為負數才代表當前有線程在擴容,至於這個變量和 sizeCtl 的關系后面會介紹。

首次擴容為什么計數要 +2 而不是 +1

首次擴容一定不會走前面兩個條件,而是走的最后一個紅框內條件,這個條件通過 CAS 操作將 rs 左移了 16(RESIZE_STAMP_SHIFT)位,然后加上一個 2,這個代表什么意思呢?為什么是加 2 呢?

要回答這個問題我們先回答另一個問題,上面通過方法獲得的擴容戳 rs 究竟有什么用?實際上這個擴容戳代表了兩個含義:

  • 16 為代表當前擴容的標記,可以理解為一個紀元。
  • 16 代表了擴容的線程數。

知道了這兩個條件就好理解了,因為 rs 最終是要賦值給 sizeCtl 的,而 sizeCtl 負數才代表擴容,而將 rs 左移 16 位就剛好使得最高位為 1,此時低 16 位全部是 0,而因為低 16 位要記錄擴容線程數,所以應該 +1,但是這里是 +2,原因是 sizeCtl-1 這個數值已經被使用了,用來代替當前有線程准備擴容,所以如果直接 +1 是會和標志位發生沖突。

所以繼續回到上圖中的第二個紅框,就是正常繼續 +1 了,只有初始化第一次記錄擴容線程數的時候才需要 +2

擴容條件

接下來我們繼續看上圖中第一個紅框,這里面有 5 個條件,代表是滿足這 5 個條件中的任意一個,則不進行擴容:

  1. (sc >>> RESIZE_STAMP_SHIFT) != rs 這個條件實際上有 bug,在 JDK12 中已經換掉。
  2. sc == rs + 1 表示最后一個擴容線程正在執行首位工作,也代表擴容即將結束。
  3. sc == rs + MAX_RESIZERS 表示當前已經達到最大擴容線程數,所以不能繼續讓線程加入擴容。
  4. 擴容完成之后會把 nextTable(擴容的新數組) 設為 null
  5. transferIndex <= 0 表示當前可供擴容的下標已經全部分配完畢,也代表了當前線程擴容結束。

多並發下如何實現擴容

在多並發下如何實現擴容才不會沖突呢?可能大家都想到了采用分而治之的思想,在 ConcurrentHashMap 中采用的是分段擴容法,即每個線程負責一段,默認最小是 16,也就是說如果 ConcurrentHashMap 中只有 16 個槽位,那么就只會有一個線程參與擴容。如果大於 16 則根據當前 CPU 數來進行分配,最大參與擴容線程數不會超過 CPU 數。

擴容空間和 HashMap 一樣,每次擴容都是將原空間大小左移一位,即擴大為之前的兩倍。注意這里的 transferIndex 代表的就是推進下標,默認為舊數組的大小。

擴容時的數據遷移如何保證安全性

初始化好了新的數組,接下來就是要准備確認邊界。也就是要確認當前線程負責的槽位,確認好之后會從大到小開始往前推進,比如線程一負責 1-16,那么對應的數組邊界就是 0-15,然后會從最后一位 15 開始遷移數據:

這里面有三個變量比較關鍵:

  • fwd 節點,這個代表的是占位節點,最關鍵的就是這個節點的 hash 值為 -1,所以一旦發現某一個節點中的 hash 值為 -1 就可以知道當前節點已經被遷移了。
  • advance:代表是否可以繼續推進下一個槽位,只有當前槽位數據被遷移完成之后才可以設置為 true
  • finishing:是否已經完成數據遷移。

知道了這幾個變量,再看看上面的代碼,第一次一定會進入 while 循環,因為默認 advancetrue,第一次進入循環的目的為了確認邊界,因為邊界值還沒有確認,所以會直接走到最后一個分支,通過 CAS 操作確認邊界。

確認邊界這里直接表述很難理解,我們通過一個例子來說明:

假設說最開始的空間為 16,那么擴容后的空間就是 32,此時 transferIndex 為舊數組大小 16,而在第二個 if判斷中,transferIndex 賦值給了 nextIndex,所以 nextIndex1,而 stride 代表的是每個線程負責的槽位數,最小就是 16,所以 stride 也是 16,所以 nextBound= nextIndex > stride ? nextIndex - stride : 0 皆可以得到:nextBound=0i=15 了,也就是當前線程負責 0-15 的數組下標,且從 0 開始推進,確認邊界后立刻將 advance 設置為 false,也就是會跳出 while 循環,從而執行下面的數據遷移部分邏輯。

PS:因為 nextBound=0,所以 CAS 操作實際上也是把 transferIndex 變成了 0,表示當前擴容的數組下標已經全部分配完畢,這也是前面不滿足擴容的第 5 個條件。

數據遷移時,會使用 synchronized 關鍵字對當前節點進行加鎖,也就是說鎖的粒度精確到了每一個節點,可以說大大提升了效率。加鎖之后的數據遷移和 HashMap 基本一致,也是通過區分高低位兩種情況來完成遷移,在本文就不重復講述。

當前節點完成數據遷移之后,advance 變量會被設置為 true,也就是說可以繼續往前推進節點了,所以會重新進入上面的 while 循環的前面兩個分支,把下標 i 往前推進之后再次把 advance 設置為 false,然后重復操作,直到下標推進到 0 完成數據遷移。

while 循環徹底結束之后,會進入到下面這個 if 判斷,紅框中就是當前線程自己完成了遷移之后,會將擴容線程數進行遞減,遞減之后會再次通過一個條件判斷,這個條件其實就是前面進入擴容前條件的反推,如果成立說明擴容已經完成,擴容完成之后會將 nextTable 設置為 null,所以上面不滿足擴容的第 4 個條件就是在這里設置的。

總結

本文主要講述了 ConcurrentHashMap 中是如何保證安全性的,並且挑選了一些比較經典的面試常用問題進行分析解答,在整個 ConcurrentHashMap 中,整個思想就是降低鎖的粒度,減少鎖的競爭,所以采用了大量的分而治之的思想,比如多線程同時進行擴容,以及通過一個數組來實現 size 的計數等。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM