ConcurrentHashMap原理分析

集合是編程中最經常使用的數據結構。而談到併發,幾乎老是離不開集合這類高級數據結構的支持。好比兩個線程須要同時訪問一箇中間臨界區(Queue),好比常會用緩存做爲外部文件的副本(HashMap)。這篇文章主要分析jdk1.5的3種併發集合類型(concurrent,copyonright,queue)中的ConcurrentHashMap,讓咱們從原理上細緻的瞭解它們,可以讓咱們在深度項目開發中獲益非淺。html

    經過分析Hashtable就知道,synchronized是針對整張Hash表的,即每次鎖住整張表讓線程獨佔,ConcurrentHashMap容許多個修改操做併發進行,其關鍵在於使用了鎖分離技術。它使用了多個鎖來控制對hash表的不一樣部分進行的修改。ConcurrentHashMap內部使用段(Segment)來表示這些不一樣的部分,每一個段其實就是一個小的hash table,它們有本身的鎖。只要多個修改操做發生在不一樣的段上,它們就能夠併發進行。
有些方法須要跨段,好比size()和containsValue(),它們可能須要鎖定整個表而而不只僅是某個段,這須要按順序鎖定全部段,操做完畢後,又按順序釋放全部段的鎖。這裏「按順序」是很重要的,不然極有可能出現死鎖,在ConcurrentHashMap內部,段數組是final的,而且其成員變量實際上也是final的,可是,僅僅是將數組聲明爲final的並不保證數組成員也是final的,這須要實現上的保證。這能夠確保不會出現死鎖,由於得到鎖的順序是固定的。java

 1、結構解析node

   ConcurrentHashMap和Hashtable主要區別就是圍繞着鎖的粒度以及如何鎖,能夠簡單理解成把一個大的HashTable分解成多個,造成了鎖分離。如圖:c++

而Hashtable的實現方式是---鎖整個hash表算法

2、應用場景編程

當有一個大數組時須要在多個線程共享時就能夠考慮是否把它給分層多個節點了,避免大鎖。並能夠考慮經過hash算法進行一些模塊定位。數組

其實不止用於線程,當設計數據表的事務時(事務某種意義上也是同步機制的體現),能夠把一個表當作一個須要同步的數組,若是操做的表數據太多時就能夠考慮事務分離了(這也是爲何要避免大表的出現),好比把數據進行字段拆分,水平分表等.緩存

3、源碼解讀安全

 ConcurrentHashMap中主要實體類就是三個:ConcurrentHashMap(整個Hash表),Segment(桶),HashEntry(節點),對應上面的圖能夠看出之間的關係數據結構

/** 
* The segments, each of which is a specialized hash table 
*/  
final Segment<K,V>[] segments;

不變(Immutable)和易變(Volatile)
ConcurrentHashMap徹底容許多個讀操做併發進行,讀操做並不須要加鎖。若是使用傳統的技術,如HashMap中的實現,若是容許能夠在hash鏈的中間添加或刪除元素,讀操做不加鎖將獲得不一致的數據。ConcurrentHashMap實現技術是保證HashEntry幾乎是不可變的。HashEntry表明每一個hash鏈中的一個節點,其結構以下所示:

 

1. static final class HashEntry<K,V> {  
2.     final K key;  
3.     final int hash;  
4.     volatile V value;  
5.     final HashEntry<K,V> next;  
6. }

 

能夠看到除了value不是final的,其它值都是final的,這意味着不能從hash鏈的中間或尾部添加或刪除節點,由於這須要修改next 引用值,全部的節點的修改只能從頭部開始。對於put操做,能夠一概添加到Hash鏈的頭部。可是對於remove操做,可能須要從中間刪除一個節點,這就須要將要刪除節點的前面全部節點整個複製一遍,最後一個節點指向要刪除結點的下一個結點。這在講解刪除操做時還會詳述。爲了確保讀操做可以看到最新的值,將value設置成volatile,這避免了加鎖。
其它
爲了加快定位段以及段中hash槽的速度,每一個段hash槽的的個數都是2^n,這使得經過位運算就能夠定位段和段中hash槽的位置。當併發級別爲默認值16時,也就是段的個數,hash值的高4位決定分配在哪一個段中。可是咱們也不要忘記《算法導論》給咱們的教訓:hash槽的的個數不該該是 2^n,這可能致使hash槽分配不均,這須要對hash值從新再hash一次。(這段彷佛有點多餘了 )

 

這是定位段的方法:

1. final Segment<K,V> segmentFor(int hash) {  
2.     return segments[(hash >>> segmentShift) & segmentMask];  
3. }

數據結構
關於Hash表的基礎數據結構,這裏不想作過多的探討。Hash表的一個很重要方面就是如何解決hash衝突,ConcurrentHashMap 和HashMap使用相同的方式,都是將hash值相同的節點放在一個hash鏈中。與HashMap不一樣的是,ConcurrentHashMap使用多個子Hash表,也就是段(Segment)。下面是ConcurrentHashMap的數據成員:

 

 

1. public class ConcurrentHashMap<K, V> extends AbstractMap<K, V>  
2.         implements ConcurrentMap<K, V>, Serializable {  
3.     /** 
4.      * Mask value for indexing into segments. The upper bits of a 
5.      * key's hash code are used to choose the segment. 
6.      */  
7.     final int segmentMask;  
8.   
9.     /** 
10.      * Shift value for indexing within segments. 
11.      */  
12.     final int segmentShift;  
13.   
14.     /** 
15.      * The segments, each of which is a specialized hash table 
16.      */  
17.     final Segment<K,V>[] segments;  
18. }

 

 

全部的成員都是final的,其中segmentMask和segmentShift主要是爲了定位段,參見上面的segmentFor方法。
每一個Segment至關於一個子Hash表,它的數據成員以下:

 

 

1.     static final class Segment<K,V> extends ReentrantLock implements Serializable {  
2. private static final long serialVersionUID = 2249069246763182397L;  
3.         /** 
4.          * The number of elements in this segment's region. 
5.          */  
6.         transient volatile int count;  
7.   
8.         /** 
9.          * Number of updates that alter the size of the table. This is 
10.          * used during bulk-read methods to make sure they see a 
11.          * consistent snapshot: If modCounts change during a traversal 
12.          * of segments computing size or checking containsValue, then 
13.          * we might have an inconsistent view of state so (usually) 
14.          * must retry. 
15.          */  
16.         transient int modCount;  
17.   
18.         /** 
19.          * The table is rehashed when its size exceeds this threshold. 
20.          * (The value of this field is always <tt>(int)(capacity * 
21.          * loadFactor)</tt>.) 
22.          */  
23.         transient int threshold;  
24.   
25.         /** 
26.          * The per-segment table. 
27.          */  
28.         transient volatile HashEntry<K,V>[] table;  
29.   
30.         /** 
31.          * The load factor for the hash table.  Even though this value 
32.          * is same for all segments, it is replicated to avoid needing 
33.          * links to outer object. 
34.          * @serial 
35.          */  
36.         final float loadFactor;  
37. }

 

 

count用來統計該段數據的個數,它是volatile(volatile 變量使用指南),它用來協調修改和讀取操做,以保證讀取操做可以讀取到幾乎最新的修改。協調方式是這樣的,每次修改操做作告終構上的改變,如增長/刪除節點(修改節點的值不算結構上的改變),都要寫count值,每次讀取操做開始都要讀取count的值。這利用了 Java 5中對volatile語義的加強,對同一個volatile變量的寫和讀存在happens-before關係。modCount統計段結構改變的次數,主要是爲了檢測對多個段進行遍歷過程當中某個段是否發生改變,在講述跨段操做時會還會詳述。threashold用來表示須要進行rehash的界限值。table數組存儲段中節點,每一個數組元素是個hash鏈,用HashEntry表示。table也是volatile,這使得可以讀取到最新的 table值而不須要同步。loadFactor表示負載因子。

先來看下刪除操做remove(key)。

 

 

1. public V remove(Object key) {  
2.  hash = hash(key.hashCode());  
3.     return segmentFor(hash).remove(key, hash, null);  
4. }  
整個操做是先定位到段,而後委託給段的remove操做。當多個刪除操做併發進行時,只要它們所在的段不相同,它們就能夠同時進行。下面是Segment的remove方法實現:
1. V remove(Object key, int hash, Object value) {  
2.     lock();  
3.     try {  
4.         int c = count - 1;  
5.         HashEntry<K,V>[] tab = table;  
6.         int index = hash & (tab.length - 1);  
7.         HashEntry<K,V> first = tab[index];  
8.         HashEntry<K,V> e = first;  
9.         while (e != null && (e.hash != hash || !key.equals(e.key)))  
10.             e = e.next;  
11.   
12.         V oldValue = null;  
13.         if (e != null) {  
14.             V v = e.value;  
15.             if (value == null || value.equals(v)) {  
16.                 oldValue = v;  
17.                 // All entries following removed node can stay  
18.                 // in list, but all preceding ones need to be  
19.                 // cloned.  
20.                 ++modCount;  
21.                 HashEntry<K,V> newFirst = e.next;  
22.                 *for (HashEntry<K,V> p = first; p != e; p = p.next)  
23.                     *newFirst = new HashEntry<K,V>(p.key, p.hash,  
24.                                                   newFirst, p.value);  
25.                 tab[index] = newFirst;  
26.                 count = c; // write-volatile  
27.             }  
28.         }  
29.         return oldValue;  
30.     } finally {  
31.         unlock();  
32.     }  
33. }

 

 

整個操做是在持有段鎖的狀況下執行的,空白行以前的行主要是定位到要刪除的節點e。接下來,若是不存在這個節點就直接返回null,不然就要將e前面的結點複製一遍,尾結點指向e的下一個結點。e後面的結點不須要複製,它們能夠重用。

中間那個for循環是作什麼用的呢?(*號標記)從代碼來看,就是將定位以後的全部entry克隆並拼回前面去,但有必要嗎?每次刪除一個元素就要將那以前的元素克隆一遍?這點實際上是由entry的不變性來決定的,仔細觀察entry定義,發現除了value,其餘全部屬性都是用final來修飾的,這意味着在第一次設置了next域以後便不能再改變它,取而代之的是將它以前的節點全都克隆一次。至於entry爲何要設置爲不變性,這跟不變性的訪問不須要同步從而節省時間有關

下面是個示意圖

刪除元素以前:

刪除元素3以後:

 

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

整個remove實現並不複雜,可是須要注意以下幾點。第一,當要刪除的結點存在時,刪除的最後一步操做要將count的值減一。這必須是最後一步操做,不然讀取操做可能看不到以前對段所作的結構性修改。第二,remove執行的開始就將table賦給一個局部變量tab,這是由於table是 volatile變量,讀寫volatile變量的開銷很大。編譯器也不能對volatile變量的讀寫作任何優化,直接屢次訪問非volatile實例變量沒有多大影響,編譯器會作相應優化。

接下來看put操做,一樣地put操做也是委託給段的put方法。下面是段的put方法:

 

 

1. V put(K key, int hash, V value, boolean onlyIfAbsent) {  
2.     lock();  
3.     try {  
4.         int c = count;  
5.         if (c++ > threshold) // ensure capacity  
6.             rehash();  
7.         HashEntry<K,V>[] tab = table;  
8.         int index = hash & (tab.length - 1);  
9.         HashEntry<K,V> first = tab[index];  
10.         HashEntry<K,V> e = first;  
11.         while (e != null && (e.hash != hash || !key.equals(e.key)))  
12.             e = e.next;  
13.   
14.         V oldValue;  
15.         if (e != null) {  
16.             oldValue = e.value;  
17.             if (!onlyIfAbsent)  
18.                 e.value = value;  
19.         }  
20.         else {  
21.             oldValue = null;  
22.             ++modCount;  
23.             tab[index] = new HashEntry<K,V>(key, hash, first, value);  
24.             count = c; // write-volatile  
25.         }  
26.         return oldValue;  
27.     } finally {  
28.         unlock();  
29.     }  
30. }

 

 

該方法也是在持有段鎖(鎖定整個segment)的狀況下執行的,這固然是爲了併發的安全,修改數據是不能併發進行的,必須得有個判斷是否超限的語句以確保容量不足時可以rehash。接着是找是否存在一樣一個key的結點,若是存在就直接替換這個結點的值。不然建立一個新的結點並添加到hash鏈的頭部,這時必定要修改modCount和count的值,一樣修改count的值必定要放在最後一步。put方法調用了rehash方法,reash方法實現得也很精巧,主要利用了table的大小爲2^n,這裏就不介紹了。而比較難懂的是這句int index = hash & (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方法:

 

 

1. V get(Object key, int hash) {  
2.     if (count != 0) { // read-volatile 當前桶的數據個數是否爲0 
3.         HashEntry<K,V> e = getFirst(hash);  獲得頭節點
4.         while (e != null) {  
5.             if (e.hash == hash && key.equals(e.key)) {  
6.                 V v = e.value;  
7.                 if (v != null)  
8.                     return v;  
9.                 return readValueUnderLock(e); // recheck  
10.             }  
11.             e = e.next;  
12.         }  
13.     }  
14.     return null;  
15. }

 

 

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<K,V>(key, hash, first, value),在這條語句中,HashEntry構造函數中對value的賦值以及對tab[index]的賦值可能被從新排序,這就可能致使結點的值爲空。這裏當v爲空時,多是一個線程正在改變節點,而以前的get操做都未進行鎖定,根據bernstein條件,讀後寫或寫後讀都會引發數據的不一致,因此這裏要對這個e從新上鎖再讀一遍,以保證獲得的是正確值。

 

1. V readValueUnderLock(HashEntry<K,V> e) {  
2.     lock();  
3.     try {  
4.         return e.value;  
5.     } finally {  
6.         unlock();  
7.     }  
8. }

 

另外一個操做是containsKey,這個實現就要簡單得多了,由於它不須要讀取值:

 

 

1. boolean containsKey(Object key, int hash) {  
2.     if (count != 0) { // read-volatile  
3.         HashEntry<K,V> e = getFirst(hash);  
4.         while (e != null) {  
5.             if (e.hash == hash && key.equals(e.key))  
6.                 return true;  
7.             e = e.next;  
8.         }  
9.     }  
10.     return false;  
11. }
相關文章
相關標籤/搜索