ConcurrentHashMap 實現原理和原始碼分析

1Vincent發表於2018-04-09

前言

       執行緒不安全的HashMap,使用Hashmap進行put操作會引起死迴圈,導致CPU利用率接近100%,詳細解析 HashMap 實現原理和原始碼分析
     效率低下的HashTable, 使用HashTable的同步方法時,其他執行緒訪問HashTable的同步方法時,可能會進入阻塞或輪詢狀態 HashMap 和 HashTable 區別;    
     ConcurrentHashMap 實現分段鎖, 使用到鎖(ReentrantLock),Volatile,final等手段來保證happens-before規則。
       Volatile 詳解 Volatile 關鍵字
       happens-before         這是一個多執行緒之間記憶體可見性(Visibility),Java編譯器的重排序(Reording)操作有可能導致執行順序和程式碼順序不一致。例如:int a =1 ; int b=2; a = 3; b = 4; 可以允許 b = 4 先於 a = 3;被CPU執行,和程式碼中的順序不一致。從執行緒工作記憶體寫回主存時順序無法保證。     加lock,保證happens-before    

結構

HashMap結構圖

ConcurrentHashMap 結構圖


 static final class HashEntry<K,V> {  
     final K key;  
     final int hash;  
     volatile V value;  
     final HashEntry<K,V> next;  
 } 

static final class Segment<K,V> extends ReentrantLock implements Serializableq	

每個segment使用物件自身的鎖來實現。只有對全域性需要改變時鎖定的是所有的segment。

這種做法,就稱之為“分離鎖(lock striping)
分拆鎖(lock spliting)就是若原先的程式中多處邏輯都採用同一個鎖,但各個邏輯之間又相互獨立,就可以拆(Spliting)為使用多個鎖,每個鎖守護不同的邏輯。
分拆鎖有時候可以被擴充套件,分成可大可小加鎖塊的集合,並且它們歸屬於相互獨立的物件,這樣的情況就是分離鎖(lock striping)。(摘自《Java併發程式設計實踐》)

初始化

它把區間按照併發級別(concurrentLevel),分成了若干個segment。預設情況下內部按併發級別為16來建立。對於每個segment的容量,預設情況也是16。concurrentLevel,segment 可以通過建構函式設定的。通過按位與的雜湊演算法來定位segments陣列的索引,必須保證segments陣列的長度是2的N次方(power-of-two size),所以必須計算出一個是大於或等於concurrencyLevel的最小的2的N次方值來作為segments陣列的長度。

定位Segment

ConcurrentHashMap會首先使用Wang/Jenkins hash的變種演算法對元素的hashCode進行一次再雜湊,其目的是為了減少雜湊衝突,使元素能夠均勻的分佈在不同的Segment上,從而提高容器的存取效率。預設情況下segmentShift為28,segmentMask為15,再雜湊後的數最大是32位二進位制資料,向右無符號移動28位,意思是讓高4位參與到hash運算中, (hash >>> segmentShift) & segmentMask的運算結果分別是4,15,7和8,可以看到hash值沒有發生衝突

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

remove操作

不同segment,可以併發操作

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

V remove(Object key, int hash, Object value) {  
     lock();  
     try {  
         int c = count - 1;  
         HashEntry<K,V>[] tab = table;  
         int index = hash & (tab.length - 1);  
         HashEntry<K,V> first = tab[index];  
         HashEntry<K,V> e = first;  
         while (e != null && (e.hash != hash || !key.equals(e.key)))  
             e = e.next;  
         V oldValue = null;  
         if (e != null) {  
             V v = e.value;  
             if (value == null || value.equals(v)) {  
                 oldValue = v;  

                 // All entries following removed node can stay  
                 // in list, but all preceding ones need to be  
                 // cloned.  
                 ++modCount;  
                 HashEntry<K,V> newFirst = e.next;  
                 for (HashEntry<K,V> p = first; p != e; p = p.next)  
                     newFirst = new HashEntry<K,V>(p.key, p.hash,  
                                                  newFirst, p.value);  
                 tab[index] = newFirst;  
                 count = c; // write-volatile  
             }  
         }  
         return oldValue;  
     } finally {  
         unlock();  
     }  
}
如果節點不存在就直接返回null,否則就要將e前面的結點複製一遍,尾結點指向e的下一個結點。因為entry除了value是volatile屬性,其他都是final修飾的,所以不能重新對next域賦值,只能重新clone一次。至於entry為什麼要設定為不變性,這跟不變性的訪問不需要同步從而節省時間有關


get操作

Segment的get方法

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

V get(Object key, int hash) {  
     if (count != 0) { // read-volatile 當前桶的資料個數是否為0 
         HashEntry<K,V> e = getFirst(hash);  得到頭節點
         while (e != null) {  
             if (e.hash == hash && key.equals(e.key)) {  
                 V v = e.value;  
                 if (v != null)  
                     return v;  
                 return readValueUnderLock(e); // recheck  
             }  
             e = e.next;  
         }  
     }  
     return null;  
 } 
get操作的高效之處在於整個get過程不需要加鎖,除非讀到的值是空的才會加鎖重讀。
我們知道HashTable容器的get方法是需要加鎖的,原因是它的get方法裡將要使用的共享變數都定義成volatile,在get操作裡只需要讀不需要寫共享變數count和value,所以可以不用加鎖。之所以不會讀到過期的值,是根據java記憶體模型的happen before原則,對volatile欄位的寫入操作先於讀操作,即使兩個執行緒同時修改和獲取volatile變數,get操作也能拿到最新的值,這是用volatile替換鎖的經典應用場景。
理論上結點的值不可能為空,這是因為 put的時候,如果為空就要拋NullPointerException。空值是因為put,remove操作時候,tab[index] = new HashEntry<K,V>(key, hash, first, value)重新克隆了節點前的資料。
 V readValueUnderLock(HashEntry<K,V> e) {  
     lock();  
     try {  
         return e.value;  
     } finally {  
         unlock();  
     }  
 }

put操作

Segment的put方法。

 V put(K key, int hash, V value, boolean onlyIfAbsent) {  
     lock();  
     try {  
         int c = count;  
         if (c++ > threshold) // ensure capacity  
             rehash();  
         HashEntry<K,V>[] tab = table;  
         int index = hash & (tab.length - 1);  
         HashEntry<K,V> first = tab[index];  
         HashEntry<K,V> e = first;  
         while (e != null && (e.hash != hash || !key.equals(e.key)))  
             e = e.next;  
         V oldValue;  
         if (e != null) {  
             oldValue = e.value;  
             if (!onlyIfAbsent)  
                 e.value = value;  
         }  
         else {  
             oldValue = null;  
             ++modCount;  
             tab[index] = new HashEntry<K,V>(key, hash, first, value);  
             count = c; // write-volatile  
         }  
         return oldValue;  
     } finally {  
         unlock();  
     }  
 }
第一步判斷是否需要對Segment裡的HashEntry陣列進行擴容,第二步定位新增元素的位置然後放在HashEntry陣列裡。
在插入元素前會先判斷Segment裡的HashEntry陣列是否超過容量(threshold),如果超過閥值,陣列進行擴容。值得一提的是,Segment的擴容判斷比HashMap更恰當,因為HashMap是在插入元素後判斷元素是否已經到達容量的,如果到達了就進行擴容,但是很有可能擴容之後沒有新元素插入,這時HashMap就進行了一次無效的擴容。
擴容的時候首先會建立一個兩倍於原容量的陣列,然後將原陣列裡的元素進行再hash後插入到新的陣列裡。為了高效ConcurrentHashMap不會對整個容器進行擴容,而只對某個segment進行擴容。

containsKey操作

 boolean containsKey(Object key, int hash) {  
     if (count != 0) { // read-volatile  
         HashEntry<K,V> e = getFirst(hash);  
         while (e != null) {  
             if (e.hash == hash && key.equals(e.key))  
                 returntrue;  
             e = e.next;  
         }  
     }  
     returnfalse;  
 } 

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是否發生變化,從而得知容器的大小是否發生變化。


參考資料

  1. 《Java併發程式設計實踐》
  2. 深入分析ConcurrentHashMap


相關文章