基礎(chǔ)的集合框架困乒,前面已經(jīng)介紹的差不多了,現(xiàn)在我們還是介紹幾個(gè)高級(jí)一些的.
首先介紹的是我們應(yīng)該都熟悉的ConcurrentHashMap.
各位在看各種資料的時(shí)候所意,或多或少應(yīng)該都會(huì)看到過(guò)它的身影巴柿,我曾經(jīng)也看了好多次網(wǎng)上關(guān)于他的文章,但是并沒(méi)有深入研究過(guò).
注意
這里我們介紹的是JDK8之前的版本中的ConcurrentHashMap,在此之前的版本的實(shí)現(xiàn)方式應(yīng)該都差不多庄新,實(shí)際上,我閱讀的源碼是JDK6中的.而在JDK8中薯鼠,源碼變化很大择诈,基本上等于重新實(shí)現(xiàn)了一遍.思路都不同了.
之前的很多文章在介紹ConcurrentHashMap的時(shí)候,都不會(huì)說(shuō)明是哪個(gè)JDK版本中的源碼出皇,我們?cè)谧x源碼時(shí)羞芍,可能就有一些困惑.我剛開(kāi)始讀的是JDK8中的源碼,然后參考IBM的一篇介紹ConcurrentHashMap的文章恶迈,就感覺(jué)有一些驢唇不對(duì)馬嘴.于是Google了一下1.6版本的ConcurrentHashMap的實(shí)現(xiàn)涩金,終于發(fā)現(xiàn)之前讀的源碼是錯(cuò)的.
但是不得不說(shuō),上面提到的那篇IBM的文章確實(shí)是不錯(cuò)的暇仲,我自認(rèn)這篇文章不會(huì)如那篇文章那樣結(jié)構(gòu)清晰步做,通俗易懂,講解的又深入奈附,但是全度,如果我不寫(xiě)這篇文章記錄下來(lái)最近閱讀源碼產(chǎn)生的感悟,不借著寫(xiě)這篇文章的時(shí)機(jī)再深入研究一點(diǎn)斥滤,可能學(xué)習(xí)的效果就不是很好将鸵,對(duì)吧?
所以佑颇,我會(huì)在文末附上IBM的那篇文章的鏈接.讀者閱讀到這里的時(shí)候顶掉,一定還沒(méi)有被我的淺見(jiàn)給影響,所以挑胸,看到此處痒筒,還是請(qǐng)各位讀者先閱讀IBM的那篇文章,理解了茬贵,懂了之后簿透,在返回此處,看看是否補(bǔ)充了什么.
另外解藻,JDK8中的ConcurrentHashMap的實(shí)現(xiàn)老充,我看現(xiàn)在還沒(méi)有人介紹.所以,接下來(lái)的這段時(shí)間螟左,我可能會(huì)更加深入的閱讀一下它的源碼啡浊,給大家一個(gè)介紹.但是,在調(diào)試時(shí)胶背,我需要用字節(jié)碼操控工具ASM或者Java assist來(lái)增強(qiáng)一下字節(jié)碼虫啥,方便我調(diào)試.可惜的是,這兩款工具奄妨,我現(xiàn)在還并不了解.所以涂籽,這幾天,我會(huì)先分別學(xué)習(xí)一下這兩款工具的使用.再去調(diào)試JDK8中的ConcurrentHashMap的實(shí)現(xiàn).
目標(biāo)
在這篇文章中砸抛,我希望能夠讓各位對(duì)下面的問(wèn)題能有一個(gè)清晰的答案:
- ConcurrentHashMap的內(nèi)部結(jié)構(gòu)是怎樣的?
- ConcurrentHashMap為何能夠保證多線程線程安全地更新數(shù)據(jù)?
- ConcurrentHashMap內(nèi)部為何大量使用Unsafe類(lèi)?
- ConcurrentHashMap中评雌,寫(xiě)操作會(huì)阻塞讀操作嗎?
- ConcurrentHashMap如何保證寫(xiě)線程的操作能夠?qū)ψx線程立即可見(jiàn)?
前提條件
在各位往下讀之前直焙,我強(qiáng)烈建議各位了解一下Java內(nèi)存模型.
這里我會(huì)簡(jiǎn)單介紹一下.
上面這幅圖是從周志明老師的<<深入理解Java虛擬機(jī) Java高級(jí)特性與最佳實(shí)踐 第2版>>版這本書(shū)中找到的.我認(rèn)為用這幅圖就能說(shuō)明.
在我繪制這幅圖的時(shí)候景东,由于不知什么原因,Dia中突然無(wú)法輸入中文了奔誓,于是就先用的英文斤吐,但是也無(wú)大礙.
從上圖中搔涝,我們可以看到,Java虛擬機(jī)和措,針對(duì)線程庄呈,將內(nèi)存劃分成兩部分,一部分是線程獨(dú)有的Working Memory派阱,一部分是Major Memory.
如果各位對(duì)Java內(nèi)存劃分熟悉的話诬留,應(yīng)該會(huì)感覺(jué)這里Working Memory包括虛擬機(jī)棧和本地方法棧,Major Memory包括方法區(qū)和堆贫母,我感覺(jué)應(yīng)該是這樣一種對(duì)應(yīng)關(guān)系文兑,但是周老師的書(shū)中并沒(méi)有明確這么說(shuō),所以現(xiàn)在這只是一種猜想.
Java Thread和Working Memory以及Major Memory的關(guān)系腺劣,其實(shí)用CPU和高速緩存以及內(nèi)存的關(guān)系來(lái)比喻更為恰當(dāng).這里可以把Java Thread看成CPU, Working Memory看成CPU的高速緩存绿贞,Major Memory看成我們常說(shuō)的物理內(nèi)存.
Java內(nèi)存模型規(guī)定了所有的實(shí)例字段,靜態(tài)字段以及構(gòu)成數(shù)組對(duì)象的元素都存放在Major Memory中橘原,然后線程的Working Memory中保存了該線程使用到的變量的Major Memory的拷貝樟蠕,線程對(duì)變量的所有操作都必須在Working Memory中進(jìn)行,而不能直接讀寫(xiě)Major Memory內(nèi)存中的變量.不同的線程之間也無(wú)法直接訪問(wèn)對(duì)方Working Memory內(nèi)存中的變量靠柑,線程間的變量值的傳遞均需要通過(guò)Major Memory來(lái)完成.
這樣就很清楚了寨辩,如果是一個(gè)普通變量,你對(duì)它執(zhí)行了更新操作之后歼冰,由于是在Working Memory中進(jìn)行的靡狞,其他的線程就無(wú)法立刻獲取到這個(gè)普通變量經(jīng)過(guò)更新之后的值.需要等到之后這個(gè)線程將變量回寫(xiě)到Major Memory然后其他線程的Working Memory中的變量失效時(shí),才能加載到最新的數(shù)據(jù).
那么如何解決這個(gè)問(wèn)題呢?
這就需要用的volatile變量了.如果我們給變量加上這個(gè)修飾符.那么當(dāng)Java線程在Working Memory中更新了這個(gè)變量之后隔嫡,它還會(huì)通知其他線程甸怕,"我已經(jīng)更新了變量a的值了,所以你們的Working Memory中的變量a的值就失效了腮恩,下次需要訪問(wèn)變量a時(shí)梢杭,記得先從Major Memory中讀取呀!".其他線程收到這個(gè)消息之后,就讓它的Working Memory中的對(duì)應(yīng)的變量失效秸滴,并從Major Memory加載最新的值.這樣不就保證沒(méi)有臟讀現(xiàn)象了嗎?對(duì)吧?
Ok,關(guān)于Java內(nèi)存模型武契,這里就介紹這么多.IBM的那篇文章中還提到了ConcurrentHashMap中用volatile防止了指令重排序的問(wèn)題.實(shí)際上,這個(gè)是由Java內(nèi)存模型為volatile賦予的特殊性質(zhì).只不過(guò)我不太清楚荡含,ConcurrentHashMap的實(shí)現(xiàn)跟指令重排序有什么關(guān)系.
如果你想對(duì)Java內(nèi)存模型有一個(gè)更加深入的了解咒唆,強(qiáng)烈建議讀一下周志明老師的<<深入理解Java虛擬機(jī) Java高級(jí)特性與最佳實(shí)踐 第2版>>.真的是不錯(cuò)的一本書(shū),讀完之后感覺(jué)對(duì)于Java的理解深入了很多.
內(nèi)部結(jié)構(gòu)
我們先來(lái)介紹一下ConcurrentHashMap的內(nèi)部結(jié)構(gòu)到底是怎么樣的释液,再來(lái)介紹一下各種操作的實(shí)現(xiàn).閱讀一個(gè)項(xiàng)目的源碼時(shí)全释,如果能了解它的內(nèi)部的數(shù)據(jù)結(jié)構(gòu),它的處理流程误债,對(duì)于理解這個(gè)項(xiàng)目是大有裨益的.
對(duì)于ConcurrentHashMap的內(nèi)部結(jié)構(gòu)浸船,IBM的那篇文章中妄迁,實(shí)際上已經(jīng)有一個(gè)非常清晰直觀的圖了,這里我直接貼過(guò)來(lái):
在ConcurrentHashMap中李命,定義了一個(gè)Segment<K,V>[]字段segments,存放了全部的Segment.
然后我們看一下Segment類(lèi)的實(shí)現(xiàn):
從源碼中登淘,我們可以看到,Segment繼承自ReentrantLock项戴,這就是ConcurrentHashMap能夠進(jìn)行多線程線程安全地執(zhí)行更新操作的重要原因.
同時(shí),我們還看到槽惫,Segment中還有一個(gè)HashEntry<K, V>[]類(lèi)型的變量table.它用于存放Segment中的數(shù)據(jù).
注意上面的table變量是有volatile關(guān)鍵字進(jìn)行修飾的周叮,也就是說(shuō),當(dāng)table擴(kuò)容時(shí)界斜,其他的線程是可以立即感知到的.但是仿耽,這并不意味著,table中的HashEntry也是volatile的.也就是說(shuō)各薇,你修改了table中的HashEntry的屬性项贺,對(duì)于其他線程來(lái)說(shuō),并不是立即可見(jiàn)的.
那么怎么解決這種問(wèn)題呢?即如何解決數(shù)組是volatile而數(shù)組中的元素不是volatile的問(wèn)題呢?
第一種,也是最容易想到的峭判,就是开缎,我們把數(shù)組中的元素中的能夠被修改的字段,也給弄成volatile的林螃,不就得了嘛奕删!但是除了這樣做以外,我們還需要配合使用Unsafe類(lèi)的putOrderedObject(還有一個(gè)方法疗认,putObjectVolatile完残,我感覺(jué)也可以,但是沒(méi)有驗(yàn)證)方法横漏,觸發(fā)Working Memory變量失效的過(guò)程.Segment中就是采用的這種方式.
第二種方案谨设,就是,修改了數(shù)組中的元素的時(shí)候缎浇,將這個(gè)數(shù)組重新賦給舊數(shù)組扎拣,用代碼表示就是:
table[0].value = 1;
table = table;
這個(gè)應(yīng)該也很容易理解吧?因?yàn)?strong>table就是volatile,你對(duì)它重新賦值一遍,當(dāng)然也會(huì)觸發(fā)上面我們提到的那個(gè)告訴其他線程Working Memory失效并從Major Memory中重新加載的過(guò)程.
這樣我們就能在數(shù)組中的元素更新了之后素跺,對(duì)于其他線程來(lái)說(shuō)鹏秋,也是立即可見(jiàn)的.
HashEntry是一個(gè)鏈表類(lèi)型的數(shù)據(jù)類(lèi)型,當(dāng)發(fā)生沖突時(shí)亡笑,采用鏈接法來(lái)處理沖突:
這里注意value和next都用volatile關(guān)鍵字進(jìn)行修飾了.
有沒(méi)有感覺(jué)Segment除了繼承自ReentrantLock之外侣夷,其他的地方都很像一個(gè)HashMap?內(nèi)部也是采用數(shù)組作為底層數(shù)據(jù)結(jié)構(gòu),沖突時(shí)也是采用鏈接法來(lái)解決仑乌,當(dāng)鏈表上的沖突的元素太多了百拓,也會(huì)進(jìn)行樹(shù)化琴锭,當(dāng)整個(gè)超過(guò)了threshold,也會(huì)擴(kuò)容并進(jìn)行重新哈希.
所以衙传,我感覺(jué)其實(shí)ConcurrentHashMap本質(zhì)上就是將多個(gè)由ReentrantLock守護(hù)的HashMap組合成了一個(gè)更大的HashMap而已.
在ConcurrentHashMap中决帖,還有很多很重要的參數(shù),比如DEFAULT_CONCURRENCY_LEVEL蓖捶,默認(rèn)為16地回,表示默認(rèn)情況下,一個(gè)ConcurrentHashMap包含16個(gè)Segment.還有代表整個(gè)ConcurrentHashMap的默認(rèn)最小容量的DEFAULT_INITIAL_CAPACITY,代表整個(gè)ConcurrentHashMap的默認(rèn)負(fù)載因子的DEFAULT_LOAD_FACTOR.
構(gòu)造方法
由于ConcurrentHashMap的一些元數(shù)據(jù)比如Segment的數(shù)量都是在構(gòu)造方法內(nèi)進(jìn)行初始化的俊鱼,所以如果我們了解了構(gòu)造方法刻像,對(duì)于這些變量的作用會(huì)有一個(gè)更加清晰的認(rèn)識(shí).
其實(shí)這里,你只要拿默認(rèn)值往里面一帶并闲,走一遍细睡,就清楚了.
咱們先用默認(rèn)值走一遍,默認(rèn)情況下帝火,傳入的參數(shù)分別是ConcurrentHashMap.DEFAULT_INITIAL_CAPACITY, ConcurrentHashMap.LOAD_FACTOR, ConcurrentHashMap.CONCURRENCY_LEVEL:
initialCapacity = DEFAULT_INITIAL_CAPACITY = 16
loadFactor = DEFAULT_LOAD_FACTOR = 0.75
concurrencyLevel = DEFAULT_CONCURRENCY_LEVEL = 16
MIN_SEGMENT_TABLE_CAPACITY = 2
sshift = 0 -> 4
ssize = 1 -> 16
segmentShift = 32 - sshift = 32 - 4 = 28
segmentMask = ssize - 1 = 15
c = initialCapacity / ssize = 16 / 16 = 1 -> 4
cap = 2 -> 4
其中上面的那一部分是傳入的參數(shù)以及默認(rèn)值溜徙,下面的那一部分是在這些默認(rèn)值的情況下,各個(gè)局部變量的變化情況.a->b表示變量的值經(jīng)過(guò)一個(gè)循環(huán)以后由a變成了b.
這個(gè)應(yīng)該沒(méi)有困難的吧.實(shí)在不理解犀填,自己動(dòng)手驗(yàn)算一下便是.
從源代碼中蠢壹,我們能夠看到,Segment的數(shù)量由ssize變量確定九巡,Segment中table的大小由cap變量確定.
從上面這段代碼我們可以看到知残,如果ssize小于concurrencyLevel,那么ssize便會(huì)一直變化為兩倍大斜茸.也就是說(shuō)求妹,如果我們指定concurrencyLevel為17,那么實(shí)際上會(huì)有32個(gè)Segment.
從這也說(shuō)明了,concurrencyLevel參數(shù)佳窑,并不是指定的Segment的數(shù)量.這個(gè)參數(shù)只是說(shuō)制恍,你估計(jì)一下會(huì)有多少個(gè)線程并發(fā)訪問(wèn)這個(gè)ConcurrentHashMap,然后ConcurrentHashMap根據(jù)你估計(jì)的這個(gè)值,確定有多少個(gè)Segment.
好神凑,現(xiàn)在我們知道了ssize代表Segment的數(shù)量了净神,那么sshift是什么含義呢?
它代表的是ssize的左移偏移量.
A的左移偏移量表示溉委,要把1左移多少位才能得到A.打個(gè)比方鹃唯,int型的16的二進(jìn)制形式為0000 0000 0000 0000 0000 0000 0001 0000.很明顯,16的左移偏移量為4.
左移偏移量有什么作用呢?
請(qǐng)看這兩行瓣喊,就用到了sshift這個(gè)左移偏移量.那么segmentShift和segmentMask這兩個(gè)ConcurrentHashMap的實(shí)例變量是干嘛的呢?相信你能夠猜出來(lái)坡慌,當(dāng)然是用這兩個(gè)實(shí)例變量定位Segment的呀.
這個(gè)方法用于根據(jù)hash獲取對(duì)應(yīng)的Segment.如果各位不了解Unsafe類(lèi),那么可能不太能理解這個(gè)方法.我簡(jiǎn)單解釋一下.
ConcurrentHashMap中藻三,獲取數(shù)組中的元素時(shí)洪橘,都不是使用的array[index]的形式跪者,而是直接從內(nèi)存中獲取,采用這樣的公式來(lái)定位內(nèi)存中熄求,這個(gè)元素的位置:數(shù)組在內(nèi)存中的起始地址 + (元素在數(shù)組中的索引 * 每個(gè)元素的尺寸).為什么采用這種方式呢?因?yàn)樵贘ava中渣玲,對(duì)數(shù)組通過(guò)索引獲取元素時(shí),需要檢查有沒(méi)有范圍越界的問(wèn)題弟晚,沒(méi)有采用直接訪問(wèn)內(nèi)存的這種方式的效率高.在ConcurrentHashMap中忘衍,為了進(jìn)一步提高效率,就采用了直接從內(nèi)存訪問(wèn)的方式.
上面的源代碼中卿城,(h >>> segmentShift) & segmentMask就是用來(lái)獲取哈希值為h的Segment在segments數(shù)組中的索引枚钓,其實(shí)源碼上采用的方法就是咱們上面說(shuō)的那個(gè)公式,但是源碼中用位操作改進(jìn)了一下藻雪,后面我們會(huì)介紹.
咱們還是回到那個(gè)問(wèn)題上秘噪,sshift這個(gè)左移偏移量到底有什么用?
我們可以看到狸吞,由于segmentMask的值為15,所以其有效位數(shù)就為低四位勉耀,所以,我們必須取h的高四位來(lái)進(jìn)行運(yùn)算蹋偏,而int型又是32的便斥,所以,segmentShift需要是28威始,才能取得h的高四位.你看segmentShift為28枢纠,sshift為4,這兩個(gè)有什么關(guān)系?
它們加起來(lái)正好是32,正好是int型數(shù)據(jù)的位數(shù)!
所以黎棠,左移偏移量sshift也可以說(shuō)是我們希望int型數(shù)據(jù)經(jīng)過(guò)無(wú)符號(hào)右移操作之后晋渺,可以取得的最高sshift位.所以,為了取得這最高的sshift位脓斩,我們需要將int型數(shù)據(jù)右移32-sshift=segmentShift位.
那上面說(shuō)的公式的優(yōu)化是怎么回事?
我們先看一下SSHIFT這個(gè)實(shí)例變量是怎么得到的木西,從ConcurrentHashMap的底部可以得到:
其中,ss變量表示的是Segment[]數(shù)組中随静,增量的長(zhǎng)度八千,也就是說(shuō),數(shù)組中相鄰的兩個(gè)索引在內(nèi)存地址上的距離燎猛,那么恋捆,31 - Integer.numberOfLeadingZero(ss)表示什么意思呢?
我們查看Integer中的numberOfLeadingZeros方法的源碼:
這里我們并不深入這個(gè)方法的實(shí)現(xiàn),而是看它的注釋?zhuān)瑥淖⑨屩兄乇粒覀兒芮逦木湍馨l(fā)現(xiàn)沸停,有這么一個(gè)公式:
其中floor的意思是,小于等于并且最接近以二為低的x的對(duì)數(shù).
在這里昭卓,由于ss是4(通過(guò)在ConcurrentHashMap中加一條打印語(yǔ)句來(lái)查看.因?yàn)樵?2bit機(jī)器上或者最大堆內(nèi)存小于32Gb的機(jī)器上星立,一個(gè)對(duì)象的引用占4個(gè)字節(jié)爽茴,所以ss是4),所以绰垂,上面的公式就等于:
所以室奏,我們有:
在結(jié)合(index << SSHIFT) + SBASE,我們可以得到下面的式子:
其中index就是哈希為h的那個(gè)Segment在segments[]數(shù)組中的索引劲装,SBASE這個(gè)變量表示的是胧沫,segment[]數(shù)組在內(nèi)存中的其實(shí)地址.
你看,最后得到的這個(gè)式子不就是我們之前說(shuō)的那個(gè)沒(méi)有經(jīng)過(guò)優(yōu)化的式子嗎占业?
實(shí)際上绒怨,在明白(index << SSHIFT) + SBASE是一個(gè)經(jīng)過(guò)優(yōu)化之后得到的公式之后,我就對(duì)ConcurrentHashMap的作者敬佩不已.我們可以看到谦疾,盡管優(yōu)化之后的式子需要進(jìn)行更多的計(jì)算南蹂,但是更多的是位操作,再加上一次減法操作念恍,這些都是機(jī)器能夠直接給我們提供的指令能做的運(yùn)算六剥,都是邏輯計(jì)算單元直接能做的操作,不像乘法這種操作.
這個(gè)構(gòu)造函數(shù)下面的內(nèi)容就很簡(jiǎn)單了吧峰伙,無(wú)非就是確定一下Segment中table的長(zhǎng)度.
put(K key, V value)方法和get(K key)方法
如果前面的內(nèi)容疗疟,各位已經(jīng)了解的話,下面的這幾個(gè)方法瞳氓,就都不是事了策彤,就特別特別簡(jiǎn)單了!
這兩個(gè)方法應(yīng)該是我們最常用的兩個(gè)方法匣摘,現(xiàn)在我們就來(lái)介紹這兩個(gè)方法.
put(K key, V value)方法
從put()方法中店诗,我們可以看到,先是對(duì)key進(jìn)行兩次哈希運(yùn)算音榜,然后找到對(duì)應(yīng)的Segment庞瘸,如果不存在那個(gè)Segment,就創(chuàng)建它囊咏,并調(diào)用Segment的put(K key, int hash, V value, boolean onlyIfAbsent)方法恕洲,將數(shù)據(jù)插入.
從ConcurrentHashMap的put()方法中,我們可以看到梅割,是沒(méi)有任何鎖操作的霜第,所以對(duì)ConcurrentHashMap進(jìn)行數(shù)據(jù)插入的操作時(shí),是不會(huì)鎖住整個(gè)ConcurrentHashMap的.
接下來(lái)户辞,我們看一下Segment的put()方法.
在這個(gè)方法中泌类,我們可以看到,一上來(lái)就是獲取鎖,最后再釋放鎖刃榨,所以弹砚,每次對(duì)ConcurrentHashMap時(shí),實(shí)際上只會(huì)鎖住一個(gè)Segment.
同時(shí)枢希,從第二章圖片中桌吃,我們還可以看到,如果發(fā)生沖突了苞轿,總是會(huì)在鏈表的前面插入新的節(jié)點(diǎn)茅诱,而不是在鏈表的末尾插入節(jié)點(diǎn).
引用IBM的那篇文章中的圖片,就是:
也就是說(shuō)搬卒,鏈表中節(jié)點(diǎn)的順序瑟俭,實(shí)際上是跟插入的順序相反的.
get()方法
get()方法更加簡(jiǎn)單.
還是先計(jì)算兩次哈希,然后先根據(jù)hash找到對(duì)應(yīng)的Segment,然后在從Segment中的table中尋找契邀,如果是個(gè)鏈表摆寄,就采用順序查找的方式進(jìn)行查找.
總結(jié)
我們可以看到,ConcurrentHashMap需要兩次計(jì)算Hash坯门,這個(gè)效率肯定是比不上只計(jì)算一次Hash的微饥,所以,如果不是在并發(fā)情況下田盈,就不要使用這個(gè)類(lèi).
另外畜号,ConcurrentHashMap和Hashtable以及由Collections.synchronizedMap方法包裝而成的線程安全的Map有什么區(qū)別?
ConcurrentHashMap相對(duì)于其他兩者缴阎,有點(diǎn)是性能更好允瞧,能夠允許多個(gè)線程并行執(zhí)行更新操作,當(dāng)然前提是這兩個(gè)線程不能是更新同一個(gè)Segment中的數(shù)據(jù)蛮拔,并且寫(xiě)線程不會(huì)阻塞讀線程.而其他兩者述暂,由于都是使用synchronized來(lái)進(jìn)行同步,所以其實(shí)際上只能串行地更新和讀取數(shù)據(jù).并且寫(xiě)線程會(huì)阻塞讀線程.
但是建炫,ConcurrentHashMap相對(duì)于其他兩者畦韭,當(dāng)然也是有弱點(diǎn)的,就是它實(shí)現(xiàn)的是弱一致性肛跌,對(duì)于某些需要強(qiáng)一致性的場(chǎng)景中艺配,用ConcurrentHashMap并不適合.