死磕Java併發編程(8):CurrentHashMap如何實現高效地線程安全?在Java8中有哪些設計實現的演進?

這篇文章一開始我以爲會比較簡單,但是在深入源碼分析時,遇到了很大的阻礙,比前面我們分析AQS以及讀寫鎖的源碼要難理解的多,斷斷續續也寫了4天了。 如果你看完還是沒有理解的話,那我在這裏表示深深的歉意,同時也歡迎你和我一起溝通。

本文是死磕Java併發編程系列文章的第 8 篇,主角就是 java 併發包中提供的 CurrentHashMap 這是一個線程安全且高效的HashMap ,也是面試的高頻考點。本文主要圍繞 ConcurrentHashMap 如何實現高效地線程安全?以及在Java8中它從設計實現上有哪些演進?

網上關於 HashMap 和 ConcurrentHashMap 的文章確實不少,不過目前的很多分析資料還是基於其早期版本,所以纔想自己也寫一篇,把細節說清楚說透,尤其像 Java8 中的 ConcurrentHashMap 的演進設計實現,大部分文章都說不清楚。希望能降低大家學習的成本,不希望大家看了一篇又一篇文章,最終還是模模糊糊。

閱讀前提:

本文會涉及源碼分析,所以至少讀者要熟悉它們的接口使用,同時,對於併發,讀者至少要知道 CAS、ReentrantLock、UNSAFE 操作這幾個基本的知識,文中不會對這些知識進行介紹。

爲什麼需要 ConcurrentHashMap?

在併發編程中使用HashMap可能導致程序死循環。而使用線程安全的HashTable效率又非常低下(它的實現就是將put、get、size等方法加上 synchronized 關鍵字),基於以上兩個原因,便有了ConcurrentHashMap的登場機會。

可能有的同學對 HashMap 爲什麼會在併發中出現死循環從而導致 cpu 佔用達到100% 不太瞭解,這裏直接展示一段示例代碼,運行它就會出現死循環。

static final HashMap<String, String> map = new HashMap<String, String>(2);
Thread t = new Thread(new Runnable() {
    @Override
    public void run() {
        for (int i = 0; i < 100000; i++) {
            int finalI1 = i;
            new Thread(new Runnable() {
                @Override
                public void run() {
                    map.put(String.valueOf(finalI1), "");
                }
            }, "ftf" + i).start();
        }
    }
}, "ftf");
t.start();
t.join();

死循環的概率還是非常低的,比較難以重現。爲了提高出現概率,採用多次迭代測試。筆者在測試時 出現在 128次。

感興趣的同學可以用 jstack 分析下,網上有很多教程,這裏就不展開 排查過程了。原因就是:HashMap 在併發執行 put 操作時會引起死循環,是因爲多線程會導致 HashMap 的 Entry 鏈表形成環形數據結構,一旦形成環形數據結構,Entry 的 next 節點永遠不爲空,就會產生死循環獲 取 Entry 。從而導致CPU佔用將近100%。

Java7中ConcurrentHashMap分析

首先,我這裏強調,ConcurrentHashMap 的設計實現其實一直在演化,比如在 Java 8 中就發生了非常大的變化(Java 7 其實也有不少更新),所以,我這裏將比較分析結構、實現機制等方面,對比不同版本的主要區別。

在 Java7 中的實現是基於:

  • 分離鎖,也就是將內部進行分段(Segment),裏面則是 HashEntry 的數組,和 HashMap 類似,哈希相同的條目也是以鏈表形式存放。
  • HashEntry 內部使用 volatile 的 value 字段來保證可見性,也利用了不可變對象的機制以改進利用 Unsafe 提供的底層能力,比如 volatile access,去直接完成部分操作,以最優化性能,畢竟 Unsafe 中的很多操作都是 JVM intrinsic 優化過的。

具體實現可以理解爲:ConcurrentHashMap 是由 Segment 數組結構和 HashEntry 數組結構組成。Segment是一種可重入鎖(繼承了ReentrantLock),在ConcurrentHashMap裏扮演鎖的角色;HashEntry 則用於存儲鍵值對數據。一個 ConcurrentHashMap 裏包含一個 Segment 數組。Segment 的結構和 HashMap 類似,是一種 數組和鏈表結構。一個 Segment 裏包含一個 HashEntry 數組,每個 HashEntry 是一個鏈表結構的元 素,每個Segment 守護着一個 HashEntry 數組裏的元素,當對 HashEntry 數組的數據進行修改時, 必須首先獲得與它對應的Segment鎖。

初始化

在構造的時候,Segment 的數量由所謂的 concurrentcyLevel 決定,默認是 16,所以理論上,這個時候,最多可以同時支持 16 個線程併發寫,只要它們的操作分別分佈在不同的 Segment 上。也可以在相應構造函數直接指定。注意,Java 需要它是 2 的冪數值,如果輸入是類似 15 這種非冪值,會被自動調整到 16 之類 2 的冪數值。並且一旦初始化後,它是不可以擴容的。

ConcurrentHashMap 初始化方法是通過 initialCapacity 、loadFactor 和 concurrencyLevel 等幾個參數來初始化segment數組、段偏移量 segmentShift 、段掩碼 segmentMask 和每個 segment 裏的 HashEntry 數組來實現的。

下面結合源代碼一起來看下,爲方便理解,我直接註釋在代碼段裏:

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;
    // Find power-of-two sizes best matching arguments
    int sshift = 0;
    int ssize = 1;
    // 計算並行級別 ssize,因爲要保持並行級別是 2 的 n 次方
    while (ssize < concurrencyLevel) {
        ++sshift;
        ssize <<= 1;
    }
    // 我們這裏先不要那麼燒腦,用默認值,concurrencyLevel 爲 16,sshift 爲 4
    // 那麼計算出 segmentShift 爲 28,segmentMask 爲 15,後面會用到這兩個值
    this.segmentShift = 32 - sshift;
    this.segmentMask = ssize - 1;

    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;

    // initialCapacity 是設置整個 map 初始的大小,
    // 這裏根據 initialCapacity 計算 Segment 數組中每個位置可以分到的大小
    // 如 initialCapacity 爲 64,那麼每個 Segment 可以分到 4 個
    int c = initialCapacity / ssize;
    if (c * ssize < initialCapacity)
        ++c;
    // 默認 MIN_SEGMENT_TABLE_CAPACITY 是 2,這個值也是有講究的,因爲這樣的話,對於具體的槽上,插入一個元素不至於擴容,插入第二個的時候纔會擴容
    int cap = MIN_SEGMENT_TABLE_CAPACITY; 
    while (cap < c)
        cap <<= 1;

    // 創建 Segment 數組,
    // 並創建數組的第一個元素 segment[0]
    Segment<K,V> s0 =
        new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
                         (HashEntry<K,V>[])new HashEntry[cap]);
    Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
    // 往數組寫入 segment[0]
    UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
    this.segments = ss;
}

初始化完成,我們得到了一個 Segment 數組。這裏之所以 segments 數組的長度必須是2的N次冪,主要是爲了能通過按位與的散列算法來定位 segments 數組的索引。

注意:concurrencyLevel 的最大值是65535,這意味着 segments 數組的長度最大爲65536, 對應的二進制是16位。

爲了加深讀者理解,下面來分析下,當我們用 new ConcurrentHashMap() 無參構造函數進行初始化的,那麼初始化完成後:

  • Segment 數組長度爲 16,不可以擴容
  • Segment[i] 的默認大小爲 2,負載因子是 0.75,得出初始閾值爲 1.5,也就是以後插入第一個元素不會觸發擴容,插入第二個會進行第一次擴容
  • 這裏初始化了 segment[0],其他位置還是 null,至於爲什麼要初始化 segment[0],後面的代碼會介紹
  • 當前段偏移量 segmentShift 的值爲 32 - 4 = 28,段掩碼 segmentMask 爲 16 - 1 = 15,這兩個值馬上就會用到

get 操作

get 操作需要保證的是可見性,所以並沒有什麼同步邏輯。

  1. 計算 hash 值,找到 segment 數組中的具體位置
  2. segment 中也是一個數組(HashEntry數組),根據 hash 找到數組中具體的位置
  3. 到這裏是鏈表了,HashEntry 是鏈表中的元素,順着鏈表進行查找即可
public V get(Object key) {
    Segment<K,V> s; // manually integrate access methods to reduce overhead
    HashEntry<K,V>[] tab;
    // 1. hash 值,32位
    int h = hash(key);
    // 利用位操作替換普通數學運算,將hash值無符號左移段偏移量位,即取高四位,在與上段掩碼(15二進制位1111)
    long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
    // 2. 根據 hash 找到對應的 segment,利用Unsafe直接進行volatile access
    if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
        (tab = s.table) != null) {
        // 3. 找到segment 內部數組相應位置的鏈表,遍歷
        for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
                 (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
             e != null; e = e.next) {
            K k;
            if ((k = e.key) == key || (e.hash == h && key.equals(k)))
                return e.value;
        }
    }
    return null;
}

put操作

對於 put 操作,首先是通過二次哈希避免哈希衝突,然後以 Unsafe 調用方式,直接獲取相應的 Segment,然後進行線程安全的 put 操作:

public V put(K key, V value) {
    Segment<K,V> s;
    if (value == null)
        throw new NullPointerException();
    // 1. 二次哈希,以保證數據的分散性,避免哈希衝突
 int hash = hash(key.hashCode());
    // 2. 根據 hash 值找到 Segment 數組中的位置 j
    //    hash 是 32 位,無符號右移 segmentShift(28) 位,剩下高 4 位,
    //    然後和 segmentMask(15) 做一次與操作,也就是說 j 是 hash 值的高 4 位,也就是segment的數組下標
    int j = (hash >>> segmentShift) & segmentMask;
    // 剛剛說了,初始化的時候初始化了 segment[0],但是其他位置還是 null,
    // ensureSegment(j) 對 segment[j] 進行初始化
    if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
         (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
        s = ensureSegment(j);
    // 3. 插入新值到 槽 s 中
    return s.put(key, hash, value, false);
}

其核心邏輯實現在下面的內部方法中:

final V put(K key, int hash, V value, boolean onlyIfAbsent) {
    // 在往該 segment 寫入前,需要先獲取該 segment 的獨佔鎖
    //    先看主流程,後面還會具體介紹這部分內容
    HashEntry<K,V> node = tryLock() ? null :
        scanAndLockForPut(key, hash, value);
    V oldValue;
    try {
        // 這個是 segment 內部的數組
        HashEntry<K,V>[] tab = table;
        // 再利用 hash 值,求應該放置的數組下標
        int index = (tab.length - 1) & hash;
        // first 是數組該位置處的鏈表的表頭
        HashEntry<K,V> first = entryAt(tab, index);

        // 下面這串 for 循環雖然很長,不過也很好理解,想象當前位置鏈表不爲空則先遍歷找是否存在,如果存在則覆蓋,否則放到合適的位置
        for (HashEntry<K,V> e = first;;) {
            if (e != null) {
                K k;
                if ((k = e.key) == key ||
                    (e.hash == hash && key.equals(k))) {
                    oldValue = e.value;
                    if (!onlyIfAbsent) {
                        // 覆蓋舊值
                        e.value = value;
                        ++modCount;
                    }
                    break;
                }
                // 繼續順着鏈表走
                e = e.next;
            }
            else {
                // node 到底是不是 null,這個要看獲取鎖的過程,不過和這裏都沒有關係。
                // 如果不爲 null,那就直接將它設置爲鏈表表頭;如果是null,初始化並設置爲鏈表表頭。
                if (node != null)
                    node.setNext(first);
                else
                    node = new HashEntry<K,V>(hash, key, value, first);
                int c = count + 1;
                // 如果超過了該 segment 的閾值,這個 segment 需要擴容
                if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                    rehash(node); // 擴容後面也會具體分析
                else
                    // 沒有達到閾值,將 node 放到數組 tab 的 index 位置,
                    // 其實就是將新的節點設置成原鏈表的表頭
                    setEntryAt(tab, index, node);
                ++modCount;
                count = c;
                oldValue = null;
                break;
            }
        }
    } finally {
        // 解鎖
        unlock();
    }
    return oldValue;
}

rehash:擴容操作

重複一下,segment 數組不能擴容,擴容是 segment 數組某個位置內部的數組 HashEntry<K,V>[] 進行擴容,擴容後,容量爲原來的 2 倍。

首先,我們要回顧一下觸發擴容的地方,put 的時候,如果判斷該值的插入會導致該 segment 的元素個數超過閾值,那麼先進行擴容,再插值,讀者這個時候可以回去 put 方法看一眼。

該方法不需要考慮併發,因爲到這裏的時候,是持有該 segment 的獨佔鎖的。

// 方法參數上的 node 是這次擴容後,需要添加到新的數組中的數據。
private void rehash(HashEntry<K,V> node) {
    HashEntry<K,V>[] oldTable = table;
    int oldCapacity = oldTable.length;
    // 2 倍
    int newCapacity = oldCapacity << 1;
    threshold = (int)(newCapacity * loadFactor);
    // 創建新數組
    HashEntry<K,V>[] newTable =
        (HashEntry<K,V>[]) new HashEntry[newCapacity];
    // 新的掩碼,如從 16 擴容到 32,那麼 sizeMask 爲 31,對應二進制 ‘000...00011111’
    int sizeMask = newCapacity - 1;

    // 遍歷原數組,老套路,將原數組位置 i 處的鏈表拆分到 新數組位置 i 和 i+oldCap 兩個位置
    for (int i = 0; i < oldCapacity ; i++) {
        // e 是鏈表的第一個元素
        HashEntry<K,V> e = oldTable[i];
        if (e != null) {
            HashEntry<K,V> next = e.next;
            // 計算應該放置在新數組中的位置,
            // 假設原數組長度爲 16,e 在 oldTable[3] 處,那麼 idx 只可能是 3 或者是 3 + 16 = 19
            int idx = e.hash & sizeMask;
            // 該位置處只有一個元素,那比較好辦,直接放到新數組中對應的位置
            if (next == null)   
                newTable[idx] = e;
            else { // Reuse consecutive sequence at same slot
                // e 是鏈表表頭
                HashEntry<K,V> lastRun = e;
                // idx 是當前鏈表的頭結點 e 的新位置
                int lastIdx = idx;

                // 下面這個 for 循環會找到一個 lastRun 節點,這個節點之後的所有元素是將要放到一起的
                for (HashEntry<K,V> last = next;
                     last != null;
                     last = last.next) {
                    int k = last.hash & sizeMask;
                    if (k != lastIdx) {
                        lastIdx = k;
                        lastRun = last;
                    }
                }
                // 將 lastRun 及其之後的所有節點組成的這個鏈表放到 lastIdx 這個位置
                newTable[lastIdx] = lastRun;
                // 下面的操作是處理 lastRun 之前的節點,
                //    這些節點可能分配在另一個鏈表中,也可能分配到上面的那個鏈表中
                for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
                    V v = p.value;
                    int h = p.hash;
                    int k = h & sizeMask;
                    HashEntry<K,V> n = newTable[k];
                    newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
                }
            }
        }
    }
    // 將新來的 node 放到新數組中剛剛的 兩個鏈表之一 的 頭部
    int nodeIndex = node.hash & sizeMask; // add the new node
    node.setNext(newTable[nodeIndex]);
    newTable[nodeIndex] = node;
    table = newTable;
}

上面有兩個挨着的 for 循環,第一個 for 有什麼用呢?

這塊代碼我看的時候真的是很難理解,反覆看了好幾遍,主要原因還是對鏈表操作不太熟悉,這裏爲大家在解釋下,幫助理解。這裏需要進行第一個 for 循環,主要是因爲擴容後,原來數組位置 i 的 HashEntry 是一個鏈表,那麼這個鏈表的元素對應擴容後的數組位置必然是 i 或 i+oldCap。第一個循環就是爲遍歷當前位置 i 的鏈表找到最後一個在新數組中位置相同的節點 lastRun。

如果沒有第一個 for 循環,也是可以工作的,但是,這個 for 循環下來,如果 lastRun 的後面還有比較多的節點,那麼這次就是值得的。因爲我們只需要克隆 lastRun 前面的節點,後面的一串節點跟着 lastRun 進行賦值就可以了,不需要做任何操作。

Doug Lea 大神這塊的想法一般人可能是想不到的,畢竟作爲併發包中的基礎類 都是爲了將併發性能做到極致的。但是也有最差的情況,就是找到的 lastRun 是鏈表的最後一個元素,或者排在倒數,那麼這次遍歷就顯得多餘了,而且浪費了性能。不過 Doug Lea 也說了,根據統計,如果使用默認的閾值,大約只有 1/6 的節點需要克隆

size 操作

知道了 ConcurrentHashMap 通過分段鎖實現高性能且線程安全的原理。試想,如果不進行同步,簡單的計算所有 Segment 的總值,可能會因爲併發 put,導致結果不準確,但是直接鎖定所有 Segment 進行計算,就會變得非常昂貴。

所以,ConcurrentHashMap 的實現是通過重試機制(RETRIES_BEFORE_LOCK,指定重試次數 2),來試圖獲得可靠值。如果沒有監控到發生變化(通過對比 Segment.modCount),就直接返回,否則獲取鎖進行操作。

Java8中ConcurrentHashMap分析

在 Java 8 和之後的版本中,ConcurrentHashMap 發生了哪些變化呢?

  • Java8 對 HashMap 進行了一些修改,最大的不同就是利用了紅黑樹,所以其由 數組+鏈表+紅黑樹 組成。

  • 因爲不再使用 Segment,初始化操作大大簡化,修改爲 lazy-load 形式,這樣可以有效避免初始開銷,解決了老版本很多人抱怨的這一點。

  • 數據存儲利用 volatile 來保證可見性。

  • 使用 CAS 等操作,在特定場景進行無鎖併發操作。

這裏介紹一個最常問的問題:Java8 爲什麼使用紅黑樹呢?

根據 Java7 HashMap 的介紹,我們知道,查找的時候,根據 hash 值我們能夠快速定位到數組的具體下標,但是之後的話,需要順着鏈表一個個比較下去才能找到我們需要的,時間複雜度取決於鏈表的長度,爲 O(n)

爲了降低這部分的開銷,在 Java8 中,當鏈表中的元素達到了 8 個時,會將鏈表轉換爲紅黑樹,在這些位置進行查找的時候可以降低時間複雜度爲 O(logN)

注意,上圖是示意圖,主要是描述結構,不會達到這個狀態的,因爲這麼多數據的時候早就擴容了。

Java7 中使用 HashEntry 來代表每個 HashMap 中的數據節點,Java8 中使用 Node,基本沒有區別,都是 key,value,hash 和 next 這四個屬性,不過,Node 只能用於鏈表的情況,紅黑樹的情況需要使用 TreeNode

先看看現在的數據存儲內部實現,我們可以發現 Key 是 final 的,因爲在生命週期中,一個條目的 Key 發生變化是不可能的;與此同時 val,則聲明爲 volatile,以保證可見性。

static class Node<K,V> implements Map.Entry<K,V> {
 final int hash;
 final K key;
 volatile V val;
 volatile Node<K,V> next;
 // …
}

爲了提高大家的閱讀體驗,我這裏就不再介紹 get 方法和構造函數了,相對比較簡單,相信你如果看懂了 Java7 的實現一定沒有啥問題的。直接看併發的 put 是如何實現的。

put操作

final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    // 得到 hash 值
    int hash = spread(key.hashCode());
    // 用於記錄相應鏈表的長度
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        // 如果數組"空",進行數組初始化
        if (tab == null || (n = tab.length) == 0)
            // 初始化數組
            tab = initTable();

        // 找該 hash 值對應的數組下標,得到第一個節點 f
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            // 如果數組該位置爲空,
            // 用一次 CAS 操作將這個新值放入其中即可,這個 put 操作差不多就結束了,可以拉到最後面
            // 如果 CAS 失敗,那就是有併發操作,進到下一個循環就好了
            if (casTabAt(tab, i, null,
                         new Node<K,V>(hash, key, value, null)))
                break;                   // no lock when adding to empty bin
        }
        // hash 居然可以等於 MOVED,這個需要到後面才能看明白,不過從名字上也能猜到,肯定是因爲在擴容
        else if ((fh = f.hash) == MOVED)
            // 幫助數據遷移,這個等到看完數據遷移部分的介紹後,再理解這個就很簡單了
            tab = helpTransfer(tab, f);

        else { // 到這裏就是說,f 是該位置的頭結點,而且不爲空

            V oldVal = null;
            // 獲取數組該位置的頭結點的監視器鎖
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) { // 頭結點的 hash 值大於 0,說明是鏈表
                        // 用於累加,記錄鏈表的長度
                        binCount = 1;
                        // 遍歷鏈表
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                            // 如果發現了"相等"的 key,判斷是否要進行值覆蓋,然後也就可以 break 了
                            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;
                            }
                        }
                    }
                    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;
                        }
                    }
                }
            }

            if (binCount != 0) {
                // 判斷是否要將鏈表轉換爲紅黑樹,臨界值和 HashMap 一樣,也是 8
                if (binCount >= TREEIFY_THRESHOLD)
                    // 這個方法和 HashMap 中稍微有一點點不同,那就是它不是一定會進行紅黑樹轉換,
                    // 如果當前數組的長度小於 64,那麼會選擇進行數組擴容,而不是轉換爲紅黑樹
                    // 具體源碼我們就不看了,擴容部分後面說
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    // 
    addCount(1L, binCount);
    return null;
}

put 的主流程看完了,但是至少留下了幾個問題,第一個是初始化,第二個是擴容,第三個是幫助數據遷移,這些我們都會在後面進行一一介紹。

初始化數組:initTable

從上面的 put 操作可以看到,數組初始化是在 put 操作時進行的,採用的 lazy-load 形式。

這個比較簡單,主要就是初始化一個合適大小的數組,然後會設置 sizeCtl。

初始化方法中的併發問題是通過對 sizeCtl 進行一個 CAS 操作來控制的。

private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    while ((tab = table) == null || tab.length == 0) {
        // 初始化的"功勞"被其他線程"搶去"了
        if ((sc = sizeCtl) < 0)
            Thread.yield(); // lost initialization race; just spin
        // CAS 一下,將 sizeCtl 設置爲 -1,代表搶到了鎖
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
            try {
                if ((tab = table) == null || tab.length == 0) {
                    // DEFAULT_CAPACITY 默認初始容量是 16
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    @SuppressWarnings("unchecked")
                    // 初始化數組,長度爲 16 或初始化時提供的長度
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    // 將這個數組賦值給 table,table 是 volatile 的
                    table = tab = nt;
                    // 如果 n 爲 16 的話,那麼這裏 sc = 12
                    // 其實就是 0.75 * n
                    sc = n - (n >>> 2);
                }
            } finally {
                // 設置 sizeCtl 爲 sc,我們就當是 12 吧
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

鏈表轉爲紅黑樹:treeifyBin

這裏需要注意:前面我們在 put 源碼分析也說過,treeifyBin 不一定就會進行紅黑樹轉換,也可能是僅僅做數組擴容。我們還是進行源碼分析吧。

private final void treeifyBin(Node<K,V>[] tab, int index) {
    Node<K,V> b; int n, sc;
    if (tab != null) {
        if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
            // 所以,如果數組長度小於 64 的時候,其實也就是 32 或者 16 或者更小的時候,會進行數組擴容
            tryPresize(n << 1);
        // b 是頭結點
        else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
            // 加鎖
            synchronized (b) {
                if (tabAt(tab, index) == b) {
                    TreeNode<K,V> hd = null, tl = null;
                    // 下面就是遍歷鏈表,建立一顆紅黑樹
                    for (Node<K,V> e = b; e != null; e = e.next) {
                        TreeNode<K,V> p =
                            new TreeNode<K,V>(e.hash, e.key, e.val,
                                              null, null);
                        if ((p.prev = tl) == null)
                            hd = p;
                        else
                            tl.next = p;
                        tl = p;
                    }
                    // 將紅黑樹設置到數組相應位置中
                    setTabAt(tab, index, new TreeBin<K,V>(hd));
                }
            }
        }
    }
}

擴容:tryPresize

如果說 Java8 ConcurrentHashMap 的源碼不簡單,那麼說的就是擴容操作和遷移操作。

這裏的擴容也是做翻倍擴容的,擴容後數組容量爲原來的 2 倍。

這個方法要完完全全看懂還需要看之後的 transfer 方法,讀者應該提前知道這點。

// 首先要說明的是,方法參數 size 傳進來的時候就已經翻了倍了
private final void tryPresize(int size) {
    // c:size 的 1.5 倍,再加 1,再往上取最近的 2 的 n 次方。
    int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
        tableSizeFor(size + (size >>> 1) + 1);
    // 目前容器大小
    int sc;
    while ((sc = sizeCtl) >= 0) {
        Node<K,V>[] tab = table; int n;
        // 這個 if 分支和之前說的初始化數組的代碼基本上是一樣的,在這裏,我們可以不用管這塊代碼
        if (tab == null || (n = tab.length) == 0) {
            n = (sc > c) ? sc : c;
            if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
                try {
                    if (table == tab) {
                        @SuppressWarnings("unchecked")
                        Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                        table = nt;
                        // 16-4=12
                        sc = n - (n >>> 2);
                    }
                } finally {
                    sizeCtl = sc;
                }
            }
        }
        // 小於目前大小或者達到最大值直接返回
        else if (c <= sc || n >= MAXIMUM_CAPACITY)
            break;
        // 說明是tab過程中沒有發生變化,類似於懶加載的雙重檢查
        else if (tab == table) {
            // value = 32795
            int rs = resizeStamp(n);
            if (sc < 0) {
                Node<K,V>[] nt;
                if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                    sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                    transferIndex <= 0)
                    break;
                // 2. 用 CAS 將 sizeCtl 加 1,然後執行 transfer 方法 此時 nextTab 不爲 null
                if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                    transfer(tab, nt);
            }
            // 1. 將 sizeCtl 設置爲 (rs << RESIZE_STAMP_SHIFT) + 2)
            // 我是沒看懂這個值真正的意義是什麼?不過可以計算出來的是,結果是一個比較大的負數
            // 調用 transfer 方法,此時 nextTab 參數爲 null
            else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                         (rs << RESIZE_STAMP_SHIFT) + 2))
                transfer(tab, null);
        }
    }
}

這個方法的核心在於 sizeCtl 值的操作,首先將其設置爲一個負數,然後執行 transfer(tab, null),再下一個循環將 sizeCtl 加 1,並執行 transfer(tab, nt),之後可能是繼續 sizeCtl 加 1,並執行 transfer(tab, nt)。

所以,可能的操作就是執行 1 次 transfer(tab, null) + 多次 transfer(tab, nt),這裏怎麼結束循環的需要看完 transfer 源碼才清楚。

數據遷移:transfer

下面這個方法有點長,將原來的 tab 數組的元素遷移到新的 nextTab 數組中。

雖然我們之前說的 tryPresize 方法中多次調用 transfer 不涉及多線程,但是這個 transfer 方法可以在其他地方被調用,典型地,我們之前在說 put 方法的時候就說過了,請往上看 put 方法,是不是有個地方調用了 helpTransfer 方法,helpTransfer 方法會調用 transfer 方法的。

此方法支持多線程執行,外圍調用此方法的時候,會保證第一個發起數據遷移的線程,nextTab 參數爲 null,之後再調用此方法的時候,nextTab 不會爲 null。

閱讀源碼之前,先要理解併發操作的機制。原數組長度爲 n,所以我們有 n 個遷移任務,讓每個線程每次負責一個小任務是最簡單的,每做完一個任務再檢測是否有其他沒做完的任務,幫助遷移就可以了,而 Doug Lea 使用了一個 stride,簡單理解就是步長,每個線程每次負責遷移其中的一部分,如每次遷移 16 個小任務。所以,我們就需要一個全局的調度者來安排哪個線程執行哪幾個任務,這個就是屬性 transferIndex 的作用。

第一個發起數據遷移的線程會將 transferIndex 指向原數組最後的位置,然後從後往前的 stride 個任務屬於第一個線程,然後將 transferIndex 指向新的位置,再往前的 stride 個任務屬於第二個線程,依此類推。當然,這裏說的第二個線程不是真的一定指代了第二個線程,也可以是同一個線程,這個讀者應該能理解吧。其實就是將一個大的遷移任務分爲了一個個任務包。

private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    int n = tab.length, stride;
    // stride 在單核下直接等於 n,多核模式下爲 (n>>>3)/NCPU,最小值是 16
    // stride 可以理解爲”步長“,有 n 個位置是需要進行遷移的,
    // 將這 n 個任務分爲多個任務包,每個任務包有 stride 個任務
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
        stride = MIN_TRANSFER_STRIDE; // subdivide range
    // 如果 nextTab 爲 null,先進行一次初始化
    // 前面我們說了,外圍會保證第一個發起遷移的線程調用此方法時,參數 nextTab 爲 null
    // 之後參與遷移的線程調用此方法時,nextTab 不會爲 null
    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 是 ConcurrentHashMap 中的屬性
        nextTable = nextTab;
        // transferIndex 也是 ConcurrentHashMap 的屬性,用於控制遷移的位置
        transferIndex = n;
    }
    int nextn = nextTab.length;
    // ForwardingNode 翻譯過來就是正在被遷移的 Node
    // 這個構造方法會生成一個Node,key、value 和 next 都爲 null,關鍵是 hash 爲 MOVED
    // 後面我們會看到,原數組中位置 i 處的節點完成遷移工作後,
    // 就會將位置 i 處設置爲這個 ForwardingNode,用來告訴其他線程該位置已經處理過了
    // 所以它其實相當於是一個標誌。
    ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
    // advance 指的是做完了一個位置的遷移工作,可以準備做下一個位置的了
    boolean advance = true;
    boolean finishing = false; // to ensure sweep before committing nextTab
    // 下面這個 for 循環,最難理解的在前面,而要看懂它們,應該先看懂後面的,然後再倒回來
    // i 是位置索引,bound 是邊界,注意是從後往前
    for (int i = 0, bound = 0;;) {
        Node<K,V> f; int fh;
        // 下面這個 while 真的是不好理解
        // advance 爲 true 表示可以進行下一個位置的遷移了
        // 簡單理解爲:i 指向了 transferIndex,bound 指向了 transferIndex-stride
        while (advance) {
            int nextIndex, nextBound;
            if (--i >= bound || finishing)
                advance = false;
            // 將 transferIndex 值賦給 nextIndex
            // 這裏 transferIndex 一旦小於等於 0,說明原數組的所有位置都有相應的線程去處理了
            else if ((nextIndex = transferIndex) <= 0) {
                i = -1;
                advance = false;
            }
            else if (U.compareAndSwapInt
                     (this, TRANSFERINDEX, nextIndex,
                      nextBound = (nextIndex > stride ?
                                   nextIndex - stride : 0))) {
                // 看括號中的代碼,nextBound 是這次遷移任務的邊界,注意,是從後往前
                bound = nextBound;
                i = nextIndex - 1;
                advance = false;
            }
        }
        if (i < 0 || i >= n || i + n >= nextn) {
            int sc;
            // 所有的遷移操作已經完成
            if (finishing) {
                nextTable = null;
                // 將新的 nextTab 賦值給 table 屬性,完成遷移
                table = nextTab;
                // 重新計算 sizeCtl:n 是原數組長度,所以 sizeCtl 得出的值將是新數組長度的 0.75 倍
                sizeCtl = (n << 1) - (n >>> 1);
                return;
            }
            // 之前我們說過,sizeCtl 在遷移前會設置爲 (rs << RESIZE_STAMP_SHIFT) + 2
            // 然後,每有一個線程參與遷移就會將 sizeCtl 加 1,
            // 這裏使用 CAS 操作對 sizeCtl 進行減 1,代表做完了屬於自己的任務
            if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
                // 任務結束,方法退出
                if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
                    return;
                // 到這裏,說明 (sc - 2) == resizeStamp(n) << RESIZE_STAMP_SHIFT,
                // 也就是說,所有的遷移任務都做完了,也就會進入到上面的 if(finishing){} 分支了
                finishing = advance = true;
                i = n; // recheck before commit
            }
        }
        // 如果位置 i 處是空的,沒有任何節點,那麼放入剛剛初始化的 ForwardingNode ”空節點“
        else if ((f = tabAt(tab, i)) == null)
            advance = casTabAt(tab, i, null, fwd);
        // 該位置處是一個 ForwardingNode,代表該位置已經遷移過了
        else if ((fh = f.hash) == MOVED)
            advance = true; // already processed
        else {
            // 對數組該位置處的結點加鎖,開始處理數組該位置處的遷移工作
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    Node<K,V> ln, hn;
                    // 頭結點的 hash 大於 0,說明是鏈表的 Node 節點
                    if (fh >= 0) {
                        // 下面這一塊和 Java7 中的 ConcurrentHashMap 遷移是差不多的,
                        // 需要將鏈表一分爲二,
                        // 找到原鏈表中的 lastRun,然後 lastRun 及其之後的節點是一起進行遷移的
                        // lastRun 之前的節點需要進行克隆,然後分到兩個鏈表中
                        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);
                        }
                        // 其中的一個鏈表放在新數組的位置 i
                        setTabAt(nextTab, i, ln);
                        // 另一個鏈表放在新數組的位置 i+n
                        setTabAt(nextTab, i + n, hn);
                        // 將原數組該位置處設置爲 fwd,代表該位置已經處理完畢,
                        // 其他線程一旦看到該位置的 hash 值爲 MOVED,就不會進行遷移了
                        setTabAt(tab, i, fwd);
                        // advance 設置爲 true,代表該位置已經遷移完畢
                        advance = true;
                    }
                    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;
                            }
                        }
                        // 如果一分爲二後,節點數少於 8,那麼將紅黑樹轉換回鏈表
                        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;
                        // 將 ln 放置在新數組的位置 i
                        setTabAt(nextTab, i, ln);
                        // 將 hn 放置在新數組的位置 i+n
                        setTabAt(nextTab, i + n, hn);
                        // 將原數組該位置處設置爲 fwd,代表該位置已經處理完畢,
                        // 其他線程一旦看到該位置的 hash 值爲 MOVED,就不會進行遷移了
                        setTabAt(tab, i, fwd);
                        // advance 設置爲 true,代表該位置已經遷移完畢
                        advance = true;
                    }
                }
            }
        }
    }
}

說到底,transfer 這個方法並沒有實現所有的遷移任務,每次調用這個方法只實現了 transferIndex 往前 stride 個位置的遷移工作,其他的需要由外圍來控制。

這個時候,再回去仔細看 tryPresize 方法可能就會更加清晰一些了。

總結

今天我從線程安全問題開始,分析爲什麼要使用ConcurrentHashMap,進而分析了 Java 7 和 Java 8 中 ConcurrentHashMap 是如何設計實現的,從源碼層面說明白了具體的實現邏輯。其實仔細認真讀懂後你會發現其實也不是太難。希望本文讓你對 ConcurrentHashMap 面試相關問題輕鬆的應對,同時作爲併發編程技巧對你在日常開發可以有所幫助。

筆者水平有限,文章難免會有紕漏,如有錯誤歡迎一起交流探討,我會第一時間更正的。都看到這裏了,碼字不易,可愛的你記得 "點贊" 哦,我需要你的正向反饋。

(全文完)fighting!

參考資料:

  1. 周志明:《深入理解 Java 虛擬機》
  2. 方騰飛:《Java 併發編程的藝術》
  3. https://www.javadoop.com/

掃碼關注

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