JDK原始碼學習——ConcurrentHashMap
注:內容主要參考《java併發程式設計的藝術》一書
為什麼要使用ConcurrentHashMap?
在併發程式設計的時候使用HashMap容易導致程式死迴圈,而使用HashTable效率比較低
(1)執行緒不安全的Hashmap
為什麼執行緒不安全?
a) hashmap是採用鏈地址法解決hash衝突的,底層由陣列和連結串列構成。當多個執行緒對同一個雜湊對映進行操作的時候,也就是對同一個陣列位置進行修改,一個執行緒代用addEntry()方法新增一個節點,然後第二個執行緒也新增一個節點,這時第二個執行緒的操作就會覆蓋掉第一個執行緒的操作,造成資料丟失。
b) 除了資料可能丟失,Hashmap在併發執行put操作的時候還可能造成死迴圈。因為當多執行緒執行put操作的時候,Entry陣列容量可能不夠,就會進行擴容。假設其中一個雜湊地址上有A和B兩個元素。一個執行緒發現不夠,準備擴容,就在這個時候,第二個執行緒介入了,第二個執行緒也發現容量不夠開始擴容,擴大為原來的兩倍,然後把A元素複製到新hash表中,接著把B元素查到連結串列的頭部。這些完成之後,第一個執行緒又開始操作,導致A的next節點指向B元素,B的next節點指向A,形成了一個環形資料結構。一旦形成環形資料結構,Entry元素的的next節點永遠不為空,就會出現死迴圈獲取Entry元素的情況。
2)效率低下的hashtable
HashTable使用synchronized來保證執行緒安全,線上程競爭激烈的情況下HashTable的效率很低。
為什麼效率低下?
因為當一個執行緒訪問HashTable的同步方法,其他執行緒也訪問HashTable的同步方法時,就會進入阻塞或者輪詢狀態。比如,當執行緒1使用put方法新增元素的時候,執行緒2不僅不能使用put方法來新增元素,也不能使用get方法獲取元素,於是競爭越激烈效率越低。
3)ConcurrentHashMap如何提高併發訪問效率
HashTable在併發條件下訪問效率低的原因是所有訪問hashtable的執行緒都去競爭同意把鎖。假如容器中有多把鎖,每一把鎖用來鎖住容器裡的一部分資料,當多個執行緒訪問不同的資料段的資料時,執行緒之間就不會發生鎖競爭的現象了,可以有效地提升併發訪問效率。ConcurrentHashMap就是這麼做的,叫做鎖分段技術。首先把資料分為一段一段的進行儲存,然後給以一個數據段配一把鎖,當一個執行緒訪問一個數據段的資料時,其他執行緒可以訪問其他資料段的資料。
4)底層實現
ConcurrentHashMap的底層由Segment陣列和HashEntry陣列構成的。
static final class Segment<K,V> extends ReentrantLock implements Serializable
Segment繼承了RetrantLock,所以每一個segment都是一個可重入鎖。每一個segment對應一個HashEntry陣列,每一個HashEntry對應著一個連結串列結構的元素。這樣每一個segment就守護者一個HashEntry陣列中的元素,想要更改這些元素,首先要獲得它對應的Segment鎖。
4.1)定位segment
既然使用分段鎖segment來保護不同資料段,那麼在插入和獲取元素的時候,要首先通過雜湊演算法定位到Segment。在ConcurrentHashMap中會首先對元素的雜湊值進行一次再雜湊。再雜湊的目的就是為了減少雜湊衝突,是元素能均勻地分佈到不同的Segment上,提升容器的存取的效率。如果沒有再雜湊,假如雜湊的質量很差,所有的元素都在一個Segment上,那麼所有的執行緒都會競爭同一把Segment鎖,不僅存取元素的速度很慢,分段鎖也失去了意義。
在ConcurrentHashMap中我們主要關注get和put操作。
4.2)get操作
get操作的過程是:先經過一次再雜湊操作,使用這個雜湊值經過雜湊運算定位到Segment,在通過雜湊運算定位到需要的元素。
get操作比較高效的地方在於:在整個get過程不需要加鎖,除非讀到的值是空,才需要加鎖重新讀取。在HashTable中get操作是需要加鎖的所以效率比較低。
如何實現不加鎖的?
不加鎖的原因是在get方法裡把將要使用的共享變數定義成volatile變數,比如用來統計segment大小的count和HashEntry裡用來儲存值的value。定義成volatile的變數,能夠線上程之間保持可見性,當多執行緒同時讀的時候,保證不會拿到過期的值。
為什麼不會讀到過期的值?
因為根據java記憶體模型的happen-before原則,volatile變數的寫操作優先於讀操作,即使兩個兩個執行緒同時修改和讀取volatile變數,get操作也能拿到最新的值。這是用volatile來替換鎖的一個經典場景。
4.3)put操作
在put方法裡需要對共享變數進行寫入操作,為了保證執行緒安全,需要進行加鎖。put方法首先定位到Segment,然後在Segment裡記性插入操作。插入操作主要有兩個步驟:
第一步:判斷Segment裡的HashEntry陣列是否需要進行擴容
是否需要擴容?
Segment的擴容判斷相比Hashmap更合適,因為Hashmap中是先插入元素再判斷元素是否達到容量,如果擴容之後沒有元素插入,這次擴容就沒什麼用了。 而Segment擴容是先判斷容量是否超過閾值,再進行插入操作,可以避免這個問題
如何擴容?
擴容的時候,先建立一個大小是原來兩倍的陣列,然後把原陣列的元素經過再雜湊之後複製過來。為了提高效率,並不會對整個容器進行擴容,而是隻針對某個Segment進行擴容。
第二步:定位元素需要插入的位置,然後把元素放到HashEntry數組裡面 。