- 術語定義
-
線程不安全的HashMap
因為多線程環境下,使用HashMap進行put操作會引起死循環,導緻CPU使用率接近100%,是以在并發情況下不能使用HashMap,如以下代碼
final HashMap<String, String> map = new HashMap<String, String>();
Thread t = new Thread(new Runnable() {
@Override
public void run() {
for (int i = ; i < ; i++) {
new Thread(new Runnable() {
@Override
public void run() {
map.put(UUID.randomUUID().toString(), "");
}
}, "ftf" + i).start();
}
}
}, "ftf");
t.start();
t.join();
-
效率低下的HashTable容器
HashTable容器使用synchronized來保證線程安全,但線上程競争激烈的情況下HashTable的效率非常低下。因為當一個線程通路HashTable的同步方法時,其他線程通路HashTable的同步方法時,可能會進入阻塞或輪詢狀态。如線程1使用put進行添加元素,線程2不但不能使用put方法添加元素,并且也不能使用get方法來擷取元素,是以競争越激烈效率越低。
-
鎖分段技術
HashTable容器在競争激烈的并發環境下表現出效率低下的原因是所有通路HashTable的線程都必須競争同一把鎖,那假如容器裡有多把鎖,每一把鎖用于鎖容器其中一部分資料,那麼當多線程通路容器裡不同資料段的資料時,線程間就不會存在鎖競争,進而可以有效的提高并發通路效率,這就是ConcurrentHashMap所使用的鎖分段技術,
首先将資料分成一段一段的存儲,然後給每一段資料配一把鎖,當一個線程占用鎖通路其中一個段資料的時候,其他段的資料也能被其他線程通路。
-
ConcurrentHashMap的結構
我們通過ConcurrentHashMap的類圖來分析ConcurrentHashMap的結構。
ConcurrentHashMap是由Segment數組結構和HashEntry數組結構組成。Segment是一種可重入鎖ReentrantLock,在
ConcurrentHashMap裡扮演鎖的角色
,
HashEntry則用于存儲鍵值對資料
。一個ConcurrentHashMap裡包含一個Segment數組,Segment的結構和HashMap類似,是一種數組和連結清單結構, 一個Segment裡包含一個HashEntry數組,每個HashEntry是一個連結清單結構的元素, 每個Segment守護者一個HashEntry數組裡的元素
,當對HashEntry數組的資料進行修改時,必須首先獲得它對應的Segment鎖。
-
ConcurrentHashMap的初始化
ConcurrentHashMap初始化方法是通過initialCapacity,loadFactor, concurrencyLevel幾個參數來初始化segments數組,
segmentShift,段偏移量
段掩碼
segmentMask和每個segment裡的HashEntry數組 。
初始化segments數組。讓我們來看一下初始化segmentShift,segmentMask和segments數組的源代碼。
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
// Find power-of-two sizes best matching arguments
int sshift = ;
int ssize = ;
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= ;
}
segmentShift = - sshift;
segmentMask = ssize - ;
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。
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
int cap =;
while (cap < c)
cap <<=;
for (int i =; i < this.segments.length; ++i)
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進行一次再哈希。
private static int hash(int h) {
h += (h << ) ^ ;
h ^= (h >>> );
h += (h << );
h ^= (h >>> );
h += (h << ) + (h << );
return h ^ (h >>> );
}
之是以進行再哈希,其目的是為了減少哈希沖突,
使元素能夠均勻的分布在不同的Segment上,進而提高容器的存取效率
。假如哈希的品質差到極點,那麼所有的元素都在一個Segment中,不僅存取元素緩慢,分段鎖也會失去意義。我做了一個測試,不通過再哈希而直接執行哈希計算。
System.out.println(Integer.parseInt("0001111", ) & );
System.out.println(Integer.parseInt("0011111", ) & );
System.out.println(Integer.parseInt("0111111", ) & );
System.out.println(Integer.parseInt("1111111", ) & );
計算後輸出的哈希值全是15,通過這個例子可以發現如果不進行再哈希,哈希沖突會非常嚴重,因為隻要低位一樣,無論高位是什麼數,其哈希值總是一樣。我們再把上面的二進制資料進行再哈希後結果如下,為了友善閱讀,不足32位的高位補了0,每隔四位用豎線分割下。
0100|0111|0110|0111|1101|1010|0100|1110
1111|0111|0100|0011|0000|0001|1011|1000
0111|0111|0110|1001|0100|0110|0011|1110
1000|0011|0000|0000|1100|1000|0001|1010
可以發現每一位的資料都散列開了,通過這種再哈希能讓數字的每一位都能參加到哈希運算當中,進而減少哈希沖突。
ConcurrentHashMap通過以下雜湊演算法定位segment
。
final Segment<K,V> segmentFor(int hash) {
return segments[(hash >>> segmentShift) & segmentMask];
}
預設情況下segmentShift為28,segmentMask為15,再哈希後的數最大是32位二進制資料,向右無符号移動28位,意思是
讓高4位參與到hash運算中
, (hash >>> segmentShift) & segmentMask的運算結果分别是4,15,7和8,可以看到hash值沒有發生沖突。
- ConcurrentHashMap的get操作
Segment的get操作實作非常簡單和高效。先經過一次再哈希,然後使用這個哈希值通過哈希運算定位到segment,再通過雜湊演算法定位到元素,代碼如下:
public V get(Object key) {
int hash = hash(key.hashCode());
return segmentFor(hash).get(key, hash);
}
get操作的高效之處在于整個get過程不需要加鎖,除非讀到的值是空的才會加鎖重讀
,我們知道HashTable容器的get方法是需要加鎖的,那麼ConcurrentHashMap的get操作是如何做到不加鎖的呢?
原因是它的get方法裡将要使用的共享變量都定義成volatile,如用于統計目前Segement大小的count字段和用于存儲值的HashEntry的value。定義成volatile的變量,能夠線上程之間保持可見性,能夠被多線程同時讀,并且保證不會讀到過期的值,但是隻能被單線程寫(有一種情況可以被多線程寫,就是寫入的值不依賴于原值),在get操作裡隻需要讀不需要寫共享變量count和value,是以可以不用加鎖。
之是以不會讀到過期的值,是根據java記憶體模型的happen before原則,對volatile字段的寫入操作先于讀操作,即使兩個線程同時修改和擷取volatile變量,get操作也能拿到最新的值,這是用volatile替換鎖的經典應用場景。
transient volatile int count;
volatile V value;
在定位元素的代碼裡我們可以發現定位HashEntry和定位Segment的雜湊演算法雖然一樣,都與數組的長度減去一相與,但是
相與的值不一樣
,定位Segment使用的是元素的hashcode通過再哈希後得到的值的高位,而定位HashEntry直接使用的是再哈希後的值。
其目的是避免兩次哈希後的值一樣
,導緻元素雖然在Segment裡散列開了,但是卻沒有在HashEntry裡散列開。
hash >>> segmentShift) & segmentMask//定位Segment所使用的hash算法
int index = hash & (tab.length - );// 定位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 。
作者介紹
方騰飛,花名清英,淘寶資深開發工程師,關注并發程式設計,目前在廣告技術部從事無線廣告聯盟的開發和設計工作。個人部落格:http://ifeve.com 微網誌:http://weibo.com/kirals 歡迎通過我的微網誌進行技術交流。
轉載: http://www.infoq.com/cn/articles/ConcurrentHashMap