ConcurrentHashMap面試靈魂拷問,你能扛多久?
來自:blog.csdn.net/zwx900102/article/details/114547968
前言
ConcurrentHashMap?常見的面試問題引入話題,并逐步揭開其設(shè)計原理,相信讀完本文,對面試中的相關(guān)問題會有很大的幫助。ConcurrentHashMap,ConcurrentHashMap?和?HashMap?的功能是基本一樣的,ConcurrentHashMap?是 HashMap 的線程安全版本。ConcurrentHashMap?和 HashMap 在 jdk1.8 版本中排除線程的安全性方面,其他的設(shè)計都很類似,所以有很多相同的設(shè)計思想本文不會做太多重復(fù)介紹。ConcurrentHashMap 原理
ConcurrentHashMap?是 HashMap 的線程安全版本,其內(nèi)部和 HashMap 一樣,也是采用了數(shù)組 + 鏈表 + 紅黑樹的方式來實現(xiàn)。HashTable?中,是直接在 put 和 get 方法上加上了?synchronized,理論上來說?ConcurrentHashMap?也可以這么做,但是這么做鎖的粒度太大,會非常影響并發(fā)性能,所以在?ConcurrentHashMap?中并沒有采用這么直接簡單粗暴的方法,其內(nèi)部采用了非常精妙的設(shè)計,大大減少了鎖的競爭,提升了并發(fā)性能。ConcurrentHashMap?中的初始化和 HashMap 中一樣,而且容量也會調(diào)整為 2 的 N 次冪,在這里不做重復(fù)介紹這么做的原因。JDK1.8 版本 ConcurrentHashMap 做了什么改進(jìn)
ConcurrentHashMap?由數(shù)組 + Segment + 分段鎖實現(xiàn),其內(nèi)部分為一個個段(Segment)數(shù)組,Segment 通過繼承?ReentrantLock?來進(jìn)行加鎖,通過每次鎖住一個 segment 來降低鎖的粒度而且保證了每個 segment 內(nèi)的操作的線程安全性,從而實現(xiàn)全局線程安全。下圖就是 JDK1.7 版本中?ConcurrentHashMap?的結(jié)構(gòu)示意圖:
通過 hash 值和 段數(shù)組長度-1 進(jìn)行位運算確認(rèn)當(dāng)前 key 屬于哪個段,即確認(rèn)其在 segments 數(shù)組的位置。 再次通過 hash 值和 table 數(shù)組(即 ConcurrentHashMap 底層存儲數(shù)據(jù)的數(shù)組)長度 - 1進(jìn)行位運算確認(rèn)其所在桶。
ConcurrentHashMap?做了優(yōu)化,取消了分段鎖的設(shè)計,取而代之的是通過 cas 操作和?synchronized?關(guān)鍵字來實現(xiàn)優(yōu)化,而擴(kuò)容的時候也利用了一種分而治之的思想來提升擴(kuò)容效率,在 JDK1.8 中?ConcurrentHashMap?的存儲結(jié)構(gòu)和 HashMap 基本一致,如下圖所示:
為什么 key 和 value 不允許為 null
ConcurrentHashMap?中卻不允許,這是為什么呢?get(key)?返回的結(jié)果是 null,那么我們無法確認(rèn)是因為當(dāng)時這個 key 對應(yīng)的 value 本身放的就是 null,還是說這個 key 值根本不存在,這會引起歧義,如果在非并發(fā)編程中,可以進(jìn)一步通過調(diào)用?containsKey?方法來進(jìn)行判斷,但是并發(fā)編程中無法保證兩個方法之間沒有其他線程來修改 key 值,所以就直接禁止了 null 值的存在。ConcurrentHashMap?是 Doug Lea 一個人開發(fā)的,所以就直接禁止了 null 值的存在。ConcurrentHashMap 如何保證線程的安全性
如何用 CAS 保證數(shù)組初始化的安全

ConcurrentHashMap?的原理非常重要。sizeCtl<-1?表示有 N-1 個線程正在執(zhí)行擴(kuò)容操作,如 -2 就表示有 2-1 個線程正在擴(kuò)容。sizeCtl=-1?占位符,表示當(dāng)前正在初始化數(shù)組。sizeCtl=0?默認(rèn)狀態(tài),表示數(shù)組還沒有被初始化。sizeCtl>0?記錄下一次需要擴(kuò)容的大小。
put 操作如何保證數(shù)組元素的可見性
ConcurrentHashMap?中存儲數(shù)據(jù)采用的 Node 數(shù)組是采用了 volatile 來修飾的,但是這只能保證數(shù)組的引用在不同線程之間是可用的,并不能保證數(shù)組內(nèi)部的元素在各個線程之間也是可見的,所以這里我們判定某一個下標(biāo)是否有元素,并不能直接通過下標(biāo)來訪問,那么應(yīng)該如何訪問呢?源碼給你答案:

synchronized?關(guān)鍵字鎖住當(dāng)前節(jié)點,并進(jìn)行對應(yīng)的設(shè)值操作:
精妙的計數(shù)方式
++size?的方式來存儲當(dāng)前集合中元素的個數(shù),但是在并發(fā)模式下,這種操作是不安全的,所以不能通過這種方式,那么是否可以通過 CAS 操作來修改 size 呢?ConcurrentHashMap?集合的性能,所以作者就想到了一個分而治之的思想來完成計數(shù)。//用來計數(shù)的數(shù)組,大小為2的N次冪,默認(rèn)為2
private?transient?volatile?CounterCell[]?counterCells;
@sun.misc.Contended?static?final?class?CounterCell?{//數(shù)組中的對象
????????volatile?long?value;//存儲元素個數(shù)
????????CounterCell(long?x)?{?value?=?x;?}
????}
addCount 計數(shù)方法

CounterCell?數(shù)組是不是為空,需要這里的是,這里的 CAS 操作是將?BASECOUNT?和 baseCount 進(jìn)行比較,如果相等,則說明當(dāng)前沒有其他線程過來修改 baseCount(即 CAS 操作成功),此時則不需要使用?CounterCell?數(shù)組,而直接采用 baseCount 來計數(shù)。CounterCell?為空且 CAS 失敗,那么就會通過調(diào)用?fullAddCount?方法來對?CounterCell?數(shù)組進(jìn)行初始化。fullAddCount 方法
CounterCell?數(shù)組的初始化和賦值等操作。初始化 CounterCell 數(shù)組

cellsBusy==0,而 as 其實在前面就是把全局變量?CounterCell?數(shù)組的賦值,這里之所以再判斷一次就是再確認(rèn)有沒有其他線程修改過全局?jǐn)?shù)組?CounterCell,所以條件滿足的話就會通過 CAS 操作修改?cellsBusy?為 1,表示當(dāng)前自己在初始化了,其他線程就不能同時進(jìn)來初始化操作了。ConcurrentHashMap?的元素數(shù)量。CounterCell 如何賦值
fullAddCount?方法的另一個分支:
CounterCell?數(shù)組不為空,然后會再次判斷數(shù)組中的元素是不是為空,因為如果元素為空,就需要初始化一個?CounterCell?對象放到數(shù)組,而如果元素不為空,則只需要 CAS 操作替換元素中的數(shù)量即可。CounterCell?對象的時候也需要將?cellBusy?由 0 改成 1。計數(shù)數(shù)組 CounterCell 也能擴(kuò)容嗎

當(dāng)前? CounterCell?數(shù)組已經(jīng)初始化完成。當(dāng)前通過 hash 計算出來的? CounterCell?數(shù)組下標(biāo)中的元素不為 null。直接通過 CAS 操作修改? CounterCell?數(shù)組中指定下標(biāo)位置中對象的數(shù)量失敗,說明有其他線程在競爭修改同一個數(shù)組下標(biāo)中的元素。當(dāng)前操作不滿足不允許擴(kuò)容的條件。 當(dāng)前沒有其他線程創(chuàng)建了新的? CounterCell?數(shù)組,且當(dāng)前?CounterCell?數(shù)組的大小仍然小于 CPU 數(shù)量。
CounterCell?數(shù)組也進(jìn)行擴(kuò)容,這個擴(kuò)容的方式和?ConcurrentHashMap?的擴(kuò)容一樣,也是將原有容量乘以 2,所以其實?CounterCell?數(shù)組的容量也是滿足 2 的 N 次冪。ConcurrentHashMap 的擴(kuò)容
ConcurrentHashMap?的大小是否達(dá)到了擴(kuò)容的閾值,如果達(dá)到,需要擴(kuò)容。擴(kuò)容也能支持并發(fā)嗎
ConcurrentHashMap?擴(kuò)容也支持多線程同時進(jìn)行,這又是如何做到的呢?接下來就讓我們回到 addCount 方法一探究竟。
>=0?才開始檢查是否需要擴(kuò)容,緊挨之后是一個 while 循環(huán),主要是滿足兩個條件:>=sizeCtl?表示的就是是否達(dá)到擴(kuò)容閾值。擴(kuò)容戳有什么用
resizeStamp?方法就一句話,其中?RESIZE_STAMP_BITS?是一個默認(rèn)值 16。?static?final?int?resizeStamp(int?n)?{
????????return?Integer.numberOfLeadingZeros(n)?|?(1?<(RESIZE_STAMP_BITS?-?1));
????}
Integer.numberOfLeadingZeros(n)?這個方法,這個方法源碼就不貼出來了,實際上這個方法就是做一件事,那就是獲取當(dāng)前數(shù)據(jù)轉(zhuǎn)成二進(jìn)制后的最高非 0 位前的 0 的個數(shù)。1 << (RESIZE_STAMP_BITS - 1)在當(dāng)前版本就是 1<<15,也就是得到一個二進(jìn)制數(shù)?1000000000000000,這里也是要做一件事,把這個 1 移動到第 16 位。最后這兩個數(shù)通過 | 操作一定得到的結(jié)果就是第 16 位是 1,因為 int 是 32 位,最多也就是 32 個 0,而且因為 n 的默認(rèn)大小是 16(ConcurrentHashMap?默認(rèn)大?。?,所以實際上最多也就是 27(11011),也就是說這個數(shù)最高位的 1 也只是在第五位,執(zhí)行 | 運算最多也就是影響低 5 位的結(jié)果。注意:這里之所以要保證第 16 位為 1,是為了保證 sizeCtl 變量為負(fù)數(shù),因為前面我們提到,這個變量為負(fù)數(shù)才代表當(dāng)前有線程在擴(kuò)容,至于這個變量和 sizeCtl 的關(guān)系后面會介紹。
首次擴(kuò)容為什么計數(shù)要 +2 而不是 +1
RESIZE_STAMP_SHIFT)位,然后加上一個 2,這個代表什么意思呢?為什么是加 2 呢?高 16 位代表當(dāng)前擴(kuò)容的標(biāo)記,可以理解為一個紀(jì)元。 低 16 位代表了擴(kuò)容的線程數(shù)。
擴(kuò)容條件
(sc >>> RESIZE_STAMP_SHIFT) != rs 這個條件實際上有 bug,在 JDK12 中已經(jīng)換掉。 sc == rs + 1 表示最后一個擴(kuò)容線程正在執(zhí)行首位工作,也代表擴(kuò)容即將結(jié)束。 sc == rs + MAX_RESIZERS 表示當(dāng)前已經(jīng)達(dá)到最大擴(kuò)容線程數(shù),所以不能繼續(xù)讓線程加入擴(kuò)容。 擴(kuò)容完成之后會把 nextTable(擴(kuò)容的新數(shù)組) 設(shè)為 null。 transferIndex <= 0 表示當(dāng)前可供擴(kuò)容的下標(biāo)已經(jīng)全部分配完畢,也代表了當(dāng)前線程擴(kuò)容結(jié)束。
多并發(fā)下如何實現(xiàn)擴(kuò)容
ConcurrentHashMap?中采用的是分段擴(kuò)容法,即每個線程負(fù)責(zé)一段,默認(rèn)最小是 16,也就是說如果?ConcurrentHashMap?中只有 16 個槽位,那么就只會有一個線程參與擴(kuò)容。如果大于 16 則根據(jù)當(dāng)前 CPU 數(shù)來進(jìn)行分配,最大參與擴(kuò)容線程數(shù)不會超過 CPU 數(shù)。
transferIndex?代表的就是推進(jìn)下標(biāo),默認(rèn)為舊數(shù)組的大小。擴(kuò)容時的數(shù)據(jù)遷移如何保證安全性

fwd 節(jié)點:?這個代表的是占位節(jié)點,最關(guān)鍵的就是這個節(jié)點的 hash 值為 -1,所以一旦發(fā)現(xiàn)某一個節(jié)點中的 hash 值為 -1 就可以知道當(dāng)前節(jié)點已經(jīng)被遷移了。 advance:?代表是否可以繼續(xù)推進(jìn)下一個槽位,只有當(dāng)前槽位數(shù)據(jù)被遷移完成之后才可以設(shè)置為 true finishing:?是否已經(jīng)完成數(shù)據(jù)遷移。
transferIndex?為舊數(shù)組大小 16,而在第二個 if判斷中,transferIndex?賦值給了 nextIndex,所以?nextIndex?為 1,而 stride 代表的是每個線程負(fù)責(zé)的槽位數(shù),最小就是 16,所以 stride 也是 16,所以?nextBound= nextIndex > stride ? nextIndex - stride : 0?皆可以得到:nextBound=0?和?i=15?了,也就是當(dāng)前線程負(fù)責(zé) 0-15 的數(shù)組下標(biāo),且從 0 開始推進(jìn),確認(rèn)邊界后立刻將 advance 設(shè)置為 false,也就是會跳出 while 循環(huán),從而執(zhí)行下面的數(shù)據(jù)遷移部分邏輯。PS:因為? nextBound=0,所以 CAS 操作實際上也是把?transferIndex?變成了 0,表示當(dāng)前擴(kuò)容的數(shù)組下標(biāo)已經(jīng)全部分配完畢,這也是前面不滿足擴(kuò)容的第 5 個條件。
synchronized?關(guān)鍵字對當(dāng)前節(jié)點進(jìn)行加鎖,也就是說鎖的粒度精確到了每一個節(jié)點,可以說大大提升了效率。加鎖之后的數(shù)據(jù)遷移和 HashMap 基本一致,也是通過區(qū)分高低位兩種情況來完成遷移,在本文就不重復(fù)講述。
nextTable?設(shè)置為 null,所以上面不滿足擴(kuò)容的第 4 個條件就是在這里設(shè)置的。總結(jié)
ConcurrentHashMap?中是如何保證安全性的,并且挑選了一些比較經(jīng)典的面試常用問題進(jìn)行分析解答,在整個?ConcurrentHashMap?中,整個思想就是降低鎖的粒度,減少鎖的競爭,所以采用了大量的分而治之的思想,比如多線程同時進(jìn)行擴(kuò)容,以及通過一個數(shù)組來實現(xiàn) size 的計數(shù)等。評論
圖片
表情
