Java集合 4:ConcurrentHashMap的原理

在ConcurrentHashMap中,鍵值都不允許爲null;

ConcurrentHashMap使用分段鎖技術,把區間按併發級別分成了若干個sagement,給每個segment配鎖,當線程佔用鎖訪問其中一個segment時其他segment內數據也能被其他線程訪問,讀操作大部分時間不用上鎖,只有在size等操作時才需要鎖住整個hash表。

ConcurrentHashMap使用ReentrantLock來保證線程安全,因爲segment繼承自ReentrantLock。

static final class HashEntry<K,V> { 
       final K key;                       // 聲明 key 爲 final 型
       final int hash;                   // 聲明 hash 值爲 final 型 
       volatile V value;                 // 聲明 value 爲 volatile 型
       final HashEntry<K,V> next;      // 聲明 next 爲 final 型 
}

HashEntry 用來封裝散列映射表中的鍵值對。在 HashEntry 類中,key,hash 和 next 域都被聲明爲 final 型,value 域被聲明爲 volatile 型。這意味着不能從hash鏈的中間或者尾部添加或刪除節點,因爲這需要修改next,爲了保證讀操作看到最新的值,將value設定爲volatile,這避免了加鎖。

ConcurrentHashMap的結構示意圖:

å¾ 3.ConcurrentHashMap çç»æ示æå¾ï¼

用分離鎖實現多個線程間的併發寫操作

在 ConcurrentHashMap 中,線程對映射表做讀操作時,一般情況下不需要加鎖就可以完成,對容器做結構性修改的操作才需要加鎖。下面以 put 操作爲例說明對 ConcurrentHashMap 做結構性修改的過程。

首先,根據 key 計算出對應的 hash 值:

public V put(K key, V value) { 
       if (value == null)          //ConcurrentHashMap 中不允許用 null 作爲映射值
           throw new NullPointerException(); 
       int hash = hash(key.hashCode());        // 計算鍵對應的散列碼
       // 根據散列碼找到對應的 Segment 
       return segmentFor(hash).put(key, hash, value, false); 
}
//xwxw

根據 hash 值找到對應的Segment 對象:

/** 
    * 使用 key 的散列碼來得到 segments 數組中對應的 Segment 
    */ 
final Segment<K,V> segmentFor(int hash) { 
   // 將散列值右移 segmentShift 個位,並在高位填充 0 
   // 然後把得到的值與 segmentMask 相“與”
// 從而得到 hash 值對應的 segments 數組的下標值
// 最後根據下標值返回散列碼對應的 Segment 對象
       return segments[(hash >>> segmentShift) & segmentMask]; 
}
//xwxw

最後,在這個 Segment 中執行具體的 put 操作:

V put(K key, int hash, V value, boolean onlyIfAbsent) { 
           lock();  // 加鎖,這裏是鎖定某個 Segment 對象而非整個 ConcurrentHashMap 
           try { 
               int c = count; 
 
               if (c++ > threshold)     // 如果超過再散列的閾值
                   rehash();              // 執行再散列,table 數組的長度將擴充一倍
 
               HashEntry<K,V>[] tab = table; 
               // 把散列碼值與 table 數組的長度減 1 的值相“與”
               // 得到該散列碼對應的 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;    // 設置 value 值
               } 
               else {                        // 鍵 / 值對不存在 
                   oldValue = null; 
                   ++modCount;         // 要添加新節點到鏈表中,所以 modCont 要加 1  
                   // 創建新節點,並添加到鏈表的頭部 
                   tab[index] = new HashEntry<K,V>(key, hash, first, value); 
                   count = c;               // 寫 count 變量
               } 
               return oldValue; 
           } finally { 
               unlock();                     // 解鎖
           } 
       }
//xwxw

注意:這裏的加鎖操作是針對(鍵的 hash 值對應的)某個具體的 Segment,鎖定的是該 Segment 而不是整個 ConcurrentHashMap。因爲插入鍵 / 值對操作只是在這個 Segment 包含的某個桶中完成,不需要鎖定整個ConcurrentHashMap。此時,其他寫線程對另外 15 個Segment 的加鎖並不會因爲當前線程對這個 Segment 的加鎖而阻塞。同時,所有讀線程幾乎不會因本線程的加鎖而阻塞(除非讀線程剛好讀到這個 Segment 中某個 HashEntry 的 value 域的值爲 null,此時需要加鎖後重新讀取該值)。

相比較於 HashTable 和由同步包裝器包裝的 HashMap每次只能有一個線程執行讀或寫操作,ConcurrentHashMap 在併發訪問性能上有了質的提高。在理想狀態下,ConcurrentHashMap 可以支持 16 個線程執行併發寫操作(如果併發級別設置爲 16),及任意數量線程的讀操作。

接下來看看remove操作

V remove(Object key, int hash, Object value) { 
           lock();         // 加鎖
           try{ 
               int c = count - 1; 
               HashEntry<K,V>[] tab = table; 
               // 根據散列碼找到 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; 
                       ++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;      // 寫 count 變量
                   } 
               } 
               return oldValue; 
           } finally{ 
               unlock();               // 解鎖
           } 
       }

和 get 操作一樣,首先根據散列碼找到具體的鏈表;然後遍歷這個鏈表找到要刪除的節點;最後把待刪除節點之後的所有節點原樣保留在新鏈表中,把待刪除節點之前的每個節點克隆到新鏈表中。下面通過圖例來說明 remove 操作。假設寫線程執行 remove 操作,要刪除鏈表的 C 節點,另一個讀線程同時正在遍歷這個鏈表。

刪除之前的原鏈表:

圖 4. 執行刪除之前的原鏈表:

刪除之後的新鏈表

圖 5. 執行刪除之後的新鏈表

從上圖可以看出,刪除節點 C 之後的所有節點原樣保留到新鏈表中;刪除節點 C 之前的每個節點被克隆到新鏈表中,注意:它們在新鏈表中的鏈接順序被反轉了。

在執行 remove 操作時,原始鏈表並沒有被修改,也就是說:讀線程不會受同時執行 remove 操作的併發寫線程的干擾。

綜合上面的分析我們可以看出,寫線程對某個鏈表的結構性修改不會影響其他的併發讀線程對這個鏈表的遍歷訪問。

用 Volatile 變量協調讀寫線程間的內存可見性

由於內存可見性問題,未正確同步的情況下,寫線程寫入的值可能並不爲後續的讀線程可見。

下面以寫線程 M 和讀線程 N 來說明 ConcurrentHashMap 如何協調讀 / 寫線程間的內存可見性問題。

 協調讀 - 寫線程間的內存可見性的示意圖:

圖 6. 協調讀 - 寫線程間的內存可見性的示意圖:

假設線程 M 在寫入了 volatile 型變量 count 後,線程 N 讀取了這個 volatile 型變量 count。

根據 happens-before 關係法則中的程序次序法則,A appens-before 於 B,C happens-before D。

根據 Volatile 變量法則,B happens-before C。

根據傳遞性,連接上面三個 happens-before 關係得到:A appens-before 於 B; B appens-before C;C happens-before D。也就是說:寫線程 M 對鏈表做的結構性修改,在讀線程 N 讀取了同一個 volatile 變量後,對線程 N 也是可見的了。

雖然線程 N 是在未加鎖的情況下訪問鏈表。Java 的內存模型可以保證:只要之前對鏈表做結構性修改操作的寫線程 M 在退出寫方法前寫 volatile 型變量 count,讀線程 N 在讀取這個 volatile 型變量 count 後,就一定能“看到”這些修改。

ConcurrentHashMap 中,每個 Segment 都有一個變量 count。它用來統計 Segment 中的 HashEntry 的個數。這個變量被聲明爲 volatile。

Count 變量的聲明:

transient volatile int count;

在 ConcurrentHashMap 中,所有執行寫操作的方法(put, remove, clear),在對鏈表做結構性修改之後,在退出寫方法前都會去寫這個 count 變量。所有未加鎖的讀操作(get, contains, containsKey)在讀方法中,都會首先去讀取這個 count 變量。所有不加鎖讀方法,在進入讀方法時,首先都會去讀這個 count 變量。比如get 方法,具體操作你可以在源碼中看到

根據 Java 內存模型,對 同一個 volatile 變量的寫 / 讀操作可以確保:寫線程寫入的值,能夠被之後未加鎖的讀線程“看到”。

這個特性和前面介紹的 HashEntry 對象的不變性相結合,使得在 ConcurrentHashMap 中,讀線程在讀取散列表時,基本不需要加鎖就能成功獲得需要的值。這兩個特性相配合,不僅減少了請求同一個鎖的頻率(讀操作一般不需要加鎖就能夠成功獲得值),也減少了持有同一個鎖的時間(只有讀到 value 域的值爲 null 時 , 讀線程才需要加鎖後重讀)。

 

ConcurrentHashMap 的高併發性來自:

1,用分離鎖實現多個線程間的更深層次的共享訪問。

2,用HashEntry對象唉的不變性來降低執行讀操作的線程在遍歷鏈表期間對加鎖的需求。

3,通過對同一個volatile的寫讀操作,協調不同線程間讀寫操作的內存可見性。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章