併發容器之ConcurrentHashMap(JDK 1.8版本)

你聽___發表於2018-05-06

原創文章&經驗總結&從校招到 A 廠一路陽光一路滄桑

詳情請戳www.codercc.com[1]

1.ConcurrentHashmap 簡介

在使用 HashMap 時在多執行緒情況下擴容會出現 CPU 接近 100%的情況,因為 hashmap 並不是執行緒安全的,通常我們可以使用在 java 體系中古老的 hashtable 類,該類基本上所有的方法都採用 synchronized 進行執行緒安全的控制,可想而知,在高併發的情況下,每次只有一個執行緒能夠獲取物件監視器鎖,這樣的併發效能的確不令人滿意。另外一種方式通過 Collections 的Map<K,V> synchronizedMap(Map<K,V> m)將 hashmap 包裝成一個執行緒安全的 map。比如 SynchronzedMap 的 put 方法原始碼為:

public V put(K key, V value) {
    synchronized (mutex) {return m.put(key, value);}
}
複製程式碼

實際上 SynchronizedMap 實現依然是採用 synchronized 獨佔式鎖進行執行緒安全的併發控制的。同樣,這種方案的效能也是令人不太滿意的。針對這種境況,Doug Lea 大師不遺餘力的為我們創造了一些執行緒安全的併發容器,讓每一個 java 開發人員倍感幸福。相對於 hashmap 來說,ConcurrentHashMap 就是執行緒安全的 map,其中利用了鎖分段的思想提高了併發度

ConcurrentHashMap 在 JDK1.6 的版本網上資料很多,有興趣的可以去看看。 JDK 1.6 版本關鍵要素:

  1. segment 繼承了 ReentrantLock 充當鎖的角色,為每一個 segment 提供了執行緒安全的保障;
  2. segment 維護了雜湊雜湊表的若干個桶,每個桶由 HashEntry 構成的連結串列。

而到了 JDK 1.8 的 ConcurrentHashMap 就有了很大的變化,光是程式碼量就足足增加了很多。1.8 版本捨棄了 segment,並且大量使用了 synchronized,以及 CAS 無鎖操作以保證 ConcurrentHashMap 操作的執行緒安全性。至於為什麼不用 ReentrantLock 而是 Synchronzied 呢?實際上,synchronzied 做了很多的優化,包括偏向鎖,輕量級鎖,重量級鎖,可以依次向上升級鎖狀態,但不能降級(關於 synchronized 可以看這篇文章[2]),因此,使用 synchronized 相較於 ReentrantLock 的效能會持平甚至在某些情況更優,具體的效能測試可以去網上查閱一些資料。另外,底層資料結構改變為採用陣列+連結串列+紅黑樹的資料形式。

2.關鍵屬性及類

在瞭解 ConcurrentHashMap 的具體方法實現前,我們需要系統的來看一下幾個關鍵的地方。

ConcurrentHashMap 的關鍵屬性

  1. table volatile Node<K,V>[] table://裝載 Node 的陣列,作為 ConcurrentHashMap 的資料容器,採用懶載入的方式,直到第一次插入資料的時候才會進行初始化操作,陣列的大小總是為 2 的冪次方。

  2. nextTable volatile Node<K,V>[] nextTable; //擴容時使用,平時為 null,只有在擴容的時候才為非 null

  3. sizeCtl volatile int sizeCtl; 該屬性用來控制 table 陣列的大小,根據是否初始化和是否正在擴容有幾種情況: **當值為負數時:**如果為-1 表示正在初始化,如果為-N 則表示當前正有 N-1 個執行緒進行擴容操作; **當值為正數時:**如果當前陣列為 null 的話表示 table 在初始化過程中,sizeCtl 表示為需要新建陣列的長度; 若已經初始化了,表示當前資料容器(table 陣列)可用容量也可以理解成臨界值(插入節點數超過了該臨界值就需要擴容),具體指為陣列的長度 n 乘以 載入因子 loadFactor; 當值為 0 時,即陣列長度為預設初始值。

  4. sun.misc.Unsafe U 在 ConcurrentHashMapde 的實現中可以看到大量的 U.compareAndSwapXXXX 的方法去修改 ConcurrentHashMap 的一些屬性。這些方法實際上是利用了 CAS 演算法保證了執行緒安全性,這是一種樂觀策略,假設每一次操作都不會產生衝突,當且僅當衝突發生的時候再去嘗試。而 CAS 操作依賴於現代處理器指令集,通過底層CMPXCHG指令實現。CAS(V,O,N)核心思想為:若當前變數實際值 V 與期望的舊值 O 相同,則表明該變數沒被其他執行緒進行修改,因此可以安全的將新值 N 賦值給變數;若當前變數實際值 V 與期望的舊值 O 不相同,則表明該變數已經被其他執行緒做了處理,此時將新值 N 賦給變數操作就是不安全的,在進行重試。而在大量的同步元件和併發容器的實現中使用 CAS 是通過sun.misc.Unsafe類實現的,該類提供了一些可以直接操控記憶體和執行緒的底層操作,可以理解為 java 中的“指標”。該成員變數的獲取是在靜態程式碼塊中:

    	static {
    	    try {
    	        U = sun.misc.Unsafe.getUnsafe();
    			.......
    	    } catch (Exception e) {
    	        throw new Error(e);
    	    }
    	}
    複製程式碼

ConcurrentHashMap 中關鍵內部類

  1. Node Node 類實現了 Map.Entry 介面,主要存放 key-value 對,並且具有 next 域

    	static class Node<K,V> implements Map.Entry<K,V> {
    	        final int hash;
    	        final K key;
    	        volatile V val;
    	        volatile Node<K,V> next;
    			......
    	}
    複製程式碼

另外可以看出很多屬性都是用 volatile 進行修飾的,也就是為了保證記憶體可見性。

  1. TreeNode 樹節點,繼承於承載資料的 Node 類。而紅黑樹的操作是針對 TreeBin 類的,從該類的註釋也可以看出,也就是 TreeBin 會將 TreeNode 進行再一次封裝

    	**
    	 * Nodes for use in TreeBins
    	 */
    	static final class TreeNode<K,V> extends Node<K,V> {
    	        TreeNode<K,V> parent;  // red-black tree links
    	        TreeNode<K,V> left;
    	        TreeNode<K,V> right;
    	        TreeNode<K,V> prev;    // needed to unlink next upon deletion
    	        boolean red;
    			......
    	}
    複製程式碼
  2. TreeBin 這個類並不負責包裝使用者的 key、value 資訊,而是包裝的很多 TreeNode 節點。實際的 ConcurrentHashMap“陣列”中,存放的是 TreeBin 物件,而不是 TreeNode 物件。

    	static final class TreeBin<K,V> extends Node<K,V> {
    	        TreeNode<K,V> root;
    	        volatile TreeNode<K,V> first;
    	        volatile Thread waiter;
    	        volatile int lockState;
    	        // values for lockState
    	        static final int WRITER = 1; // set while holding write lock
    	        static final int WAITER = 2; // set when waiting for write lock
    	        static final int READER = 4; // increment value for setting read lock
    			......
    	}
    複製程式碼
  3. ForwardingNode 在擴容時才會出現的特殊節點,其 key,value,hash 全部為 null。並擁有 nextTable 指標引用新的 table 陣列。

    	static final class ForwardingNode<K,V> extends Node<K,V> {
    	    final Node<K,V>[] nextTable;
    	    ForwardingNode(Node<K,V>[] tab) {
    	        super(MOVED, null, null, null);
    	        this.nextTable = tab;
    	    }
    	   .....
    	}
    複製程式碼

CAS 關鍵操作

在上面我們提及到在 ConcurrentHashMap 中會大量使用 CAS 修改它的屬性和一些操作。因此,在理解 ConcurrentHashMap 的方法前我們需要了解下面幾個常用的利用 CAS 演算法來保障執行緒安全的操作。

  1. tabAt

    	static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
    	    return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
    	}
    複製程式碼

    該方法用來獲取 table 陣列中索引為 i 的 Node 元素。

  2. casTabAt

    static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
                                        Node<K,V> c, Node<K,V> v) {
        return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
    }
    複製程式碼

    利用 CAS 操作設定 table 陣列中索引為 i 的元素

  3. setTabAt

    static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
        U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
    }
    複製程式碼

    該方法用來設定 table 陣列中索引為 i 的元素

3.重點方法講解

在熟悉上面的這核心資訊之後,我們接下來就來依次看看幾個常用的方法是怎樣實現的。

3.1 例項構造器方法

在使用 ConcurrentHashMap 第一件事自然而然就是 new 出來一個 ConcurrentHashMap 物件,一共提供瞭如下幾個構造器方法:

// 1. 構造一個空的map,即table陣列還未初始化,初始化放在第一次插入資料時,預設大小為16
ConcurrentHashMap()
// 2. 給定map的大小
ConcurrentHashMap(int initialCapacity)
// 3. 給定一個map
ConcurrentHashMap(Map<? extends K, ? extends V> m)
// 4. 給定map的大小以及載入因子
ConcurrentHashMap(int initialCapacity, float loadFactor)
// 5. 給定map大小,載入因子以及併發度(預計同時運算元據的執行緒)
ConcurrentHashMap(int initialCapacity,float loadFactor, int concurrencyLevel)
複製程式碼

ConcurrentHashMap 一共給我們提供了 5 中構造器方法,具體使用請看註釋,我們來看看第 2 種構造器,傳入指定大小時的情況,該構造器原始碼為:

public ConcurrentHashMap(int initialCapacity) {
	//1. 小於0直接拋異常
    if (initialCapacity < 0)
        throw new IllegalArgumentException();
	//2. 判斷是否超過了允許的最大值,超過了話則取最大值,否則再對該值進一步處理
    int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
               MAXIMUM_CAPACITY :
               tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
	//3. 賦值給sizeCtl
    this.sizeCtl = cap;
}
複製程式碼

這段程式碼的邏輯請看註釋,很容易理解,如果小於 0 就直接丟擲異常,如果指定值大於了所允許的最大值的話就取最大值,否則,在對指定值做進一步處理。最後將 cap 賦值給 sizeCtl,關於 sizeCtl 的說明請看上面的說明,當呼叫構造器方法之後,sizeCtl 的大小應該就代表了 ConcurrentHashMap 的大小,即 table 陣列長度。tableSizeFor 做了哪些事情了?原始碼為:

/**
 * Returns a power of two table size for the given desired capacity.
 * See Hackers Delight, sec 3.2
 */
private static final int tableSizeFor(int c) {
    int n = c - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
複製程式碼

通過註釋就很清楚了,該方法會將呼叫構造器方法時指定的大小轉換成一個 2 的冪次方數,也就是說 ConcurrentHashMap 的大小一定是 2 的冪次方,比如,當指定大小為 18 時,為了滿足 2 的冪次方特性,實際上 concurrentHashMapd 的大小為 2 的 5 次方(32)。另外,需要注意的是,呼叫構造器方法的時候並未構造出 table 陣列(可以理解為 ConcurrentHashMap 的資料容器),只是算出 table 陣列的長度,當第一次向 ConcurrentHashMap 插入資料的時候才真正的完成初始化建立 table 陣列的工作

3.2 initTable 方法

直接上原始碼:

private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    while ((tab = table) == null || tab.length == 0) {
        if ((sc = sizeCtl) < 0)
			// 1. 保證只有一個執行緒正在進行初始化操作
            Thread.yield(); // lost initialization race; just spin
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
            try {
                if ((tab = table) == null || tab.length == 0) {
					// 2. 得出陣列的大小
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    @SuppressWarnings("unchecked")
					// 3. 這裡才真正的初始化陣列
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    table = tab = nt;
					// 4. 計算陣列中可用的大小:實際大小n*0.75(載入因子)
                    sc = n - (n >>> 2);
                }
            } finally {
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}
複製程式碼

程式碼的邏輯請見註釋,有可能存在一個情況是多個執行緒同時走到這個方法中,為了保證能夠正確初始化,在第 1 步中會先通過 if 進行判斷,若當前已經有一個執行緒正在初始化即 sizeCtl 值變為-1,這個時候其他執行緒在 If 判斷為 true 從而呼叫 Thread.yield()讓出 CPU 時間片。正在進行初始化的執行緒會呼叫 U.compareAndSwapInt 方法將 sizeCtl 改為-1 即正在初始化的狀態。另外還需要注意的事情是,在第四步中會進一步計算陣列中可用的大小即為陣列實際大小 n 乘以載入因子 0.75.可以看看這裡乘以 0.75 是怎麼算的,0.75 為四分之三,這裡n - (n >>> 2)是不是剛好是 n-(1/4)n=(3/4)n,挺有意思的吧:)。如果選擇是無參的構造器的話,這裡在 new Node 陣列的時候會使用預設大小為DEFAULT_CAPACITY(16),然後乘以載入因子 0.75 為 12,也就是說陣列的可用大小為 12。

3.3 put 方法

使用 ConcurrentHashMap 最長用的也應該是 put 和 get 方法了吧,我們先來看看 put 方法是怎樣實現的。呼叫 put 方法時實際具體實現是 putVal 方法,原始碼如下:

/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
	//1. 計算key的hash值
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
		//2. 如果當前table還沒有初始化先呼叫initTable方法將tab進行初始化
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
		//3. tab中索引為i的位置的元素為null,則直接使用CAS將值插入即可
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            if (casTabAt(tab, i, null,
                         new Node<K,V>(hash, key, value, null)))
                break;                   // no lock when adding to empty bin
        }
		//4. 當前正在擴容
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        else {
            V oldVal = null;
            synchronized (f) {
                if (tabAt(tab, i) == f) {
					//5. 當前為連結串列,在連結串列中插入新的鍵值對
                    if (fh >= 0) {
                        binCount = 1;
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                            if (e.hash == hash &&
                                ((ek = e.key) == key ||
                                 (ek != null && key.equals(ek)))) {
                                oldVal = e.val;
                                if (!onlyIfAbsent)
                                    e.val = value;
                                break;
                            }
                            Node<K,V> pred = e;
                            if ((e = e.next) == null) {
                                pred.next = new Node<K,V>(hash, key,
                                                          value, null);
                                break;
                            }
                        }
                    }
					// 6.當前為紅黑樹,將新的鍵值對插入到紅黑樹中
                    else if (f instanceof TreeBin) {
                        Node<K,V> p;
                        binCount = 2;
                        if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                       value)) != null) {
                            oldVal = p.val;
                            if (!onlyIfAbsent)
                                p.val = value;
                        }
                    }
                }
            }
			// 7.插入完鍵值對後再根據實際大小看是否需要轉換成紅黑樹
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
	//8.對當前容量大小進行檢查,如果超過了臨界值(實際大小*載入因子)就需要擴容
    addCount(1L, binCount);
    return null;
}
複製程式碼

put 方法的程式碼量有點長,我們按照上面的分解的步驟一步步來看。從整體而言,為了解決執行緒安全的問題,ConcurrentHashMap 使用了 synchronzied 和 CAS 的方式。在之前瞭解過 HashMap 以及 1.8 版本之前的 ConcurrenHashMap 都應該知道 ConcurrentHashMap 結構圖,為了方面下面的講解這裡先直接給出,如果對這有疑問的話,可以在網上隨便搜搜即可。

ConcurrentHashMap雜湊桶陣列結構示意圖
ConcurrentHashMap雜湊桶陣列結構示意圖

如圖(圖片摘自網路),ConcurrentHashMap 是一個雜湊桶陣列,如果不出現雜湊衝突的時候,每個元素均勻的分佈在雜湊桶陣列中。當出現雜湊衝突的時候,是標準的鏈地址的解決方式,將 hash 值相同的節點構成連結串列的形式,稱為“拉鍊法”,另外,在 1.8 版本中為了防止拉鍊過長,當連結串列的長度大於 8 的時候會將連結串列轉換成紅黑樹。table 陣列中的每個元素實際上是單連結串列的頭結點或者紅黑樹的根節點。當插入鍵值對時首先應該定位到要插入的桶,即插入 table 陣列的索引 i 處。那麼,怎樣計算得出索引 i 呢?當然是根據 key 的 hashCode 值。

  1. spread()重雜湊,以減小 Hash 衝突

我們知道對於一個 hash 表來說,hash 值分散的不夠均勻的話會大大增加雜湊衝突的概率,從而影響到 hash 表的效能。因此通過 spread 方法進行了一次重 hash 從而大大減小雜湊衝突的可能性。spread 方法為:

static final int spread(int h) {
    return (h ^ (h >>> 16)) & HASH_BITS;
}
複製程式碼

該方法主要是將 key 的 hashCode 的低 16 位於高 16 位進行異或運算,這樣不僅能夠使得 hash 值能夠分散能夠均勻減小 hash 衝突的概率,另外只用到了異或運算,在效能開銷上也能兼顧,做到平衡的 trade-off。

2.初始化 table

緊接著到第 2 步,會判斷當前 table 陣列是否初始化了,沒有的話就呼叫 initTable 進行初始化,該方法在上面已經講過了。

3.能否直接將新值插入到 table 陣列中

從上面的結構示意圖就可以看出存在這樣一種情況,如果插入值待插入的位置剛好所在的 table 陣列為 null 的話就可以直接將值插入即可。那麼怎樣根據 hash 確定在 table 中待插入的索引 i 呢?很顯然可以通過 hash 值與陣列的長度取模操作,從而確定新值插入到陣列的哪個位置。而之前我們提過 ConcurrentHashMap 的大小總是 2 的冪次方,(n - 1) & hash 運算等價於對長度 n 取模,也就是 hash%n,但是位運算比取模運算的效率要高很多,Doug lea 大師在設計併發容器的時候也是將效能優化到了極致,令人欽佩。

確定好陣列的索引 i 後,就可以可以 tabAt()方法(該方法在上面已經說明了,有疑問可以回過頭去看看)獲取該位置上的元素,如果當前 Node f 為 null 的話,就可以直接用 casTabAt 方法將新值插入即可。

4.當前是否正在擴容

如果當前節點不為 null,且該節點為特殊節點(forwardingNode)的話,就說明當前 concurrentHashMap 正在進行擴容操作,關於擴容操作,下面會作為一個具體的方法進行講解。那麼怎樣確定當前的這個 Node 是不是特殊的節點了?是通過判斷該節點的 hash 值是不是等於-1(MOVED),程式碼為(fh = f.hash) == MOVED,對 MOVED 的解釋在原始碼上也寫的很清楚了:

static final int MOVED     = -1; // hash for forwarding nodes
複製程式碼

5.當 table[i]為連結串列的頭結點,在連結串列中插入新值

在 table[i]不為 null 並且不為 forwardingNode 時,並且當前 Node f 的 hash 值大於 0(fh >= 0)的話說明當前節點 f 為當前桶的所有的節點組成的連結串列的頭結點。那麼接下來,要想向 ConcurrentHashMap 插入新值的話就是向這個連結串列插入新值。通過 synchronized (f)的方式進行加鎖以實現執行緒安全性。往連結串列中插入節點的部分程式碼為:

if (fh >= 0) {
    binCount = 1;
    for (Node<K,V> e = f;; ++binCount) {
        K ek;
		// 找到hash值相同的key,覆蓋舊值即可
        if (e.hash == hash &&
            ((ek = e.key) == key ||
             (ek != null && key.equals(ek)))) {
            oldVal = e.val;
            if (!onlyIfAbsent)
                e.val = value;
            break;
        }
        Node<K,V> pred = e;
        if ((e = e.next) == null) {
			//如果到連結串列末尾仍未找到,則直接將新值插入到連結串列末尾即可
            pred.next = new Node<K,V>(hash, key,
                                      value, null);
            break;
        }
    }
}
複製程式碼

這部分程式碼很好理解,就是兩種情況:1. 在連結串列中如果找到了與待插入的鍵值對的 key 相同的節點,就直接覆蓋即可;2. 如果直到找到了連結串列的末尾都沒有找到的話,就直接將待插入的鍵值對追加到連結串列的末尾即可

6.當 table[i]為紅黑樹的根節點,在紅黑樹中插入新值

按照之前的陣列+連結串列的設計方案,這裡存在一個問題,即使負載因子和 Hash 演算法設計的再合理,也免不了會出現拉鍊過長的情況,一旦出現拉鍊過長,甚至在極端情況下,查詢一個節點會出現時間複雜度為 O(n)的情況,則會嚴重影響 ConcurrentHashMap 的效能,於是,在 JDK1.8 版本中,對資料結構做了進一步的優化,引入了紅黑樹。而當連結串列長度太長(預設超過 8)時,連結串列就轉換為紅黑樹,利用紅黑樹快速增刪改查的特點提高 ConcurrentHashMap 的效能,其中會用到紅黑樹的插入、刪除、查詢等演算法。當 table[i]為紅黑樹的樹節點時的操作為:

if (f instanceof TreeBin) {
    Node<K,V> p;
    binCount = 2;
    if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                   value)) != null) {
        oldVal = p.val;
        if (!onlyIfAbsent)
            p.val = value;
    }
}
複製程式碼

首先在 if 中通過f instanceof TreeBin判斷當前 table[i]是否是樹節點,這下也正好驗證了我們在最上面介紹時說的 TreeBin 會對 TreeNode 做進一步封裝,對紅黑樹進行操作的時候針對的是 TreeBin 而不是 TreeNode。這段程式碼很簡單,呼叫 putTreeVal 方法完成向紅黑樹插入新節點,同樣的邏輯,如果在紅黑樹中存在於待插入鍵值對的 Key 相同(hash 值相等並且 equals 方法判斷為 true)的節點的話,就覆蓋舊值,否則就向紅黑樹追加新節點

7.根據當前節點個數進行調整

當完成資料新節點插入之後,會進一步對當前連結串列大小進行調整,這部分程式碼為:

if (binCount != 0) {
    if (binCount >= TREEIFY_THRESHOLD)
        treeifyBin(tab, i);
    if (oldVal != null)
        return oldVal;
    break;
}
複製程式碼

很容易理解,如果當前連結串列節點個數大於等於 8(TREEIFY_THRESHOLD)的時候,就會呼叫 treeifyBin 方法將 tabel[i](第 i 個雜湊桶)拉鍊轉換成紅黑樹。

至此,關於 Put 方法的邏輯就基本說的差不多了,現在來做一些總結:

整體流程:

  1. 首先對於每一個放入的值,首先利用 spread 方法對 key 的 hashcode 進行一次 hash 計算,由此來確定這個值在 table 中的位置;
  2. 如果當前 table 陣列還未初始化,先將 table 陣列進行初始化操作;
  3. 如果這個位置是 null 的,那麼使用 CAS 操作直接放入;
  4. 如果這個位置存在結點,說明發生了 hash 碰撞,首先判斷這個節點的型別。如果該節點 fh==MOVED(代表 forwardingNode,陣列正在進行擴容)的話,說明正在進行擴容;
  5. 如果是連結串列節點(fh>0),則得到的結點就是 hash 值相同的節點組成的連結串列的頭節點。需要依次向後遍歷確定這個新加入的值所在位置。如果遇到 key 相同的節點,則只需要覆蓋該結點的 value 值即可。否則依次向後遍歷,直到連結串列尾插入這個結點;
  6. 如果這個節點的型別是 TreeBin 的話,直接呼叫紅黑樹的插入方法進行插入新的節點;
  7. 插入完節點之後再次檢查連結串列長度,如果長度大於 8,就把這個連結串列轉換成紅黑樹;
  8. 對當前容量大小進行檢查,如果超過了臨界值(實際大小*載入因子)就需要擴容。

3.4 get 方法

看完了 put 方法再來看 get 方法就很容易了,用逆向思維去看就好,這樣存的話我反過來這麼取就好了。get 方法原始碼為:

public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
	// 1. 重hash
    int h = spread(key.hashCode());
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
        // 2. table[i]桶節點的key與查詢的key相同,則直接返回
		if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
		// 3. 當前節點hash小於0說明為樹節點,在紅黑樹中查詢即可
        else if (eh < 0)
            return (p = e.find(h, key)) != null ? p.val : null;
        while ((e = e.next) != null) {
		//4. 從連結串列中查詢,查詢到則返回該節點的value,否則就返回null即可
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}
複製程式碼

程式碼的邏輯請看註釋,首先先看當前的 hash 桶陣列節點即 table[i]是否為查詢的節點,若是則直接返回;若不是,則繼續再看當前是不是樹節點?通過看節點的 hash 值是否為小於 0,如果小於 0 則為樹節點。如果是樹節點在紅黑樹中查詢節點;如果不是樹節點,那就只剩下為連結串列的形式的一種可能性了,就向後遍歷查詢節點,若查詢到則返回節點的 value 即可,若沒有找到就返回 null。

3.5 transfer 方法

當 ConcurrentHashMap 容量不足的時候,需要對 table 進行擴容。這個方法的基本思想跟 HashMap 是很像的,但是由於它是支援併發擴容的,所以要複雜的多。原因是它支援多執行緒進行擴容操作,而並沒有加鎖。我想這樣做的目的不僅僅是為了滿足 concurrent 的要求,而是希望利用併發處理去減少擴容帶來的時間影響。transfer 方法原始碼為:

private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    int n = tab.length, stride;
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
        stride = MIN_TRANSFER_STRIDE; // subdivide range
	//1. 新建Node陣列,容量為之前的兩倍
    if (nextTab == null) {            // initiating
        try {
            @SuppressWarnings("unchecked")
            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
            nextTab = nt;
        } catch (Throwable ex) {      // try to cope with OOME
            sizeCtl = Integer.MAX_VALUE;
            return;
        }
        nextTable = nextTab;
        transferIndex = n;
    }
    int nextn = nextTab.length;
	//2. 新建forwardingNode引用,在之後會用到
    ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
    boolean advance = true;
    boolean finishing = false; // to ensure sweep before committing nextTab
    for (int i = 0, bound = 0;;) {
        Node<K,V> f; int fh;
        // 3. 確定遍歷中的索引i
		while (advance) {
            int nextIndex, nextBound;
            if (--i >= bound || finishing)
                advance = false;
            else if ((nextIndex = transferIndex) <= 0) {
                i = -1;
                advance = false;
            }
            else if (U.compareAndSwapInt
                     (this, TRANSFERINDEX, nextIndex,
                      nextBound = (nextIndex > stride ?
                                   nextIndex - stride : 0))) {
                bound = nextBound;
                i = nextIndex - 1;
                advance = false;
            }
        }
		//4.將原陣列中的元素複製到新陣列中去
		//4.5 for迴圈退出,擴容結束脩改sizeCtl屬性
        if (i < 0 || i >= n || i + n >= nextn) {
            int sc;
            if (finishing) {
                nextTable = null;
                table = nextTab;
                sizeCtl = (n << 1) - (n >>> 1);
                return;
            }
            if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
                if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
                    return;
                finishing = advance = true;
                i = n; // recheck before commit
            }
        }
		//4.1 當前陣列中第i個元素為null,用CAS設定成特殊節點forwardingNode(可以理解成佔位符)
        else if ((f = tabAt(tab, i)) == null)
            advance = casTabAt(tab, i, null, fwd);
		//4.2 如果遍歷到ForwardingNode節點  說明這個點已經被處理過了 直接跳過  這裡是控制併發擴容的核心
        else if ((fh = f.hash) == MOVED)
            advance = true; // already processed
        else {
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    Node<K,V> ln, hn;
                    if (fh >= 0) {
						//4.3 處理當前節點為連結串列的頭結點的情況,構造兩個連結串列,一個是原連結串列  另一個是原連結串列的反序排列
                        int runBit = fh & n;
                        Node<K,V> lastRun = f;
                        for (Node<K,V> p = f.next; p != null; p = p.next) {
                            int b = p.hash & n;
                            if (b != runBit) {
                                runBit = b;
                                lastRun = p;
                            }
                        }
                        if (runBit == 0) {
                            ln = lastRun;
                            hn = null;
                        }
                        else {
                            hn = lastRun;
                            ln = null;
                        }
                        for (Node<K,V> p = f; p != lastRun; p = p.next) {
                            int ph = p.hash; K pk = p.key; V pv = p.val;
                            if ((ph & n) == 0)
                                ln = new Node<K,V>(ph, pk, pv, ln);
                            else
                                hn = new Node<K,V>(ph, pk, pv, hn);
                        }
                       //在nextTable的i位置上插入一個連結串列
                       setTabAt(nextTab, i, ln);
                       //在nextTable的i+n的位置上插入另一個連結串列
                       setTabAt(nextTab, i + n, hn);
                       //在table的i位置上插入forwardNode節點  表示已經處理過該節點
                       setTabAt(tab, i, fwd);
                       //設定advance為true 返回到上面的while迴圈中 就可以執行i--操作
                       advance = true;
                    }
					//4.4 處理當前節點是TreeBin時的情況,操作和上面的類似
                    else if (f instanceof TreeBin) {
                        TreeBin<K,V> t = (TreeBin<K,V>)f;
                        TreeNode<K,V> lo = null, loTail = null;
                        TreeNode<K,V> hi = null, hiTail = null;
                        int lc = 0, hc = 0;
                        for (Node<K,V> e = t.first; e != null; e = e.next) {
                            int h = e.hash;
                            TreeNode<K,V> p = new TreeNode<K,V>
                                (h, e.key, e.val, null, null);
                            if ((h & n) == 0) {
                                if ((p.prev = loTail) == null)
                                    lo = p;
                                else
                                    loTail.next = p;
                                loTail = p;
                                ++lc;
                            }
                            else {
                                if ((p.prev = hiTail) == null)
                                    hi = p;
                                else
                                    hiTail.next = p;
                                hiTail = p;
                                ++hc;
                            }
                        }
                        ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
                            (hc != 0) ? new TreeBin<K,V>(lo) : t;
                        hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
                            (lc != 0) ? new TreeBin<K,V>(hi) : t;
                        setTabAt(nextTab, i, ln);
                        setTabAt(nextTab, i + n, hn);
                        setTabAt(tab, i, fwd);
                        advance = true;
                    }
                }
            }
        }
    }
}
複製程式碼

程式碼邏輯請看註釋,整個擴容操作分為兩個部分

第一部分是構建一個 nextTable,它的容量是原來的兩倍,這個操作是單執行緒完成的。新建 table 陣列的程式碼為:Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1],在原容量大小的基礎上右移一位。

第二個部分就是將原來 table 中的元素複製到 nextTable 中,主要是遍歷複製的過程。 根據運算得到當前遍歷的陣列的位置 i,然後利用 tabAt 方法獲得 i 位置的元素再進行判斷:

  1. 如果這個位置為空,就在原 table 中的 i 位置放入 forwardNode 節點,這個也是觸發併發擴容的關鍵點;
  2. 如果這個位置是 Node 節點(fh>=0),如果它是一個連結串列的頭節點,就構造一個反序連結串列,把他們分別放在 nextTable 的 i 和 i+n 的位置上
  3. 如果這個位置是 TreeBin 節點(fh<0),也做一個反序處理,並且判斷是否需要 untreefi,把處理的結果分別放在 nextTable 的 i 和 i+n 的位置上
  4. 遍歷過所有的節點以後就完成了複製工作,這時讓 nextTable 作為新的 table,並且更新 sizeCtl 為新容量的 0.75 倍 ,完成擴容。設定為新容量的 0.75 倍程式碼為 sizeCtl = (n << 1) - (n >>> 1),仔細體會下是不是很巧妙,n<<1 相當於 n 左移一位表示 n 的兩倍即 2n,n>>>1,n 右移相當於 n 除以 2 即 0.5n,然後兩者相減為 2n-0.5n=1.5n,是不是剛好等於新容量的 0.75 倍即 2n*0.75=1.5n。最後用一個示意圖來進行總結(圖片摘自網路):
ConcurrentHashMap擴容示意圖
ConcurrentHashMap擴容示意圖

3.6 與 size 相關的一些方法

對於 ConcurrentHashMap 來說,這個 table 裡到底裝了多少東西其實是個不確定的數量,因為不可能在呼叫 size()方法的時候像 GC 的“stop the world”一樣讓其他執行緒都停下來讓你去統計,因此只能說這個數量是個估計值。對於這個估計值,ConcurrentHashMap 也是大費周章才計算出來的。

為了統計元素個數,ConcurrentHashMap 定義了一些變數和一個內部類

/**
 * A padded cell for distributing counts.  Adapted from LongAdder
 * and Striped64.  See their internal docs for explanation.
 */
@sun.misc.Contended static final class CounterCell {
    volatile long value;
    CounterCell(long x) { value = x; }
}

/******************************************/

/**

  • 實際上儲存的是hashmap中的元素個數 利用CAS鎖進行更新 但它並不用返回當前hashmap的元素個數

/ private transient volatile long baseCount; /*

  • Spinlock (locked via CAS) used when resizing and/or creating CounterCells. */ private transient volatile int cellsBusy;

/**

複製程式碼
    複製程式碼
  • Table of counter cells. When non-null, size is a power of 2. */ private transient volatile CounterCell[] counterCells; 複製程式碼

mappingCount 與 size 方法

mappingCountsize方法的類似 從給出的註釋來看,應該使用 mappingCount 代替 size 方法 兩個方法都沒有直接返回 basecount 而是統計一次這個值,而這個值其實也是一個大概的數值,因此可能在統計的時候有其他執行緒正在執行插入或刪除操作。

public int size() {
    long n = sumCount();
    return ((n < 0L) ? 0 :
            (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
            (int)n);
}
 /**
 * Returns the number of mappings. This method should be used
 * instead of {@link #size} because a ConcurrentHashMap may
 * contain more mappings than can be represented as an int. The
 * value returned is an estimate; the actual count may differ if
 * there are concurrent insertions or removals.
 *
 * @return the number of mappings
 * @since 1.8
 */
public long mappingCount() {
    long n = sumCount();
    return (n < 0L) ? 0L : n; // ignore transient negative values
}
 final long sumCount() {
    CounterCell[] as = counterCells; CounterCell a;
    long sum = baseCount;
    if (as != null) {
        for (int i = 0; i < as.length; ++i) {
            if ((a = as[i]) != null)
                sum += a.value;//所有counter的值求和
        }
    }
    return sum;
}
複製程式碼

addCount 方法

在 put 方法結尾處呼叫了 addCount 方法,把當前 ConcurrentHashMap 的元素個數+1 這個方法一共做了兩件事,更新 baseCount 的值,檢測是否進行擴容。

private final void addCount(long x, int check) {
    CounterCell[] as; long b, s;
    //利用CAS方法更新baseCount的值
    if ((as = counterCells) != null ||
        !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
        CounterCell a; long v; int m;
        boolean uncontended = true;
        if (as == null || (m = as.length - 1) < 0 ||
            (a = as[ThreadLocalRandom.getProbe() & m]) == null ||
            !(uncontended =
              U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
            fullAddCount(x, uncontended);
            return;
        }
        if (check <= 1)
            return;
        s = sumCount();
    }
    //如果check值大於等於0 則需要檢驗是否需要進行擴容操作
    if (check >= 0) {
        Node<K,V>[] tab, nt; int n, sc;
        while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
               (n = tab.length) < MAXIMUM_CAPACITY) {
            int rs = resizeStamp(n);
            //
            if (sc < 0) {
                if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                    sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                    transferIndex <= 0)
                    break;
                 //如果已經有其他執行緒在執行擴容操作
                if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                    transfer(tab, nt);
            }
            //當前執行緒是唯一的或是第一個發起擴容的執行緒  此時nextTable=null
            else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                         (rs << RESIZE_STAMP_SHIFT) + 2))
                transfer(tab, null);
            s = sumCount();
        }
    }
}
複製程式碼

4. 總結

JDK6,7 中的 ConcurrentHashmap 主要使用 Segment 來實現減小鎖粒度,分割成若干個 Segment,在 put 的時候需要鎖住 Segment,get 時候不加鎖,使用 volatile 來保證可見性,當要統計全域性時(比如 size),首先會嘗試多次計算 modcount 來確定,這幾次嘗試中,是否有其他執行緒進行了修改操作,如果沒有,則直接返回 size。如果有,則需要依次鎖住所有的 Segment 來計算。

1.8 之前 put 定位節點時要先定位到具體的 segment,然後再在 segment 中定位到具體的桶。而在 1.8 的時候摒棄了 segment 臃腫的設計,直接針對的是 Node[] tale 陣列中的每一個桶,進一步減小了鎖粒度。並且防止拉鍊過長導致效能下降,當連結串列長度大於 8 的時候採用紅黑樹的設計。

主要設計上的變化有以下幾點:

  1. 不採用 segment 而採用 node,鎖住 node 來實現減小鎖粒度。
  2. 設計了 MOVED 狀態 當 resize 的中過程中 執行緒 2 還在 put 資料,執行緒 2 會幫助 resize。
  3. 使用 3 個 CAS 操作來確保 node 的一些操作的原子性,這種方式代替了鎖。
  4. sizeCtl 的不同值來代表不同含義,起到了控制的作用。
  5. 採用 synchronized 而不是 ReentrantLock

更多關於 1.7 版本與 1.8 版本的 ConcurrentHashMap 的實現對比,可以參考這篇文章[3]

參考文章

1.8 版本 ConcurrentHashMap

  1. http://www.importnew.com/22007.html[4]
  2. http://www.jianshu.com/p/c0642afe03e0[5]

1.8 版本的 HashMap

http://www.importnew.com/20386.html[6]

參考資料

[1]

www.codercc.com: https://www.codercc.com

[2]

看這篇文章: https://juejin.im/post/5ae6dc04f265da0ba351d3ff

[3]

這篇文章: http://www.jianshu.com/p/e694f1e868ec

[4]

http://www.importnew.com/22007.html: http://www.importnew.com/22007.html

[5]

http://www.jianshu.com/p/c0642afe03e0: http://www.jianshu.com/p/c0642afe03e0

[6]

http://www.importnew.com/20386.html: http://www.importnew.com/20386.html

相關文章