ConcurrentHashMap 併發之美

1、前言

她如暴風雨中的一葉扁舟,在高併發的大風大浪下疾馳而過,眼看就要被湮滅,卻又在絕境中絕處逢生html

編寫一套即穩定高效、且支持併發的代碼,不說難如登天,卻也絕非易事。java

一直有小夥伴向我諮詢關於ConcurrentHashMap(後文簡寫爲CHM)的問題,經常抱怨說:其餘源碼懂就是懂了,不懂就是不懂,惟獨CHM總給人一種似懂非懂的感受,感受抓住了精髓,卻又若即若離。其實,之因此有這種感受,並不難理解,由於本質上CHM是一套支持高併發的代碼,同一個方法、同一個返回值,在不一樣的線程或不一樣併發場景都須要完美運行,之因此感受似懂非懂,多是由於只抓住了某一類場景。區別於其餘源碼,咱們讀CHM時,也必定讓本身學會分身。node

本文在介紹CHM原理時,會更多的以分身的角度去看她,我會盡可能拋棄逐行讀源碼的方式,並抱着爲CHM找bug的心態去讀她(不存在完美的代碼,CHM也不例外)git

2、概述

本文介紹的CHM版本基於JDK1.8,源碼洋洋灑灑共有6000+行代碼,本文着重介紹put(初始化、累加器、擴容)、get方法github

建議沒有讀過源碼的同窗先看一遍源碼,而後帶着問題來讀,這樣更容易讀懂並吃透她數組

3、總體介紹

3.一、模型介紹

咱們首先把1.8版本的CHM數據結構介紹下,讓你們對她有個宏觀認識
緩存

  • 說明:此示意圖僅爲展現CHM數據結構,並不是真實場景,例如數據個數若是超過數組長度的3/4,會自動進行擴容;還有某節點下hash衝突嚴重,致使鏈表樹化的時,數組長度至少要擴容至64

名詞約定

分桶: 如上圖所示,CHM的Node數組長度爲16,咱們把每個數組元素及其相關節點稱爲一個分桶,可見一個分桶的數據結構能夠是鏈表形式的,也能夠是紅黑樹或者null數據結構

結構簡述

在沒有指定參數的狀況下,CHM 會默認建立一個長度爲 16 的 Node 數組,隨着數據 put 進來,CHM 經過 key 計算其 hash(正數) 值,而後對數據長度取模,確認其將要插入的分桶後經過尾插法將新數據插入鏈表尾部,當鏈表長度超過8,CHM 會將其轉換爲紅黑樹,爲以後的查詢、插入等提速,紅黑樹的數據結構爲 TreeBin,hash值固定爲-2;當因發生節點刪除致使紅黑樹總長度低於6時,便從新轉換爲鏈表。一旦數量超過 Node 數組長度的 3/4,CHM 便會發生擴容。多線程

class Node<K,V> implements Map.Entry<K,V> {
   final int hash;	// hash值,正常節點的hash值都爲正數
   final K key;	// map的key值
   volatile V val;	// map的value值
   volatile Node<K,V> next;	// 當前節點的下一個,如沒有則爲null
}

以上是 CHM 的操做梗概,不少細節都沒展開來講,你們先有個宏觀概念便可,另紅黑樹的操做本文不會展開來講,因本文主要側重點爲併發,而操做紅黑樹時通常都掛有synchronized鎖,那多線程併發的場景便不會涉及,讀者若是有興趣可自行google、百度;或者參考本人的github工程git@github.com:xijiu/share.git,裏面有關於紅黑樹、B樹、B+樹等詳細用例,值得一提的是用例會直接在控制檯打印樹信息,方便調試、學習併發

3.二、宏觀認識

put方法的流程以下圖所示,其中涉及幾個關鍵步驟:table初始化擴容數據寫入總數累加。其實總體來看的話,流程很簡單,沒有初始化時,執行初始化,須要擴容時,幫助擴容,而後將數據寫入,最後記錄map總數。接下來咱們逐個分析

注:本文中,橙色線表示執行時不加任何鎖;藍色表示CAS操做;綠色表示synchronized

3.三、初始化

變量說明

table 成員變量,volatile修飾,定義爲 Node<K,V>[] table,初始默認值爲null;Node的數據結構簡單明晰,爲map存儲數據的主要數據結構,讀者可自行參看jdk源碼,此處再也不贅述

sizeCtl int 類型的成員變量,volatile修飾,保證內存可見性,主要用來標記map擴容的閾值;例如map新建立時,table的長度爲16,那麼siteCtl=leng*3/4=12,即達到該閾值後,map就須要進行擴容;siteCtl 的初始默認值爲 0。不過在table初始化或者擴容時,sizeCtl 會複用

  • -1 table初始化時,會將其經過CAS操做置爲-1,用來標記初始化加鎖成功
  • ≈ -2147024894 很大的一個負數,逼近int最小值,擴容時用到,主要用來標記參與擴容線程數量以及控制最大擴容併發線程。具體計算公式爲((Integer.numberOfLeadingZeros(n) | (1 << 15)) << 16) + 2,其低4位及高4位都有設計理念,在講到擴容部分時會詳細介紹

質疑

Ⅰ、問:最後直接將 sizeCtl 修改成12時,是否存在漏洞?設想場景:當線程 A 執行到此處,並完成了對 table 的初始化操做,但還未對 sizeCtl 進行賦值。新的請求進來後,發現table不爲null,那麼便執行賦值操做(初始化線程還未執行完畢),在後續的擴容判斷時,sizeCtl 的值一直爲-1,致使CHM異常

答:其實這個問題質量很高,的確存在描述的狀況,不過即使真的出現,也不會致使CHM異常,在擴容階段有個關鍵判斷(sc >>> RESIZE_STAMP_SHIFT) != rs會將擴容操做攔截,在講到擴容部分時,會詳細說明。因此在初始化線程 A 已經完成對table的初始化,但還未執行 sizeCtl 初始化就被hang住後,其餘線程是能夠正常插入數據,但卻不會觸發擴容,直到線程 A 執行完畢 (注:上述分析的案例發生的機率極低,但即使是再小的概率也會有可能觸發,此處可見 Doug 老爺子編碼之嚴謹)

3.四、數據插入

變量說明

Node 及 hashCode 其實節點類型與hashCode一一對應

  • 一、null,即table新建後,尚未內容加入分桶
  • 二、List Node,hashCode >= 0;即桶內的鏈表長度沒有超過8
  • 三、Tree Node,hashCode == -2;紅黑樹
  • 四、FWD Node,hashCode == -1;標記轉移節點
  • 五、ReservationNode,hashCode == -3;在computeIfAbsent()等方法使用到,本文再也不展開

質疑

Ⅰ、問:[點1] 若是當前分桶 f 若是爲空,那麼會新建 Node 節點並將其插入,若是2個線程同時進入,不會致使數據丟失嗎?

答:不會。由於CAS操做確保了賦值成功時,f 節點必須爲null,若是2個線程同時進入當前操做,必定會有一個失敗,進而重試。此處有一個小點,即 CAS 失敗後,程序從新輪訓,new Node的操做豈不是白白浪費了空間?的確是這樣,不過也不太好避免;除非是爲其添加劇量級synchronized鎖,在鎖內開闢空間,不過這樣又會影響性能,相似場景的操做後文還會涉及

Ⅱ、問:[點1] 若是在執行當前操做時,map發生了擴容,而成員變量 table 已經指向了新數組;而此處會將新建的 node 節點賦值給老的 table,豈不是致使了當前數據的丟失?

答:不會。一樣仍是CAS的功勞,擴容時若是發現 f 節點爲null,會經過CAS操做將其修改成 ForwardingNode 節點,無論是當前操做仍是擴容,失敗的話都會觸發重試

Ⅲ、問:[點2] 若是在進行賦值操做時,map觸發了擴容,成員變量table已經指向了新的數組,那此處添加的新節點豈不是要丟失?

答:不會。由於在擴容時,也須要對分桶加鎖,也就是在分桶粒度看的話,添加新節點與擴容是互斥的關係,正在進行添加操做的過程當中,當前分桶的擴容是沒法進行的

Ⅳ、問:[點2] 不管是List Node仍是Tree Node,雖然有synchronized加持,但在進行最終賦值操做時,都沒有CAS控制,會不會致使最終數據的不一致?

答:不會。其實要回答這個問題,首先要分析Node涉及寫操做的變動場景。以下:a、正常向分桶添加、修改數據;b、擴容;c、table初始化;d、節點刪除。而table初始化必定發生在當前操做以前,不然當前線程會先執行初始化操做,其餘a、b、d在操做伊始都會對桶添加同步鎖synchronized,保證了修改操做的同步執行

3.五、累加器

總體思想

相信不少同窗直觀感覺是:不就作個多線程計數器累加麼,至於搞這麼複雜?直接使用AtomicInteger不香嗎?其實此處做者爲了提速仍是用心了良苦。累加器的核心思想與LongAdder是一致的,其本質仍是想盡力避免衝突,從而提升吞吐。與擴容不一樣,在併發比較大的場景下,累加器很快就能達到stable狀態,緣由是counterCells數組的長度超過了CPU核數時,便不會繼續增加。

爲何使用LongAdder而不是AtomicInteger?首先二者實現累加的機理是不一致的,AtomicInteger只有一個併發點,好處是每次累加完,均可以拿到最新的數值;弊端是多CPU下,衝突嚴重。LongAdder則根據使用場景動態增長併發點,帶來的最大收益即是提升了寫入的吞吐,但由於衝突點變多,每次統計最新值時,煞費周章。二者談不上好壞,或誰取代誰,都要視你的應用場景而定。而CHM的size()方法的更偏向寫多讀少,故採用LongAdder的處理方式。本節後有關於二者的對比實驗

變量說明

baseCount 定義爲private volatile long baseCount; CHM的成員變量,累加時若是出現衝突,會將壓力打散

counterCells 定義爲private volatile long baseCount; CHM的成員變量,map的總數即是由baseCount及counterCells聯合存儲的,定義爲:

@sun.misc.Contended (解決緩存行僞共享問題)
static final class CounterCell {
    volatile long value;
    CounterCell(long x) { value = x; }
}

質疑

Ⅰ、問:[點1] 既然要進行CAS控制,能夠不要cellBusy == 0counterCells == as這2個判斷嗎?

答:能夠。由於在CAS加鎖成功後,還會進行double check,查看counterCells是否已經被初始化。可是直接進行CAS加鎖操做會影響效率,試想若是counterCells已經被另一個線程初始化完畢,若是有這2個判斷,就能夠直接跳出本次循環,不然還要進行CAS搶鎖

Ⅱ、問:[點2] 會有counterCells != as的場景嗎?

答:會,例如2個線程都發現counterCells == null,都進來初始化,具體場景可參見上述流程圖

Ⅲ、問:[點3] 若是執行cas期間發生counterCells擴容咋辦?

答:其實累加器的擴容不一樣於map中table數組的擴容,table的擴容是會新建Node對象,而累加器的擴容則不會新建對象,而是直接複用已建立的CounterCell對象,且數組的下標都不會發生變化,因此即使是在執行CAS期間發生了擴容,也不會影響總體計數的準確性

Ⅳ、問:[點4] Doug 老爺子是否是寫漏了?竟然在CAS鎖外直接建立對象,若是CAS失敗,這個new操做豈不是無謂之舉,影響性能?

答:其實看到這裏第一反應就是不夠嚴謹,在加鎖前執行這個操做容易形成 r 的無謂犧牲;但再一仔細琢磨,做者此舉是有深意的,主要爲如下二點:一、new操做跟分支判斷等語句是很耗時的操做,放在鎖外,可減小當前線程對鎖的佔用;二、counterCells數組不一樣於table數組,其最大值max介於
CPU <= max < 2*CPU。在併發較大的狀況下,很快就能達到stable狀態,不會一直上漲。因此這塊爲了性能的提高,仍是煞費苦心的

Ⅴ、問:[點5] 全部進入累加主邏輯的線程,在累加結束後,所有都直接返回了,也就是再也不參與後續的擴容邏輯,若是剛好本次累加後,總體長度達到閾值而又不擴容,豈不是形成CHM過載?

答:又是一個精妙的細節!的確是這樣,也就是CHM不嚴格保證在長度達到閾值後,立刻進行擴容。爲何這樣設計呢?其實主要仍是爲了不頻繁的調用sumCount()方法,由於計算總長度的方法採用的是LongAdder分散法,每次統計長度相對來講是比較耗時的,而能進入累加主邏輯的話,代表如今併發比較大,在大併發下每一個進入的流量都計算長度是得不償失的,因此此處犧牲了及時進行CHM擴容的代價,換取了累加的高性能;而其餘協助擴容的線程僅是判斷分桶 f hashChode == -1纔會協助擴容,一樣也不會調用sumCount()方法

LongAdderAtomicLong寫入性能對比,將目標值從1多線程累加至10億,分別統計2個併發類的耗時。原本打算將CHM中計數器累加部分的代碼摳出來作性能對比,但其本質上是LongAdder的思想,因此咱們直接抓其精要

併發數 1 2 3 4
AtomicLong 6311 19375 21209 27508
LongAdder 11003 5252 3647 2900

注:僅測試寫入性能,單位(ms)。測試用例 git@github.com:xijiu/share.git

3.六、擴容

總體思想

多線程協助擴容是CHM最難最重要的部分,同時也是存在bug的部分

具體實現思路咱們可先打個比方:比如咱們有100塊磚頭須要從A搬至B,可是每人每次只能搬運10塊,路途花費5分鐘,假如某人完成一次任務後,發現A地還有剩餘磚塊,那麼他還將持續工做,直至A地沒有剩餘磚塊,他的工做纔算結束。每一個人進入場地前首選須要領取一張工做許可證,而管理員手中共有20張許可證,即最多容許20人同時工做。當有人開始歸還許可證時,並不表明全部的磚塊已經從A搬運至了B,由於雖然此時A地已經沒有磚頭,但並不表明全部的磚頭都已搬運至B,可能有些磚頭正在路上,因此只有最後一張許可證歸還時,才表示全部的工做已經作完

而體如今CHM上的話,則是由transferIndex字段控制,例如map中table的長度爲16,步幅爲4,transferIndex的初始值爲16,每一個線程進入後對其進行CAS加鎖操做(transferIndex = transferIndex - 4),若是加鎖成功話,當前線程便獲取了轉移此4個節點的惟一權限,轉移完畢後,如 transferIndex > 0,當前線程還會嘗試對transferIndex進行加鎖並轉移,直至transferIndex == 0;因此本例中transferIndex存在的5個狀態:1六、十二、八、四、0

  • 鏈表轉移

    如上圖所示,對節點6進行擴容,分桶內的數據只會對應新table中的2個分桶,即桶6跟桶22,而後分別將以前的數據拷貝一份,並造成2個list,而後掛在新table的對應分桶下。此處爲何要新建而不是直接引用?主要是爲了保證get方法的吞吐,即使是在擴容階段,get也不受影響

  • 紅黑樹轉移

    其主要思想與鏈表轉移相似,惟一不一樣是,紅黑樹拆分後可能變成2個紅黑樹、或者1個樹1個鏈表、或者2個鏈表

質疑

Ⅰ、問:[點1] 第一個進入擴容的線程,在搶到鎖至爲nextTable賦值是有一點gap的,假設某個後續線程在執行時,正好處於這個gap,那nextTable == null就會成立,這樣豈不是會致使當前線程誤覺得擴容已經結束,而後直接返回了麼?這是不是一個bug?

答:的確是問題描述的這種狀況,不過是不是bug值得商榷。由於首先協助擴容並非功能上強依賴的,即使是隻有一個線程在擴容,其餘線程一直在等待也不會對總體功能有影響;其次這個gap存在的時間相比較整個擴容來講仍是比較短的,若是某個線程正好處於這個gap對總體性能的影響可控

Ⅱ、問:[點1] (sc >>> 16) != rs這個表達式何時會成立?直觀看代碼,好像(sc >>> 16)恆等於 rs 呀?

答:好問題,其實要回答這個問題還要看結合後續的擴容邏輯來看,在擴容結束後,最後一個線程會給成員變量賦新值,賦值的順序爲:

nextTable = null;
table = nextTab;
sizeCtl = n * 2 * 0.75;

可見,他們沒法作到原子操做,而是有前後順序;設想當程序已經爲table賦了新值,而sizeCtl還未被賦值時(此時sizeCtl爲一個很大的負數),某個線程處理新數據添加並判斷是否要擴容時,便命中了此判斷,由於此時sizeCtl的高16位標記的仍是舊的table長度,因此此判斷仍是很是嚴謹的。讓我不由想到了不朽名著《紅樓夢》的「草蛇灰線,伏脈千里」啊,嘆嘆!

Ⅲ、問:[點2] 此表達式在什麼場景下會成立?前面會對 transferIndex 進行CAS加鎖,按理說這個表達式永遠不會成立?

答:僅當前的邏輯,此表達式確實永遠不會成立。但是最後一個負責擴容的線程會對全部的節點進行一遍double check,來確保全部的節點的hash值都爲-1,即全部節點都完成轉移

Ⅳ、問:[點2] 既然每一個線程都按照嚴格的加鎖順序將CHM已經轉移完畢,爲何最後一個線程還要執行double check?

答:若是你讀源碼也注意到了這點,那麼恭喜你,你發現了CHM的另外一個bug!的確,最後一個線程再次double check是徹底沒有必要的,doug 本人已經實錘,是前一個版本遺留的,會在下個版本中刪去;其實我本人讀到這兒時,糾結了很長時間,一直不明白做者此舉用意,心想是否是上下文有些漏讀的信息,致使浪費了很多時間哈。此優化具體可參看: http://cs.oswego.edu/pipermail/concurrency-interest/2020-July/017171.html

Ⅴ、問:[點1] 流程圖中標註在計算最大線程時存在bug,爲何CHM真正跑起來時歷來沒有遇到過?

答:CHM這個控制最大參與擴容併發線程樹的bug,源碼是

if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
	sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
   	transferIndex <= 0)

此處其實爲想獲取正常參與擴容的線程數,應修改成sc == (rs << 16) + 1 || sc == (rs << 16) + MAX_RESIZERS,之因此咱們實際生產過程當中不多碰到,是由於首先須要線程數達到MAX_RESIZERS65536個,纔有可能出問題。此bug地址 https://bugs.java.com/bugdatabase/view_bug.do?bug_id=JDK-8214427

3.七、get方法

get方法相對簡單,先上源碼

public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    int h = spread(key.hashCode());
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        else if (eh < 0)
            return (p = e.find(h, key)) != null ? p.val : null;
        while ((e = e.next) != null) {
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}

其實也就是直接獲取值,是鏈表或紅黑樹,就直接尋找,若是分桶爲空,也就直接返回空;能作到這麼瀟灑,仍是得力於volatile關鍵字以及CHM在擴容時對數據進行復制新建

4、總結

文中的流程圖算是比較重要的信息,CHM的功能、併發、知識點全都涵蓋在裏面,建議讀者一邊看圖一邊參照源碼,這樣更能加深印象,也更容易吃透CHM

原本想作個知識點總結的,結果發現赫赫有名的CHM僅僅用到了CAS、volatile、循環以及分支判斷,讓咱們不由對 doug 肅然起敬,他留給咱們的東西太美了

相關文章
相關標籤/搜索