天天看點

Java集合---ConcurrentHashMap原理分析

集合是程式設計中最常用的資料結構。而談到并發,幾乎總是離不開集合這類進階資料結構的支援。比如兩個線程需要同時通路一個中間臨界區(Queue),比如常會用緩存作為外部檔案的副本(HashMap)。這篇文章主要分析jdk1.5的3種并發集合類型(concurrent,copyonright,queue)中的ConcurrentHashMap,讓我們從原理上細緻的了解它們,能夠讓我們在深度項目開發中獲益非淺。

    通過分析Hashtable就知道,synchronized是針對整張Hash表的,即每次鎖住整張表讓線程獨占,ConcurrentHashMap允許多個修改操作并發進行,其關鍵在于使用了鎖分離技術。它使用了多個鎖來控制對hash表的不同部分進行的修改。ConcurrentHashMap内部使用段(Segment)來表示這些不同的部分,每個段其實就是一個小的hash table,它們有自己的鎖。隻要多個修改操作發生在不同的段上,它們就可以并發進行。

有些方法需要跨段,比如size()和containsValue(),它們可能需要鎖定整個表而而不僅僅是某個段,這需要按順序鎖定所有段,操作完畢後,又按順序釋放所有段的鎖。這裡“按順序”是很重要的,否則極有可能出現死鎖,在ConcurrentHashMap内部,段數組是final的,并且其成員變量實際上也是final的,但是,僅僅是将數組聲明為final的并不保證數組成員也是final的,這需要實作上的保證。這可以確定不會出現死鎖,因為獲得鎖的順序是固定的。

 一、結構解析

   ConcurrentHashMap和Hashtable主要差別就是圍繞着鎖的粒度以及如何鎖,可以簡單了解成把一個大的HashTable分解成多個,形成了鎖分離。如圖:

Java集合---ConcurrentHashMap原理分析

而Hashtable的實作方式是---鎖整個hash表

二、應用場景

當有一個大數組時需要在多個線程共享時就可以考慮是否把它給分層多個節點了,避免大鎖。并可以考慮通過hash算法進行一些子產品定位。

其實不止用于線程,當設計資料表的事務時(事務某種意義上也是同步機制的展現),可以把一個表看成一個需要同步的數組,如果操作的表資料太多時就可以考慮事務分離了(這也是為什麼要避免大表的出現),比如把資料進行字段拆分,水準分表等.

三、源碼解讀

 ConcurrentHashMap中主要實體類就是三個:ConcurrentHashMap(整個Hash表),Segment(桶),HashEntry(節點),對應上面的圖可以看出之間的關系

不變(Immutable)和易變(Volatile)

ConcurrentHashMap完全允許多個讀操作并發進行,讀操作并不需要加鎖。如果使用傳統的技術,如HashMap中的實作,如果允許可以在hash鍊的中間添加或删除元素,讀操作不加鎖将得到不一緻的資料。ConcurrentHashMap實作技術是保證HashEntry幾乎是不可變的。HashEntry代表每個hash鍊中的一個節點,其結構如下所示:

可以看到除了value不是final的,其它值都是final的,這意味着不能從hash鍊的中間或尾部添加或删除節點,因為這需要修改next 引用值,所有的節點的修改隻能從頭部開始。對于put操作,可以一律添加到Hash鍊的頭部。但是對于remove操作,可能需要從中間删除一個節點,這就需要将要删除節點的前面所有節點整個複制一遍,最後一個節點指向要删除結點的下一個結點。這在講解删除操作時還會詳述。為了確定讀操作能夠看到最新的值,将value設定成volatile,這避免了加鎖。

其它

為了加快定位段以及段中hash槽的速度,每個段hash槽的的個數都是2^n,這使得通過位運算就可以定位段和段中hash槽的位置。當并發級别為預設值16時,也就是段的個數,hash值的高4位決定配置設定在哪個段中。但是我們也不要忘記《算法導論》給我們的教訓:hash槽的的個數不應該是 2^n,這可能導緻hash槽配置設定不均,這需要對hash值重新再hash一次。(這段似乎有點多餘了 )

這是定位段的方法:

資料結構

關于Hash表的基礎資料結構,這裡不想做過多的探讨。Hash表的一個很重要方面就是如何解決hash沖突,ConcurrentHashMap 和HashMap使用相同的方式,都是将hash值相同的節點放在一個hash鍊中。與HashMap不同的是,ConcurrentHashMap使用多個子Hash表,也就是段(Segment)。下面是ConcurrentHashMap的資料成員:

<a target="_blank"></a>

所有的成員都是final的,其中segmentMask和segmentShift主要是為了定位段,參見上面的segmentFor方法。

每個Segment相當于一個子Hash表,它的資料成員如下:

先來看下删除操作remove(key)。

整個操作是在持有段鎖的情況下執行的,空白行之前的行主要是定位到要删除的節點e。接下來,如果不存在這個節點就直接傳回null,否則就要将e前面的結點複制一遍,尾結點指向e的下一個結點。e後面的結點不需要複制,它們可以重用。

中間那個for循環是做什麼用的呢?(*号标記)從代碼來看,就是将定位之後的所有entry克隆并拼回前面去,但有必要嗎?每次删除一個元素就要将那之前的元素克隆一遍?這點其實是由entry的不變性來決定的,仔細觀察entry定義,發現除了value,其他所有屬性都是用final來修飾的,這意味着在第一次設定了next域之後便不能再改變它,取而代之的是将它之前的節點全都克隆一次。至于entry為什麼要設定為不變性,這跟不變性的通路不需要同步進而節省時間有關

下面是個示意圖

删除元素之前:

Java集合---ConcurrentHashMap原理分析

删除元素3之後:

Java集合---ConcurrentHashMap原理分析

第二個圖其實有點問題,複制的結點中應該是值為2的結點在前面,值為1的結點在後面,也就是剛好和原來結點順序相反,還好這不影響我們的讨論。

整個remove實作并不複雜,但是需要注意如下幾點。第一,當要删除的結點存在時,删除的最後一步操作要将count的值減一。這必須是最後一步操作,否則讀取操作可能看不到之前對段所做的結構性修改。第二,remove執行的開始就将table賦給一個局部變量tab,這是因為table是 volatile變量,讀寫volatile變量的開銷很大。編譯器也不能對volatile變量的讀寫做任何優化,直接多次通路非volatile執行個體變量沒有多大影響,編譯器會做相應優化。

接下來看put操作,同樣地put操作也是委托給段的put方法。下面是段的put方法:

該方法也是在持有段鎖(鎖定整個segment)的情況下執行的,這當然是為了并發的安全,修改資料是不能并發進行的,必須得有個判斷是否超限的語句以確定容量不足時能夠rehash。接着是找是否存在同樣一個key的結點,如果存在就直接替換這個結點的值。否則建立一個新的結點并添加到hash鍊的頭部,這時一定要修改modCount和count的值,同樣修改count的值一定要放在最後一步。put方法調用了rehash方法,reash方法實作得也很精巧,主要利用了table的大小為2^n,這裡就不介紹了。而比較難懂的是這句int index = hash &amp; (tab.length - 1),原來segment裡面才是真正的hashtable,即每個segment是一個傳統意義上的hashtable,如上圖,從兩者的結構就可以看出差別,這裡就是找出需要的entry在table的哪一個位置,之後得到的entry就是這個鍊的第一個節點,如果e!=null,說明找到了,這是就要替換節點的值(onlyIfAbsent == false),否則,我們需要new一個entry,它的後繼是first,而讓tab[index]指向它,什麼意思呢?實際上就是将這個新entry插入到鍊頭,剩下的就非常容易了解了

修改操作還有putAll和replace。putAll就是多次調用put方法,沒什麼好說的。replace甚至不用做結構上的更改,實作要比put和delete要簡單得多,了解了put和delete,了解replace就不在話下了,這裡也不介紹了。

擷取操作

首先看下get操作,同樣ConcurrentHashMap的get操作是直接委托給Segment的get方法,直接看Segment的get方法:

get操作不需要鎖。第一步是通路count變量,這是一個volatile變量,由于所有的修改操作在進行結構修改時都會在最後一步寫count 變量,通過這種機制保證get操作能夠得到幾乎最新的結構更新。對于非結構更新,也就是結點值的改變,由于HashEntry的value變量是 volatile的,也能保證讀取到最新的值。接下來就是根據hash和key對hash鍊進行周遊找到要擷取的結點,如果沒有找到,直接訪回null。對hash鍊進行周遊不需要加鎖的原因在于鍊指針next是final的。但是頭指針卻不是final的,這是通過getFirst(hash)方法傳回,也就是存在 table數組中的值。這使得getFirst(hash)可能傳回過時的頭結點,例如,當執行get方法時,剛執行完getFirst(hash)之後,另一個線程執行了删除操作并更新頭結點,這就導緻get方法中傳回的頭結點不是最新的。這是可以允許,通過對count變量的協調機制,get能讀取到幾乎最新的資料,雖然可能不是最新的。要得到最新的資料,隻有采用完全的同步。

最後,如果找到了所求的結點,判斷它的值如果非空就直接傳回,否則在有鎖的狀态下再讀一次。這似乎有些費解,理論上結點的值不可能為空,這是因為 put的時候就進行了判斷,如果為空就要抛NullPointerException。空值的唯一源頭就是HashEntry中的預設值,因為 HashEntry中的value不是final的,非同步讀取有可能讀取到空值。仔細看下put操作的語句:tab[index] = new HashEntry&lt;K,V&gt;(key, hash, first, value),在這條語句中,HashEntry構造函數中對value的指派以及對tab[index]的指派可能被重新排序,這就可能導緻結點的值為空。這裡當v為空時,可能是一個線程正在改變節點,而之前的get操作都未進行鎖定,根據bernstein條件,讀後寫或寫後讀都會引起資料的不一緻,是以這裡要對這個e重新上鎖再讀一遍,以保證得到的是正确值。

另一個操作是containsKey,這個實作就要簡單得多了,因為它不需要讀取值:

 優秀博文:

<a target="_blank" href="http://www.cnblogs.com/yydcdut/p/3959815.html">ConcurrentHashMap</a>