還記得大學(xué)快畢業(yè)的時(shí)候要準(zhǔn)備找工作了,然后就看各種面試相關(guān)的書(shū)籍,還記得很多面試書(shū)中都說(shuō)到:
HashMap是非線程安全的,HashTable是線程安全的。
那個(gè)時(shí)候沒(méi)怎么寫(xiě)java代碼,所以根本就沒(méi)有聽(tīng)說(shuō)過(guò)ConcurrentHashMap,只知道面試的時(shí)候就記住這句話就行了…至于為什么是線程安全的,內(nèi)部怎么實(shí)現(xiàn)的,通通不了解。今天我們將深入剖析一個(gè)比HashTable性能更優(yōu)的線程安全的Map類,它就是ConcurrentHashMap,本文基于Java 7的源碼做剖析。
ConcurrentHashMap的目的
多線程環(huán)境下,使用Hashmap進(jìn)行put操作會(huì)引起死循環(huán),導(dǎo)致CPU利用率接近100%,所以在并發(fā)情況下不能使用HashMap。雖然已經(jīng)有一個(gè)線程安全的HashTable,但是HashTable容器使用synchronized(他的get和put方法的實(shí)現(xiàn)代碼如下)來(lái)保證線程安全,在線程競(jìng)爭(zhēng)激烈的情況下HashTable的效率非常低下。因?yàn)楫?dāng)一個(gè)線程訪問(wèn)HashTable的同步方法時(shí),訪問(wèn)其他同步方法的線程就可能會(huì)進(jìn)入阻塞或者輪訓(xùn)狀態(tài)。如線程1使用put進(jìn)行添加元素,線程2不但不能使用put方法添加元素,并且也不能使用get方法來(lái)獲取元素,所以競(jìng)爭(zhēng)越激烈效率越低。
在這么惡劣的環(huán)境下,ConcurrentHashMap應(yīng)運(yùn)而生。
實(shí)現(xiàn)原理
ConcurrentHashMap使用分段鎖技術(shù),將數(shù)據(jù)分成一段一段的存儲(chǔ),然后給每一段數(shù)據(jù)配一把鎖,當(dāng)一個(gè)線程占用鎖訪問(wèn)其中一個(gè)段數(shù)據(jù)的時(shí)候,其他段的數(shù)據(jù)也能被其他線程訪問(wèn),能夠?qū)崿F(xiàn)真正的并發(fā)訪問(wèn)。如下圖是ConcurrentHashMap的內(nèi)部結(jié)構(gòu)圖:
對(duì)于一個(gè)key,先進(jìn)行一次hash操作,得到hash值h1,也即h1 = hash1(key);將得到的h1的高幾位進(jìn)行第二次hash,得到hash值h2,也即h2 = hash2(h1高幾位),通過(guò)h2能夠確定該元素的放在哪個(gè)Segment;將得到的h1進(jìn)行第三次hash,得到hash值h3,也即h3 = hash3(h1),通過(guò)h3能夠確定該元素放置在哪個(gè)HashEntry。從圖中可以看到,ConcurrentHashMap內(nèi)部分為很多個(gè)Segment,每一個(gè)Segment擁有一把鎖,然后每個(gè)Segment(繼承ReentrantLock)下面包含很多個(gè)HashEntry列表數(shù)組。對(duì)于一個(gè)key,需要經(jīng)過(guò)三次(為什么要hash三次下文會(huì)詳細(xì)講解)hash操作,才能最終定位這個(gè)元素的位置,這三次hash分別為:
初始化
先看看ConcurrentHashMap的初始化做了哪些事情,構(gòu)造函數(shù)的源碼如下:
傳入的參數(shù)有initialCapacity,loadFactor,concurrencyLevel這三個(gè)。
initialCapacity表示新創(chuàng)建的這個(gè)ConcurrentHashMap的初始容量,也就是上面的結(jié)構(gòu)圖中的Entry數(shù)量。默認(rèn)值為static final int DEFAULT_INITIAL_CAPACITY = 16;loadFactor表示負(fù)載因子,就是當(dāng)ConcurrentHashMap中的元素個(gè)數(shù)大于loadFactor * 最大容量時(shí)就需要rehash,擴(kuò)容。默認(rèn)值為static final float DEFAULT_LOAD_FACTOR = 0.75f;concurrencyLevel表示并發(fā)級(jí)別,這個(gè)值用來(lái)確定Segment的個(gè)數(shù),Segment的個(gè)數(shù)是大于等于concurrencyLevel的第一個(gè)2的n次方的數(shù)。比如,如果concurrencyLevel為12,13,14,15,16這些數(shù),則Segment的數(shù)目為16(2的4次方)。默認(rèn)值為static final int DEFAULT_CONCURRENCY_LEVEL = 16;。理想情況下ConcurrentHashMap的真正的并發(fā)訪問(wèn)量能夠達(dá)到concurrencyLevel,因?yàn)橛衏oncurrencyLevel個(gè)Segment,假如有concurrencyLevel個(gè)線程需要訪問(wèn)Map,并且需要訪問(wèn)的數(shù)據(jù)都恰好分別落在不同的Segment中,則這些線程能夠無(wú)競(jìng)爭(zhēng)地自由訪問(wèn)(因?yàn)樗麄儾恍枰?jìng)爭(zhēng)同一把鎖),達(dá)到同時(shí)訪問(wèn)的效果。這也是為什么這個(gè)參數(shù)起名為“并發(fā)級(jí)別”的原因。初始化的一些動(dòng)作:
驗(yàn)證參數(shù)的合法性,如果不合法,直接拋出異常。concurrencyLevel也就是Segment的個(gè)數(shù)不能超過(guò)規(guī)定的最大Segment的個(gè)數(shù),默認(rèn)值為static final int MAX_SEGMENTS = 1 << 16;,如果超過(guò)這個(gè)值,設(shè)置為這個(gè)值。然后使用循環(huán)找到大于等于concurrencyLevel的第一個(gè)2的n次方的數(shù)ssize,這個(gè)數(shù)就是Segment數(shù)組的大小,并記錄一共向左按位移動(dòng)的次數(shù)sshift,并令segmentShift = 32 - sshift,并且segmentMask的值等于ssize - 1,segmentMask的各個(gè)二進(jìn)制位都為1,目的是之后可以通過(guò)key的hash值與這個(gè)值做&運(yùn)算確定Segment的索引。檢查給的容量值是否大于允許的最大容量值,如果大于該值,設(shè)置為該值。最大容量值為static final int MAXIMUM_CAPACITY = 1 << 30;。然后計(jì)算每個(gè)Segment平均應(yīng)該放置多少個(gè)元素,這個(gè)值c是向上取整的值。比如初始容量為15,Segment個(gè)數(shù)為4,則每個(gè)Segment平均需要放置4個(gè)元素。最后創(chuàng)建一個(gè)Segment實(shí)例,將其當(dāng)做Segment數(shù)組的第一個(gè)元素。put操作
put操作的源碼如下:
操作步驟如下:
判斷value是否為null,如果為null,直接拋出異常。key通過(guò)一次hash運(yùn)算得到一個(gè)hash值。(這個(gè)hash運(yùn)算下文詳說(shuō))將得到hash值向右按位移動(dòng)segmentShift位,然后再與segmentMask做&運(yùn)算得到segment的索引j。在初始化的時(shí)候我們說(shuō)過(guò)segmentShift的值等于32-sshift,例如concurrencyLevel等于16,則sshift等于4,則segmentShift為28。hash值是一個(gè)32位的整數(shù),將其向右移動(dòng)28位就變成這個(gè)樣子:0000 0000 0000 0000 0000 0000 0000 xxxx,然后再用這個(gè)值與segmentMask做&運(yùn)算,也就是取最后四位的值。這個(gè)值確定Segment的索引。使用Unsafe的方式從Segment數(shù)組中獲取該索引對(duì)應(yīng)的Segment對(duì)象。向這個(gè)Segment對(duì)象中put值,這個(gè)put操作也基本是一樣的步驟(通過(guò)&運(yùn)算獲取HashEntry的索引,然后set)。get操作
get操作的源碼如下:
操作步驟為:
和put操作一樣,先通過(guò)key進(jìn)行兩次hash確定應(yīng)該去哪個(gè)Segment中取數(shù)據(jù)。使用Unsafe獲取對(duì)應(yīng)的Segment,然后再進(jìn)行一次&運(yùn)算得到HashEntry鏈表的位置,然后從鏈表頭開(kāi)始遍歷整個(gè)鏈表(因?yàn)镠ash可能會(huì)有碰撞,所以用一個(gè)鏈表保存),如果找到對(duì)應(yīng)的key,則返回對(duì)應(yīng)的value值,如果鏈表遍歷完都沒(méi)有找到對(duì)應(yīng)的key,則說(shuō)明Map中不包含該key,返回null。size操作
size操作與put和get操作最大的區(qū)別在于,size操作需要遍歷所有的Segment才能算出整個(gè)Map的大小,而put和get都只關(guān)心一個(gè)Segment。假設(shè)我們當(dāng)前遍歷的Segment為SA,那么在遍歷SA過(guò)程中其他的Segment比如SB可能會(huì)被修改,于是這一次運(yùn)算出來(lái)的size值可能并不是Map當(dāng)前的真正大小。所以一個(gè)比較簡(jiǎn)單的辦法就是計(jì)算Map大小的時(shí)候所有的Segment都Lock住,不能更新(包含put,remove等等)數(shù)據(jù),計(jì)算完之后再Unlock。這是普通人能夠想到的方案,但是牛逼的作者還有一個(gè)更好的Idea:先給3次機(jī)會(huì),不lock所有的Segment,遍歷所有Segment,累加各個(gè)Segment的大小得到整個(gè)Map的大小,如果某相鄰的兩次計(jì)算獲取的所有Segment的更新的次數(shù)(每個(gè)Segment都有一個(gè)modCount變量,這個(gè)變量在Segment中的Entry被修改時(shí)會(huì)加一,通過(guò)這個(gè)值可以得到每個(gè)Segment的更新操作的次數(shù))是一樣的,說(shuō)明計(jì)算過(guò)程中沒(méi)有更新操作,則直接返回這個(gè)值。如果這三次不加鎖的計(jì)算過(guò)程中Map的更新次數(shù)有變化,則之后的計(jì)算先對(duì)所有的Segment加鎖,再遍歷所有Segment計(jì)算Map大小,最后再解鎖所有Segment。源代碼如下:
舉個(gè)例子:
一個(gè)Map有4個(gè)Segment,標(biāo)記為S1,S2,S3,S4,現(xiàn)在我們要獲取Map的size。計(jì)算過(guò)程是這樣的:第一次計(jì)算,不對(duì)S1,S2,S3,S4加鎖,遍歷所有的Segment,假設(shè)每個(gè)Segment的大小分別為1,2,3,4,更新操作次數(shù)分別為:2,2,3,1,則這次計(jì)算可以得到Map的總大小為1+2+3+4=10,總共更新操作次數(shù)為2+2+3+1=8;第二次計(jì)算,不對(duì)S1,S2,S3,S4加鎖,遍歷所有Segment,假設(shè)這次每個(gè)Segment的大小變成了2,2,3,4,更新次數(shù)分別為3,2,3,1,因?yàn)閮纱斡?jì)算得到的Map更新次數(shù)不一致(第一次是8,第二次是9)則可以斷定這段時(shí)間Map數(shù)據(jù)被更新,則此時(shí)應(yīng)該再試一次;第三次計(jì)算,不對(duì)S1,S2,S3,S4加鎖,遍歷所有Segment,假設(shè)每個(gè)Segment的更新操作次數(shù)還是為3,2,3,1,則因?yàn)榈诙斡?jì)算和第三次計(jì)算得到的Map的更新操作的次數(shù)是一致的,就能說(shuō)明第二次計(jì)算和第三次計(jì)算這段時(shí)間內(nèi)Map數(shù)據(jù)沒(méi)有被更新,此時(shí)可以直接返回第三次計(jì)算得到的Map的大小。最壞的情況:第三次計(jì)算得到的數(shù)據(jù)更新次數(shù)和第二次也不一樣,則只能先對(duì)所有Segment加鎖再計(jì)算最后解鎖。
containsValue操作
containsValue操作采用了和size操作一樣的想法:
關(guān)于hash
大家一定還記得使用一個(gè)key定位Segment之前進(jìn)行過(guò)一次hash操作吧?這次hash的作用是什么呢?看看hash的源代碼:
源碼中的注釋是這樣的:
Applies a supplemental hash function to a given hashCode, which defends against poor quality hash functions. This is critical because ConcurrentHashMap uses power-of-two length hash tables, that otherwise encounter collisions for hashCodes that do not differ in lower or upper bits.
這里用到了Wang/Jenkins hash算法的變種,主要的目的是為了減少哈希沖突,使元素能夠均勻的分布在不同的Segment上,從而提高容器的存取效率。假如哈希的質(zhì)量差到極點(diǎn),那么所有的元素都在一個(gè)Segment中,不僅存取元素緩慢,分段鎖也會(huì)失去意義。
舉個(gè)簡(jiǎn)單的例子:
這些數(shù)字得到的hash值都是一樣的,全是15,所以如果不進(jìn)行第一次預(yù)hash,發(fā)生沖突的幾率還是很大的,但是如果我們先把上例中的二進(jìn)制數(shù)字使用hash()函數(shù)先進(jìn)行一次預(yù)hash,得到的結(jié)果是這樣的:
0100|0111|0110|0111|1101|1010|0100|11101111|0111|0100|0011|0000|0001|1011|10000111|0111|0110|1001|0100|0110|0011|11101000|0011|0000|0000|1100|1000|0001|1010
上面這個(gè)例子引用自: InfoQ可以看到每一位的數(shù)據(jù)都散開(kāi)了,并且ConcurrentHashMap中是使用預(yù)hash值的高位參與運(yùn)算的。比如之前說(shuō)的先將hash值向右按位移動(dòng)28位,再與15做&運(yùn)算,得到的結(jié)果都別為:4,15,7,8,沒(méi)有沖突!
注意事項(xiàng)
ConcurrentHashMap中的key和value值都不能為null。ConcurrentHashMap的整個(gè)操作過(guò)程中大量使用了Unsafe類來(lái)獲取Segment/HashEntry,這里Unsafe的主要作用是提供原子操作。Unsafe這個(gè)類比較恐怖,破壞力極強(qiáng),一般場(chǎng)景不建議使用,如果有興趣可以到這里做詳細(xì)的了解Java中鮮為人知的特性ConcurrentHashMap是線程安全的類并不能保證使用了ConcurrentHashMap的操作都是線程安全的!聲明
原創(chuàng)文章,轉(zhuǎn)載請(qǐng)注明出處,本文鏈接:http://qifuguang.me/2015/09/10/[Java并發(fā)包學(xué)習(xí)八]深度剖析ConcurrentHashMap/
新聞熱點(diǎn)
疑難解答
圖片精選
網(wǎng)友關(guān)注