她如暴風雨中的一葉扁舟,在高併發的大風大浪下疾馳而過,眼看就要被湮滅,卻又在絕境中絕處逢生html
編寫一套即穩定、高效、且支持併發的代碼,不說難如登天,卻也絕非易事。java
一直有小夥伴向我諮詢關於ConcurrentHashMap(後文簡寫爲CHM)的問題,經常抱怨說:其餘源碼懂就是懂了,不懂就是不懂,惟獨CHM總給人一種似懂非懂的感受,感受抓住了精髓,卻又若即若離。其實,之因此有這種感受,並不難理解,由於本質上CHM是一套支持高併發的代碼,同一個方法、同一個返回值,在不一樣的線程或不一樣併發場景都須要完美運行,之因此感受似懂非懂,多是由於只抓住了某一類場景。區別於其餘源碼,咱們讀CHM時,也必定讓本身學會分身。node
本文在介紹CHM原理時,會更多的以分身的角度去看她,我會盡可能拋棄逐行讀源碼的方式,並抱着爲CHM找bug的心態去讀她(不存在完美的代碼,CHM也不例外)git
本文介紹的CHM版本基於JDK1.8,源碼洋洋灑灑共有6000+行代碼,本文着重介紹put
(初始化、累加器、擴容)、get
方法github
建議沒有讀過源碼的同窗先看一遍源碼,而後帶着問題來讀,這樣更容易讀懂並吃透她數組
咱們首先把1.8版本的CHM數據結構介紹下,讓你們對她有個宏觀認識
緩存
分桶: 如上圖所示,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+樹等詳細用例,值得一提的是用例會直接在控制檯打印樹信息,方便調試、學習併發
put
方法的流程以下圖所示,其中涉及幾個關鍵步驟:table初始化、擴容、數據寫入、總數累加。其實總體來看的話,流程很簡單,沒有初始化時,執行初始化,須要擴容時,幫助擴容,而後將數據寫入,最後記錄map總數。接下來咱們逐個分析
注:本文中,橙色線表示執行時不加任何鎖;藍色表示CAS操做;綠色表示synchronized
鎖
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 老爺子編碼之嚴謹)
Node 及 hashCode 其實節點類型與hashCode一一對應
Ⅰ、問:[點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,保證了修改操做的同步執行
相信不少同窗直觀感覺是:不就作個多線程計數器累加麼,至於搞這麼複雜?直接使用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 == 0
及counterCells == 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擴容的代價,換取了累加的高性能;而其餘協助擴容的線程僅是判斷分桶 fhashChode == -1
纔會協助擴容,一樣也不會調用sumCount()
方法
LongAdder
與AtomicLong
寫入性能對比,將目標值從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
多線程協助擴容是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_RESIZERS
65536個,纔有可能出問題。此bug地址https://bugs.java.com/bugdatabase/view_bug.do?bug_id=JDK-8214427
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在擴容時對數據進行復制新建
文中的流程圖算是比較重要的信息,CHM的功能、併發、知識點全都涵蓋在裏面,建議讀者一邊看圖一邊參照源碼,這樣更能加深印象,也更容易吃透CHM
原本想作個知識點總結的,結果發現赫赫有名的CHM僅僅用到了CAS、volatile、循環以及分支判斷,讓咱們不由對 doug 肅然起敬,他留給咱們的東西太美了