聊聊併發(四)深刻分析ConcurrentHashMap 本文是做者原創,發表於InfoQ:http://www.infoq.com/cn/articles/ConcurrentHashMapjava
術語定義 術語 英文 解釋 哈希算法 hash algorithm 是一種將任意內容的輸入轉換成相同長度輸出的加密方式,其輸出被稱爲哈希值。 哈希表 hash table 根據設定的哈希函數H(key)和處理衝突方法將一組關鍵字映象到一個有限的地址區間上,並以關鍵字在地址區間中的象做爲記錄在表中的存儲位置,這種表稱爲哈希表或散列,所得存儲位置稱爲哈希地址或散列地址。 線程不安全的HashMap 由於多線程環境下,使用Hashmap進行put操做會引發死循環,致使CPU利用率接近100%,因此在併發狀況下不能使用HashMap。算法
如如下代碼:編程
01 final HashMap<String, String> map = new HashMap<String, String>(2); 02數組
03 Thread t = new Thread(new Runnable() { 04安全
05 @Override 06多線程
07 public void run() { 08併發
09 for (int i = 0; i < 10000; i++) { 10app
11 new Thread(new Runnable() { 12dom
13 @Override 14ssh
15 public void run() { 16
17 map.put(UUID.randomUUID().toString(), ""); 18
19 } 20
21 }, "ftf" + i).start(); 22
23 } 24
25 } 26
27 }, "ftf"); 28
29 t.start(); 30
31 t.join(); 效率低下的HashTable容器 HashTable容器使用synchronized來保證線程安全,但在線程競爭激烈的狀況下HashTable的效率很是低下。由於當一個線程訪問HashTable的同步方法時,其餘線程訪問HashTable的同步方法時,可能會進入阻塞或輪詢狀態。如線程1使用put進行添加元素,線程2不但不能使用put方法添加元素,而且也不能使用get方法來獲取元素,因此競爭越激烈效率越低。
ConcurrentHashMap的鎖分段技術 HashTable容器在競爭激烈的併發環境下表現出效率低下的緣由,是由於全部訪問HashTable的線程都必須競爭同一把鎖,那假如容器裏有多把鎖,每一把鎖用於鎖容器其中一部分數據,那麼當多線程訪問容器裏不一樣數據段的數據時,線程間就不會存在鎖競爭,從而能夠有效的提升併發訪問效率,這就是ConcurrentHashMap所使用的鎖分段技術,首先將數據分紅一段一段的存儲,而後給每一段數據配一把鎖,當一個線程佔用鎖訪問其中一個段數據的時候,其餘段的數據也能被其餘線程訪問。
ConcurrentHashMap的結構 咱們經過ConcurrentHashMap的類圖來分析ConcurrentHashMap的結構。 ConcurrentHashMap類圖 ConcurrentHashMap是由Segment數組結構和HashEntry數組結構組成。Segment是一種可重入鎖ReentrantLock,在ConcurrentHashMap裏扮演鎖的角色,HashEntry則用於存儲鍵值對數據。一個ConcurrentHashMap裏包含一個Segment數組,Segment的結構和HashMap相似,是一種數組和鏈表結構, 一個Segment裏包含一個HashEntry數組,每一個HashEntry是一個鏈表結構的元素, 每一個Segment守護者一個HashEntry數組裏的元素,當對HashEntry數組的數據進行修改時,必須首先得到它對應的Segment鎖。 ConcurrentHashMap結構圖
ConcurrentHashMap的初始化 ConcurrentHashMap初始化方法是經過initialCapacity,loadFactor, concurrencyLevel幾個參數來初始化segments數組,段偏移量segmentShift,段掩碼segmentMask和每一個segment裏的HashEntry數組。
初始化segments數組。讓咱們來看一下初始化segmentShift,segmentMask和segments數組的源代碼。
01 if (concurrencyLevel > MAX_SEGMENTS) 02
03 concurrencyLevel = MAX_SEGMENTS; 04
05 // Find power-of-two sizes best matching arguments 06
07 int sshift = 0; 08
09 int ssize = 1; 10
11 while (ssize < concurrencyLevel) { 12
13 ++sshift; 14
15 ssize <<= 1; 16
17 } 18
19 segmentShift = 32 - sshift; 20
21 segmentMask = ssize - 1; 22
23 this.segments = Segment.newArray(ssize); 由上面的代碼可知segments數組的長度ssize經過concurrencyLevel計算得出。爲了能經過按位與的哈希算法來定位segments數組的索引,必須保證segments數組的長度是2的N次方(power-of-two size),因此必須計算出一個是大於或等於concurrencyLevel的最小的2的N次方值來做爲segments數組的長度。假如concurrencyLevel等於14,15或16,ssize都會等於16,即容器裏鎖的個數也是16。注意concurrencyLevel的最大大小是65535,意味着segments數組的長度最大爲65536,對應的二進制是16位。
初始化segmentShift和segmentMask。這兩個全局變量在定位segment時的哈希算法裏須要使用,sshift等於ssize從1向左移位的次數,在默認狀況下concurrencyLevel等於16,1須要向左移位移動4次,因此sshift等於4。segmentShift用於定位參與hash運算的位數,segmentShift等於32減sshift,因此等於28,這裏之因此用32是由於ConcurrentHashMap裏的hash()方法輸出的最大數是32位的,後面的測試中咱們能夠看到這點。segmentMask是哈希運算的掩碼,等於ssize減1,即15,掩碼的二進制各個位的值都是1。由於ssize的最大長度是65536,因此segmentShift最大值是16,segmentMask最大值是65535,對應的二進制是16位,每一個位都是1。
初始化每一個Segment。輸入參數initialCapacity是ConcurrentHashMap的初始化容量,loadfactor是每一個segment的負載因子,在構造方法裏須要經過這兩個參數來初始化數組中的每一個segment。
01 if (initialCapacity > MAXIMUM_CAPACITY) 02
03 initialCapacity = MAXIMUM_CAPACITY; 04
05 int c = initialCapacity / ssize; 06
07 if (c * ssize < initialCapacity) 08
09 ++c; 10
11 int cap = 1; 12
13 while (cap < c) 14
15 cap <<= 1; 16
17 for (int i = 0; i < this.segments.length; ++i) 18
19 this.segments[i] = new Segment<K,V>(cap, loadFactor); 上面代碼中的變量cap就是segment裏HashEntry數組的長度,它等於initialCapacity除以ssize的倍數c,若是c大於1,就會取大於等於c的2的N次方值,因此cap不是1,就是2的N次方。segment的容量threshold=(int)cap*loadFactor,默認狀況下initialCapacity等於16,loadfactor等於0.75,經過運算cap等於1,threshold等於零。
定位Segment 既然ConcurrentHashMap使用分段鎖Segment來保護不一樣段的數據,那麼在插入和獲取元素的時候,必須先經過哈希算法定位到Segment。能夠看到ConcurrentHashMap會首先使用Wang/Jenkins hash的變種算法對元素的hashCode進行一次再哈希。
1 private static int hash(int h) { 2
3 h += (h << 15) ^ 0xffffcd7d; h ^= (h >>> 10); 4
5 h += (h << 3); h ^= (h >>> 6); 6
7 h += (h << 2) + (h << 14); return h ^ (h >>> 16); 8
9 } 再哈希,其目的是爲了減小哈希衝突,使元素可以均勻的分佈在不一樣的Segment上,從而提升容器的存取效率。假如哈希的質量差到極點,那麼全部的元素都在一個Segment中,不只存取元素緩慢,分段鎖也會失去意義。我作了一個測試,不經過再哈希而直接執行哈希計算。
1 System.out.println(Integer.parseInt("0001111", 2) & 15); 2
3 System.out.println(Integer.parseInt("0011111", 2) & 15); 4
5 System.out.println(Integer.parseInt("0111111", 2) & 15); 6
7 System.out.println(Integer.parseInt("1111111", 2) & 15); 計算後輸出的哈希值全是15,經過這個例子能夠發現若是不進行再哈希,哈希衝突會很是嚴重,由於只要低位同樣,不管高位是什麼數,其哈希值老是同樣。咱們再把上面的二進制數據進行再哈希後結果以下,爲了方便閱讀,不足32位的高位補了0,每隔四位用豎線分割下。
1 0100|0111|0110|0111|1101|1010|0100|1110 2
3 1111|0111|0100|0011|0000|0001|1011|1000 4
5 0111|0111|0110|1001|0100|0110|0011|1110 6
7 1000|0011|0000|0000|1100|1000|0001|1010 能夠發現每一位的數據都散列開了,經過這種再哈希能讓數字的每一位都能參加到哈希運算當中,從而減小哈希衝突。ConcurrentHashMap經過如下哈希算法定位segment。
默認狀況下segmentShift爲28,segmentMask爲15,再哈希後的數最大是32位二進制數據,向右無符號移動28位,意思是讓高4位參與到hash運算中, (hash >>> segmentShift) & segmentMask的運算結果分別是4,15,7和8,能夠看到hash值沒有發生衝突。
1 final Segment<K,V> segmentFor(int hash) { 2
3 return segments[(hash >>> segmentShift) & segmentMask]; 4
5 } ConcurrentHashMap的get操做 Segment的get操做實現很是簡單和高效。先通過一次再哈希,而後使用這個哈希值經過哈希運算定位到segment,再經過哈希算法定位到元素,代碼以下:
1 public V get(Object key) { 2
3 int hash = hash(key.hashCode()); 4
5 return segmentFor(hash).get(key, hash); 6
7 } get操做的高效之處在於整個get過程不須要加鎖,除非讀到的值是空的纔會加鎖重讀,咱們知道HashTable容器的get方法是須要加鎖的,那麼ConcurrentHashMap的get操做是如何作到不加鎖的呢?緣由是它的get方法裏將要使用的共享變量都定義成volatile,如用於統計當前Segement大小的count字段和用於存儲值的HashEntry的value。定義成volatile的變量,可以在線程之間保持可見性,可以被多線程同時讀,而且保證不會讀到過時的值,可是隻能被單線程寫(有一種狀況能夠被多線程寫,就是寫入的值不依賴於原值),在get操做裏只須要讀不須要寫共享變量count和value,因此能夠不用加鎖。之因此不會讀到過時的值,是根據java內存模型的happen before原則,對volatile字段的寫入操做先於讀操做,即便兩個線程同時修改和獲取volatile變量,get操做也能拿到最新的值,這是用volatile替換鎖的經典應用場景。
1 transient volatile int count; 2
3 volatile V value; 在定位元素的代碼裏咱們能夠發現定位HashEntry和定位Segment的哈希算法雖然同樣,都與數組的長度減去一相與,可是相與的值不同,定位Segment使用的是元素的hashcode經過再哈希後獲得的值的高位,而定位HashEntry直接使用的是再哈希後的值。其目的是避免兩次哈希後的值同樣,致使元素雖然在Segment裏散列開了,可是卻沒有在HashEntry裏散列開。
1 hash >>> segmentShift) & segmentMask//定位Segment所使用的hash算法 2
3 int index = hash & (tab.length - 1);// 定位HashEntry所使用的hash算法 ConcurrentHashMap的Put操做
因爲put方法裏須要對共享變量進行寫入操做,因此爲了線程安全,在操做共享變量時必須得加鎖。Put方法首先定位到Segment,而後在Segment裏進行插入操做。插入操做須要經歷兩個步驟,第一步判斷是否須要對Segment裏的HashEntry數組進行擴容,第二步定位添加元素的位置而後放在HashEntry數組裏。
是否須要擴容。在插入元素前會先判斷Segment裏的HashEntry數組是否超過容量(threshold),若是超過閥值,數組進行擴容。值得一提的是,Segment的擴容判斷比HashMap更恰當,由於HashMap是在插入元素後判斷元素是否已經到達容量的,若是到達了就進行擴容,可是頗有可能擴容以後沒有新元素插入,這時HashMap就進行了一次無效的擴容。
如何擴容。擴容的時候首先會建立一個兩倍於原容量的數組,而後將原數組裏的元素進行再hash後插入到新的數組裏。爲了高效ConcurrentHashMap不會對整個容器進行擴容,而只對某個segment進行擴容。
ConcurrentHashMap的size操做 若是咱們要統計整個ConcurrentHashMap裏元素的大小,就必須統計全部Segment裏元素的大小後求和。Segment裏的全局變量count是一個volatile變量,那麼在多線程場景下,咱們是否是直接把全部Segment的count相加就能夠獲得整個ConcurrentHashMap大小了呢?不是的,雖然相加時能夠獲取每一個Segment的count的最新值,可是拿到以後可能累加前使用的count發生了變化,那麼統計結果就不許了。因此最安全的作法,是在統計size的時候把全部Segment的put,remove和clean方法所有鎖住,可是這種作法顯然很是低效。
由於在累加count操做過程當中,以前累加過的count發生變化的概率很是小,因此ConcurrentHashMap的作法是先嚐試2次經過不鎖住Segment的方式來統計各個Segment大小,若是統計的過程當中,容器的count發生了變化,則再採用加鎖的方式來統計全部Segment的大小。
那麼ConcurrentHashMap是如何判斷在統計的時候容器是否發生了變化呢?使用modCount變量,在put , remove和clean方法裏操做元素前都會將變量modCount進行加1,那麼在統計size先後比較modCount是否發生變化,從而得知容器的大小是否發生變化。
參考資料 JDK1.6源代碼。 《Java併發編程實踐》 Java併發編程之ConcurrentHashMap