探索 ConcurrentHashMap 高併發性的實現機制

feng27156發表於2014-02-12

簡介

ConcurrentHashMap 是 util.concurrent 包的重要成員。本文將結合 Java 記憶體模型,分析 JDK 原始碼,探索 ConcurrentHashMap 高併發的具體實現機制。

由於 ConcurrentHashMap 的原始碼實現依賴於 Java 記憶體模型,所以閱讀本文需要讀者瞭解 Java 記憶體模型。同時,ConcurrentHashMap 的原始碼會涉及到雜湊演算法和連結串列資料結構,所以,讀者需要對雜湊演算法和基於連結串列的資料結構有所瞭解。

Java 記憶體模型

由於 ConcurrentHashMap 是建立在 Java 記憶體模型基礎上的,為了更好的理解 ConcurrentHashMap,讓我們首先來了解一下 Java 的記憶體模型。

Java 語言的記憶體模型由一些規則組成,這些規則確定執行緒對記憶體的訪問如何排序以及何時可以確保它們對執行緒是可見的。下面我們將分別介紹 Java 記憶體模型的重排序,記憶體可見性和 happens-before 關係。

重排序

記憶體模型描述了程式的可能行為。具體的編譯器實現可以產生任意它喜歡的程式碼 -- 只要所有執行這些程式碼產生的結果,能夠和記憶體模型預測的結果保持一致。這為編譯器實現者提供了很大的自由,包括操作的重排序。

編譯器生成指令的次序,可以不同於原始碼所暗示的“顯然”版本。重排序後的指令,對於優化執行以及成熟的全域性暫存器分配演算法的使用,都是大有脾益的,它使得程式在計算效能上有了很大的提升。

重排序型別包括:

  • 編譯器生成指令的次序,可以不同於原始碼所暗示的“顯然”版本。
  • 處理器可以亂序或者並行的執行指令。
  • 快取會改變寫入提交到主記憶體的變數的次序。

記憶體可見性

由於現代可共享記憶體的多處理器架構可能導致一個執行緒無法馬上(甚至永遠)看到另一個執行緒操作產生的結果。所以 Java 記憶體模型規定了 JVM 的一種最小保證:什麼時候寫入一個變數對其他執行緒可見。

在現代可共享記憶體的多處理器體系結構中每個處理器都有自己的快取,並週期性的與主記憶體協調一致。假設執行緒 A 寫入一個變數值 V,隨後另一個執行緒 B 讀取變數 V 的值,在下列情況下,執行緒 B 讀取的值可能不是執行緒 A 寫入的最新值:

  • 執行執行緒 A 的處理器把變數 V 快取到暫存器中。
  • 執行執行緒 A 的處理器把變數 V 快取到自己的快取中,但還沒有同步重新整理到主記憶體中去。
  • 執行執行緒 B 的處理器的快取中有變數 V 的舊值。

Happens-before 關係

happens-before 關係保證:如果執行緒 A 與執行緒 B 滿足 happens-before 關係,則執行緒 A 執行動作的結果對於執行緒 B 是可見的。如果兩個操作未按 happens-before 排序,JVM 將可以對他們任意重排序。

下面介紹幾個與理解 ConcurrentHashMap 有關的 happens-before 關係法則:

  1. 程式次序法則:如果在程式中,所有動作 A 出現在動作 B 之前,則執行緒中的每動作 A 都 happens-before 於該執行緒中的每一個動作 B。
  2. 監視器鎖法則:對一個監視器的解鎖 happens-before 於每個後續對同一監視器的加鎖。
  3. Volatile 變數法則:對 Volatile 域的寫入操作 happens-before 於每個後續對同一 Volatile 的讀操作。
  4. 傳遞性:如果 A happens-before 於 B,且 B happens-before C,則 A happens-before C。

ConcurrentHashMap 的結構分析

為了更好的理解 ConcurrentHashMap 高併發的具體實現,讓我們先探索它的結構模型。

ConcurrentHashMap 類中包含兩個靜態內部類 HashEntry 和 Segment。HashEntry 用來封裝對映表的鍵 / 值對;Segment 用來充當鎖的角色,每個 Segment 物件守護整個雜湊對映表的若干個桶。每個桶是由若干個 HashEntry 物件連結起來的連結串列。一個 ConcurrentHashMap 例項中包含由若干個 Segment 物件組成的陣列。

HashEntry 類

HashEntry 用來封裝雜湊對映表中的鍵值對。在 HashEntry 類中,key,hash 和 next 域都被宣告為 final 型,value 域被宣告為 volatile 型。

清單 1.HashEntry 類的定義
 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(K key, int hash, HashEntry<K,V> next, V value) { 
            this.key = key; 
            this.hash = hash; 
            this.next = next; 
            this.value = value; 
        } 
 }

在 ConcurrentHashMap 中,在雜湊時如果產生“碰撞”,將採用“分離連結法”來處理“碰撞”:把“碰撞”的 HashEntry 物件連結成一個連結串列。由於 HashEntry 的 next 域為 final 型,所以新節點只能在連結串列的表頭處插入。 下圖是在一個空桶中依次插入 A,B,C 三個 HashEntry 物件後的結構圖:

圖 1. 插入三個節點後桶的結構示意圖:
圖 1. 插入三個節點後桶的結構示意圖:

注意:由於只能在表頭插入,所以連結串列中節點的順序和插入的順序相反。

避免熱點域

在 ConcurrentHashMap中,每一個 Segment 物件都有一個 count 物件來表示本 Segment 中包含的 HashEntry 物件的個數。這樣當需要更新計數器時,不用鎖定整個ConcurrentHashMap

Segment 類

Segment 類繼承於 ReentrantLock 類,從而使得 Segment 物件能充當鎖的角色。每個 Segment 物件用來守護其(成員物件 table 中)包含的若干個桶。

table 是一個由 HashEntry 物件組成的陣列。table 陣列的每一個陣列成員就是雜湊對映表的一個桶。

count 變數是一個計數器,它表示每個 Segment 物件管理的 table 陣列(若干個 HashEntry 組成的連結串列)包含的 HashEntry 物件的個數。每一個 Segment 物件都有一個 count 物件來表示本 Segment 中包含的 HashEntry 物件的總數。注意,之所以在每個 Segment 物件中包含一個計數器,而不是在 ConcurrentHashMap 中使用全域性的計數器,是為了避免出現“熱點域”而影響 ConcurrentHashMap 的併發性。

清單 2.Segment 類的定義
 static final class Segment<K,V> extends ReentrantLock implements Serializable { 
        /** 
         * 在本 segment 範圍內,包含的 HashEntry 元素的個數
         * 該變數被宣告為 volatile 型
         */ 
        transient volatile int count; 

        /** 
         * table 被更新的次數
         */ 
        transient int modCount; 

        /** 
         * 當 table 中包含的 HashEntry 元素的個數超過本變數值時,觸發 table 的再雜湊
         */ 
        transient int threshold; 

        /** 
         * table 是由 HashEntry 物件組成的陣列
         * 如果雜湊時發生碰撞,碰撞的 HashEntry 物件就以連結串列的形式連結成一個連結串列
         * table 陣列的陣列成員代表雜湊對映表的一個桶
         * 每個 table 守護整個 ConcurrentHashMap 包含桶總數的一部分
         * 如果併發級別為 16,table 則守護 ConcurrentHashMap 包含的桶總數的 1/16 
         */ 
        transient volatile HashEntry<K,V>[] table; 

        /** 
         * 裝載因子
         */ 
        final float loadFactor; 

        Segment(int initialCapacity, float lf) { 
            loadFactor = lf; 
            setTable(HashEntry.<K,V>newArray(initialCapacity)); 
        } 

        /** 
         * 設定 table 引用到這個新生成的 HashEntry 陣列
         * 只能在持有鎖或建構函式中呼叫本方法
         */ 
        void setTable(HashEntry<K,V>[] newTable) { 
            // 計算臨界閥值為新陣列的長度與裝載因子的乘積
            threshold = (int)(newTable.length * loadFactor); 
            table = newTable; 
        } 

        /** 
         * 根據 key 的雜湊值,找到 table 中對應的那個桶(table 陣列的某個陣列成員)
         */ 
        HashEntry<K,V> getFirst(int hash) { 
            HashEntry<K,V>[] tab = table; 
            // 把雜湊值與 table 陣列長度減 1 的值相“與”,
 // 得到雜湊值對應的 table 陣列的下標
            // 然後返回 table 陣列中此下標對應的 HashEntry 元素
            return tab[hash & (tab.length - 1)]; 
        } 
 }

下圖是依次插入 ABC 三個 HashEntry 節點後,Segment 的結構示意圖。

圖 2. 插入三個節點後 Segment 的結構示意圖:
圖 2. 插入三個節點後 Segment 的結構示意圖:

ConcurrentHashMap 類

ConcurrentHashMap 在預設併發級別會建立包含 16 個 Segment 物件的陣列。每個 Segment 的成員物件 table 包含若干個雜湊表的桶。每個桶是由 HashEntry 連結起來的一個連結串列。如果鍵能均勻雜湊,每個 Segment 大約守護整個雜湊表中桶總數的 1/16。

清單 3.ConcurrentHashMap 類的定義
 public class ConcurrentHashMap<K, V> extends AbstractMap<K, V> 
        implements ConcurrentMap<K, V>, Serializable { 

    /** 
     * 雜湊對映表的預設初始容量為 16,即初始預設為 16 個桶
     * 在建構函式中沒有指定這個引數時,使用本引數
     */ 
    static final 	 int DEFAULT_INITIAL_CAPACITY= 16; 

    /** 
     * 雜湊對映表的預設裝載因子為 0.75,該值是 table 中包含的 HashEntry 元素的個數與
 * table 陣列長度的比值
     * 當 table 中包含的 HashEntry 元素的個數超過了 table 陣列的長度與裝載因子的乘積時,
 * 將觸發 再雜湊
     * 在建構函式中沒有指定這個引數時,使用本引數
     */ 
    static final float DEFAULT_LOAD_FACTOR= 0.75f; 

    /** 
     * 雜湊表的預設併發級別為 16。該值表示當前更新執行緒的估計數
     * 在建構函式中沒有指定這個引數時,使用本引數
     */ 
    static final int DEFAULT_CONCURRENCY_LEVEL= 16; 

    /** 
     * segments 的掩碼值
     * key 的雜湊碼的高位用來選擇具體的 segment 
     */ 
    final int segmentMask; 

    /** 
     * 偏移量
     */ 
    final int segmentShift; 

    /** 
     * 由 Segment 物件組成的陣列
     */ 
    final Segment<K,V>[] segments; 

    /** 
     * 建立一個帶有指定初始容量、載入因子和併發級別的新的空對映。
     */ 
    public ConcurrentHashMap(int initialCapacity, 
                             float loadFactor, int concurrencyLevel) { 
        if(!(loadFactor > 0) || initialCapacity < 0 || 
 concurrencyLevel <= 0) 
            throw new IllegalArgumentException(); 

        if(concurrencyLevel > MAX_SEGMENTS) 
            concurrencyLevel = MAX_SEGMENTS; 

        // 尋找最佳匹配引數(不小於給定引數的最接近的 2 次冪) 
        int sshift = 0; 
        int ssize = 1; 
        while(ssize < concurrencyLevel) { 
            ++sshift; 
            ssize <<= 1; 
        } 
        segmentShift = 32 - sshift;       // 偏移量值
        segmentMask = ssize - 1;           // 掩碼值 
        this.segments = Segment.newArray(ssize);   // 建立陣列

        if (initialCapacity > MAXIMUM_CAPACITY) 
            initialCapacity = MAXIMUM_CAPACITY; 
        int c = initialCapacity / ssize; 
        if(c * ssize < initialCapacity) 
            ++c; 
        int cap = 1; 
        while(cap < c) 
            cap <<= 1; 

        // 依次遍歷每個陣列元素
        for(int i = 0; i < this.segments.length; ++i) 
            // 初始化每個陣列元素引用的 Segment 物件
 this.segments[i] = new Segment<K,V>(cap, loadFactor); 
    } 

    /** 
     * 建立一個帶有預設初始容量 (16)、預設載入因子 (0.75) 和 預設併發級別 (16) 
  * 的空雜湊對映表。
     */ 
    public ConcurrentHashMap() { 
        // 使用三個預設引數,呼叫上面過載的建構函式來建立空雜湊對映表
 this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL); 
 }

}

下面是 ConcurrentHashMap 的結構示意圖。

圖 3.ConcurrentHashMap 的結構示意圖:
圖 3.ConcurrentHashMap 的結構示意圖:

用分離鎖實現多個執行緒間的併發寫操作

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

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

清單 4.Put 方法的實現
 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); 
 }

然後,根據 hash 值找到對應的Segment 物件:

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

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

清單 6.在 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();                     // 解鎖
            } 
        }

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

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

用 HashEntery 物件的不變性來降低讀操作對加鎖的需求

在程式碼清單“HashEntry 類的定義”中我們可以看到,HashEntry 中的 key,hash,next 都宣告為 final 型。這意味著,不能把節點新增到連結的中間和尾部,也不能在連結的中間和尾部刪除節點。這個特性可以保證:在訪問某個節點時,這個節點之後的連結不會被改變。這個特性可以大大降低處理連結串列時的複雜性。

同時,HashEntry 類的 value 域被宣告為 Volatile 型,Java 的記憶體模型可以保證:某個寫執行緒對 value 域的寫入馬上可以被後續的某個讀執行緒“看”到。在 ConcurrentHashMap 中,不允許用 unll 作為鍵和值,當讀執行緒讀到某個 HashEntry 的 value 域的值為 null 時,便知道產生了衝突——發生了重排序現象,需要加鎖後重新讀入這個 value 值。這些特性互相配合,使得讀執行緒即使在不加鎖狀態下,也能正確訪問 ConcurrentHashMap。

下面我們分別來分析執行緒寫入的兩種情形:對雜湊表做非結構性修改的操作和對雜湊表做結構性修改的操作。

非結構性修改操作只是更改某個 HashEntry 的 value 域的值。由於對 Volatile 變數的寫入操作將與隨後對這個變數的讀操作進行同步。當一個寫執行緒修改了某個 HashEntry 的 value 域後,另一個讀執行緒讀這個值域,Java 記憶體模型能夠保證讀執行緒讀取的一定是更新後的值。所以,寫執行緒對連結串列的非結構性修改能夠被後續不加鎖的讀執行緒“看到”。

對 ConcurrentHashMap 做結構性修改,實質上是對某個桶指向的連結串列做結構性修改。如果能夠確保:在讀執行緒遍歷一個連結串列期間,寫執行緒對這個連結串列所做的結構性修改不影響讀執行緒繼續正常遍歷這個連結串列。那麼讀 / 寫執行緒之間就可以安全併發訪問這個 ConcurrentHashMap。

結構性修改操作包括 put,remove,clear。下面我們分別分析這三個操作。

clear 操作只是把 ConcurrentHashMap 中所有的桶“置空”,每個桶之前引用的連結串列依然存在,只是桶不再引用到這些連結串列(所有連結串列的結構並沒有被修改)。正在遍歷某個連結串列的讀執行緒依然可以正常執行對該連結串列的遍歷。

從上面的程式碼清單“在 Segment 中執行具體的 put 操作”中,我們可以看出:put 操作如果需要插入一個新節點到連結串列中時 , 會在連結串列頭部插入這個新節點。此時,連結串列中的原有節點的連結並沒有被修改。也就是說:插入新健 / 值對到連結串列中的操作不會影響讀執行緒正常遍歷這個連結串列。

下面來分析 remove 操作,先讓我們來看看 remove 操作的原始碼實現。

清單 7.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. 執行刪除之前的原連結串列:
圖 4. 執行刪除之前的原連結串列:
圖 5. 執行刪除之後的新連結串列
圖 5. 執行刪除之後的新連結串列

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

在執行 remove 操作時,原始連結串列並沒有被修改,也就是說:讀執行緒不會受同時執行 remove 操作的併發寫執行緒的干擾。

綜合上面的分析我們可以看出,寫執行緒對某個連結串列的結構性修改不會影響其他的併發讀執行緒對這個連結串列的遍歷訪問。

用 Volatile 變數協調讀寫執行緒間的記憶體可見性

由於記憶體可見性問題,未正確同步的情況下,寫執行緒寫入的值可能並不為後續的讀執行緒可見。

下面以寫執行緒 M 和讀執行緒 N 來說明 ConcurrentHashMap 如何協調讀 / 寫執行緒間的記憶體可見性問題。

圖 6. 協調讀 - 寫執行緒間的記憶體可見性的示意圖:
圖 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。

清單 8.Count 變數的宣告
 transient volatile int count;

所有不加鎖讀方法,在進入讀方法時,首先都會去讀這個 count 變數。比如下面的 get 方法:

清單 9.get 操作
 V get(Object key, int hash) { 
            if(count != 0) {       // 首先讀 count 變數
                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; 
                        // 如果讀到 value 域為 null,說明發生了重排序,加鎖後重新讀取
                        return readValueUnderLock(e); 
                    } 
                    e = e.next; 
                } 
            } 
            return null; 
        }

在 ConcurrentHashMap 中,所有執行寫操作的方法(put, remove, clear),在對連結串列做結構性修改之後,在退出寫方法前都會去寫這個 count 變數。所有未加鎖的讀操作(get, contains, containsKey)在讀方法中,都會首先去讀取這個 count 變數。

根據 Java 記憶體模型,對 同一個 volatile 變數的寫 / 讀操作可以確保:寫執行緒寫入的值,能夠被之後未加鎖的讀執行緒“看到”。

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

ConcurrentHashMap 實現高併發的總結

基於通常情形而優化

在實際的應用中,雜湊表一般的應用場景是:除了少數插入操作和刪除操作外,絕大多數都是讀取操作,而且讀操作在大多數時候都是成功的。正是基於這個前提,ConcurrentHashMap 針對讀操作做了大量的優化。通過 HashEntry 物件的不變性和用 volatile 型變數協調執行緒間的記憶體可見性,使得 大多數時候,讀操作不需要加鎖就可以正確獲得值。這個特性使得 ConcurrentHashMap 的併發效能在分離鎖的基礎上又有了近一步的提高。

總結

ConcurrentHashMap 是一個併發雜湊對映表的實現,它允許完全併發的讀取,並且支援給定數量的併發更新。相比於 HashTable 和用同步包裝器包裝的 HashMap(Collections.synchronizedMap(new HashMap())),ConcurrentHashMap 擁有更高的併發性。在 HashTable 和由同步包裝器包裝的 HashMap 中,使用一個全域性的鎖來同步不同執行緒間的併發訪問。同一時間點,只能有一個執行緒持有鎖,也就是說在同一時間點,只能有一個執行緒能訪問容器。這雖然保證多執行緒間的安全併發訪問,但同時也導致對容器的訪問變成序列化的了。

在使用鎖來協調多執行緒間併發訪問的模式下,減小對鎖的競爭可以有效提高併發性。有兩種方式可以減小對鎖的競爭:

  1. 減小請求 同一個鎖的 頻率。
  2. 減少持有鎖的 時間。

ConcurrentHashMap 的高併發性主要來自於三個方面:

  1. 用分離鎖實現多個執行緒間的更深層次的共享訪問。
  2. 用 HashEntery 物件的不變性來降低執行讀操作的執行緒在遍歷連結串列期間對加鎖的需求。
  3. 通過對同一個 Volatile 變數的寫 / 讀訪問,協調不同執行緒間讀 / 寫操作的記憶體可見性。

使用分離鎖,減小了請求 同一個鎖的頻率。

通過 HashEntery 物件的不變性及對同一個 Volatile 變數的讀 / 寫來協調記憶體可見性,使得 讀操作大多數時候不需要加鎖就能成功獲取到需要的值。由於雜湊對映表在實際應用中大多數操作都是成功的 讀操作,所以 2 和 3 既可以減少請求同一個鎖的頻率,也可以有效減少持有鎖的時間。

通過減小請求同一個鎖的頻率和儘量減少持有鎖的時間 ,使得 ConcurrentHashMap 的併發性相對於 HashTable 和用同步包裝器包裝的 HashMap有了質的提高。

參考資料

學習

  • Java 語言規範(第 3 版):Java 程式設計語言最權威的技術參考書。17.4 章探討了 Java 記憶體模型。
  • Java 併發程式設計實踐:本書作者系 Java 標準化組織 JSR 166 專家組的主要成員,本書是近年來 Java 併發程式設計圖書中最值得一讀的力作。5.2 章探討了併發容器, 11.4 探討了如何減少鎖的競爭,11.5 比較了各種 Map 的效能,16 章探討了 Java 記憶體模型。
  • Java 併發程式設計—設計原則與模式(第二版):Java 併發程式設計領域的先驅 --Doug Lea 先生的經典著作。Doug Lea 先生是 JDK 中 util.concurrent 包的實現者。
  • 多核系統的 Java 併發缺陷模式(bug patterns):本文通過對 6 個鮮為人知的併發缺陷問題的講解,闡述了威脅執行在多核系統上的 Java 應用程式執行緒安全和效能的原因,同時帶領您研究併發缺陷模式(bug patterns),讓您既能夠提高對併發程式設計的理解,還能夠了解如何發現無效或可能無效的程式設計方法。
  • Java 多執行緒與併發程式設計專題:本專題彙集了與 Java 多執行緒與併發程式設計相關的文章和教程,幫助讀者理解 Java 併發程式設計的模式及其利弊,向讀者展示瞭如何更精確地使用 Java 平臺的執行緒模型。
  • developerWorks Java 技術專區:這裡有數百篇關於 Java 程式設計各個方面的文章。

討論

  • 加入 developerWorks 中文社群。檢視開發人員推動的部落格、論壇、組和維基,並與其他 developerWorks 使用者交流。
  • 尊重原創作者
  • 原創連線:http://www.ibm.com/developerworks/cn/java/java-lo-concurrenthashmap/

相關文章