1. 程式人生 > >Java併發程式設計之ConcurrentHashMap原理分析

Java併發程式設計之ConcurrentHashMap原理分析

前言

集合是程式設計中最常用的資料結構。而談到併發,幾乎總是離不開集合這類高階資料結構的支援。比如兩個執行緒需要同時訪問一箇中間臨界區(Queue),比如常會用快取作為外部檔案的副本(HashMap)。

在tiger之前,我們使用得最多的資料結構之一就是HashMapHashtable。大家都知道,HashMap中未進行同步考慮,而Hashtable則使用了synchronized,(HashMap是執行緒非安全的,HashTable是執行緒安全的)帶來的直接影響就是可選擇,我們可以在單執行緒時使用HashMap提高效率,而多執行緒時用Hashtable來保證安全。

ConcurrentHashMap原理分析

當我們享受著jdk帶來的便利時同樣承受它帶來的不幸惡果。通過分析Hashtable就知道,synchronized是針對整張Hash表的,即每次鎖住整張表讓執行緒獨佔,安全的背後是巨大的浪費,慧眼獨具的Doug Lee立馬拿出瞭解決方案—-ConcurrentHashMap。
如圖:
這裡寫圖片描述

左邊便是Hashtable的實現方式—鎖整個hash表;而右邊則是ConcurrentHashMap的實現方式—鎖桶(或段)

官方文件中是這樣說的:

ConcurrentHashMap支援獲取的完全併發和更新的所期望可調整併發的雜湊表。此類遵守與 Hashtable 相同的功能規範,並且包括對應於 Hashtable 的每個方法的方法版本。不過,儘管所有操作都是執行緒安全的,但獲取操作不 必鎖定,並且不 支援以某種防止所有訪問的方式鎖定整個表。此類可以通過程式完全與 Hashtable 進行互操作,這取決於其執行緒安全,而與其同步細節無關。

ConcurrentHashMap將hash表分為16個桶(預設值),諸如get,put,remove等常用操作只鎖當前需要用到的桶。試想,原來只能一個執行緒進入,現在卻能同時16個寫執行緒進入(寫執行緒才需要鎖定,而讀執行緒幾乎不受限制,之後會提到),併發性的提升是顯而易見的。

更令人驚訝的是ConcurrentHashMap的讀取併發因為在讀取的大多數時候都沒有用到鎖定,所以讀取操作幾乎是完全的併發操作,而寫操作鎖定的粒度又非常細,比起之前又更加快速(這一點在桶更多時表現得更明顯些)。

ConcurrentHashMap只有在求size等操作時才需要鎖定整個表。而在迭代時,ConcurrentHashMap使用了不同於傳統集合的快速失敗迭代器的另一種迭代方式,我們稱為弱一致迭代器。在這種迭代方式中,當iterator被建立後集合再發生改變就不再是丟擲ConcurrentModificationException,取而代之的是在改變時new新的資料從而不影響原有的資料,iterator完成後再將頭指標替換為新的資料,這樣iterator執行緒可以使用原來老的資料,而寫執行緒也可以併發的完成改變,更重要的,這保證了多個執行緒併發執行的連續性和擴充套件性,是效能提升的關鍵。
接下來,讓我們看看ConcurrentHashMap中的幾個重要方法,心裡知道了實現機制後,使用起來就更加有底氣。

ConcurrentHashMap中主要實體類就是三個:
1、ConcurrentHashMap(整個Hash表);
2、Segment(桶);
3、HashEntry(節點)
對應上面的圖可以看出之間的關係。

ConcurrentHashMap允許多個修改操作併發進行,其關鍵在於使用了鎖分離技術。它使用了多個鎖來控制對hash表的不同部分進行的修改。ConcurrentHashMap內部使用段(Segment)來表示這些不同的部分,每個段其實就是一個小的hash table,它們有自己的鎖。只要多個修改操作發生在不同的段上,它們就可以併發進行。

有些方法需要跨段,比如size()和containsValue(),它們可能需要鎖定整個表而而不僅僅是某個段,這需要按順序鎖定所有段,操作完畢後,又按順序釋放所有段的鎖。這裡“按順序”是很重要的,否則極有可能出現死鎖,在ConcurrentHashMap內部,段陣列是final的,並且其成員變數實際上也是final的,但是,僅僅是將陣列宣告為final的並不保證陣列成員也是final的,這需要實現上的保證。

ConcurrentHashMap完全允許多個讀操作併發進行,讀操作並不需要加鎖。

get方法(請注意,這裡分析的方法都是針對桶的,因為ConcurrentHashMap的最大改進就是將粒度細化到了桶上),首先判斷了當前桶的資料個數是否為0,為0自然不可能get到什麼,只有返回null,這樣做避免了不必要的搜尋,也用最小的代價避免出錯。然後得到頭節點(方法將在下面涉及)之後就是根據hash和key逐個判斷是否是指定的值,如果是並且值非空就說明找到了,直接返回;程式非常簡單,但有一個令人困惑的地方,這句return readValueUnderLock(e)到底是用來幹什麼的呢?研究它的程式碼,在鎖定之後返回一個值。但這裡已經有一句V v = e.value得到了節點的值,這句return readValueUnderLock(e)是否多此一舉?事實上,這裡完全是為了併發考慮的,這裡當v為空時,可能是一個執行緒正在改變節點,而之前的get操作都未進行鎖定,根據bernstein條件,讀後寫或寫後讀都會引起資料的不一致,所以這裡要對這個e重新上鎖再讀一遍,以保證得到的是正確值,這裡不得不佩服Doug Lee思維的嚴密性。整個get操作只有很少的情況會鎖定,相對於之前的Hashtable,併發是不可避免的啊!

ConcurrentHashMap常用方法

這裡寫圖片描述
這個是原始碼中ConcurrentHashMap的繼承和實現的關係

ConcurrentHashMap具體是怎麼實現執行緒安全的呢,肯定不可能是每個方法加synchronized,那樣就變成了HashTable。
從ConcurrentHashMap程式碼中可以看出,它引入了一個“分段鎖”的概念,具體可以理解為把一個大的Map拆分成N個小的HashTable,根據key.hashCode()來決定把key放到哪個HashTable中。
在ConcurrentHashMap中,就是把Map分成了N個Segment,put和get的時候,都是現根據key.hashCode()算出放到哪個Segment中

注:ConcurrentMap
官方解釋:記憶體一致性效果:當存在其他併發 collection 時,將物件放入 ConcurrentMap 之前的執行緒中的操作 happen-before 隨後通過另一執行緒從 ConcurrentMap 中訪問或移除該元素的操作。
其實ConcurrentMap可以看做是一個快取的容器,其中包含remove、replace方法,當元素進行remove的時候,會通知阻塞的執行緒,類似於Guava中的CacheBuilder(http://blog.csdn.net/xlgen157387/article/details/47293517

原始碼再往下來的話就是一些常量:


    static final int DEFAULT_INITIAL_CAPACITY = 16; //桶的預設容量
    static final float DEFAULT_LOAD_FACTOR = 0.75f; //載入因子
    static final int DEFAULT_CONCURRENCY_LEVEL = 16; //新的空對映
    static final int MAXIMUM_CAPACITY = 1 << 30; //位移的方式,就是2的30次方1073741824,代表桶的最大值
    static final int MIN_SEGMENT_TABLE_CAPACITY = 2; //就是每個表分的最小份數是2
    static final int MAX_SEGMENTS = 1 << 16; 
    static final int RETRIES_BEFORE_LOCK = 2;

(1)刪除操作:

    public V remove(Object key) {
    int hash = hash(key.hashCode());
        return segmentFor(hash).remove(key, hash, null);
    }

根據程式碼可以知道,整個刪除操作,是根據key然後進行hashCode計算來找到具體的hash段,定位到ConcurrentHashMap的某一段,然後委託給段的remove操作。當多個刪除操作併發進行時,只要它們所在的段不相同,它們就可以同時進行。
再刪除的時候,被刪除的元素,會被放進一個待刪除的表中,等待垃圾收集器集中清理。