併發程式設計——ConcurrentHashMap#transfer() 擴容逐行分析

莫那·魯道發表於2018-05-19

前言

ConcurrentHashMap 是併發中的重中之重,也是最常用的資料結果,之前的文章中,我們介紹了 putVal 方法。併發程式設計之 ConcurrentHashMap(JDK 1.8) putVal 原始碼分析。其中分析了 initTable 方法和 putVal 方法,但也留下了一句話:

這篇文章僅僅是 ConcurrentHashMap 的開頭,關於 ConcurrentHashMap 裡面的精華太多,值得我們好好學習。

說道精華,他的擴容方法絕對是精華,要知道,ConcurrentHashMap 擴容是高度併發的。

今天來逐行分析原始碼。

先說結論

首先說結論。原始碼加註釋我會放在後面。該方法的執行邏輯如下:

  1. 通過計算 CPU 核心數和 Map 陣列的長度得到每個執行緒(CPU)要幫助處理多少個桶,並且這裡每個執行緒處理都是平均的。預設每個執行緒處理 16 個桶。因此,如果長度是 16 的時候,擴容的時候只會有一個執行緒擴容。

  2. 初始化臨時變數 nextTable。將其在原有基礎上擴容兩倍。

  3. 死迴圈開始轉移。多執行緒併發轉移就是在這個死迴圈中,根據一個 finishing 變數來判斷,該變數為 true 表示擴容結束,否則繼續擴容。

    3.1 進入一個 while 迴圈,分配陣列中一個桶的區間給執行緒,預設是 16. 從大到小進行分配。當拿到分配值後,進行 i-- 遞減。這個 i 就是陣列下標。(其中有一個 bound 引數,這個引數指的是該執行緒此次可以處理的區間的最小下標,超過這個下標,就需要重新領取區間或者結束擴容,還有一個 advance 引數,該引數指的是是否繼續遞減轉移下一個桶,如果為 true,表示可以繼續向後推進,反之,說明還沒有處理好當前桶,不能推進) 3.2 出 while 迴圈,進 if 判斷,判斷擴容是否結束,如果擴容結束,清空臨死變數,更新 table 變數,更新庫容閾值。如果沒完成,但已經無法領取區間(沒了),該執行緒退出該方法,並將 sizeCtl 減一,表示擴容的執行緒少一個了。如果減完這個數以後,sizeCtl 迴歸了初始狀態,表示沒有執行緒再擴容了,該方法所有的執行緒擴容結束了。(這裡主要是判斷擴容任務是否結束,如果結束了就讓執行緒退出該方法,並更新相關變數)。然後檢查所有的桶,防止遺漏。 3.3 如果沒有完成任務,且 i 對應的槽位是空,嘗試 CAS 插入佔位符,讓 putVal 方法的執行緒感知。 3.4 如果 i 對應的槽位不是空,且有了佔位符,那麼該執行緒跳過這個槽位,處理下一個槽位。 3.5 如果以上都是不是,說明這個槽位有一個實際的值。開始同步處理這個桶。 3.6 到這裡,都還沒有對桶內資料進行轉移,只是計算了下標和處理區間,然後一些完成狀態判斷。同時,如果對應下標內沒有資料或已經被佔位了,就跳過了。

  4. 處理每個桶的行為都是同步的。防止 putVal 的時候向連結串列插入資料。 4.1 如果這個桶是連結串列,那麼就將這個連結串列根據 length 取於拆成兩份,取於結果是 0 的放在新表的低位,取於結果是 1 放在新表的高位。 4.2 如果這個桶是紅黑數,那麼也拆成 2 份,方式和連結串列的方式一樣,然後,判斷拆分過的樹的節點數量,如果數量小於等於 6,改造成連結串列。反之,繼續使用紅黑樹結構。 4.3 到這裡,就完成了一個桶從舊錶轉移到新表的過程。

好,以上,就是 transfer 方法的總體邏輯。還是挺複雜的。再進行精簡,分成 3 步驟:

  1. 計算每個執行緒可以處理的桶區間。預設 16.
  2. 初始化臨時變數 nextTable,擴容 2 倍。
  3. 死迴圈,計算下標。完成總體判斷。
  4. 1 如果桶內有資料,同步轉移資料。通常會像連結串列拆成 2 份。

大體就是上的的 3 個步驟。

再來看看原始碼和註釋。

再看原始碼分析

原始碼加註釋:

/**
 * Moves and/or copies the nodes in each bin to new table. See
 * above for explanation.
 * 
 * transferIndex 表示轉移時的下標,初始為擴容前的 length。
 * 
 * 我們假設長度是 32
 */
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    int n = tab.length, stride;
    // 將 length / 8 然後除以 CPU核心數。如果得到的結果小於 16,那麼就使用 16。
    // 這裡的目的是讓每個 CPU 處理的桶一樣多,避免出現轉移任務不均勻的現象,如果桶較少的話,預設一個 CPU(一個執行緒)處理 16 個桶
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
        stride = MIN_TRANSFER_STRIDE; // subdivide range 細分範圍 stridea:TODO
    // 新的 table 尚未初始化
    if (nextTab == null) {            // initiating
        try {
            // 擴容  2 倍
            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
            // 更新
            nextTab = nt;
        } catch (Throwable ex) {      // try to cope with OOME
            // 擴容失敗, sizeCtl 使用 int 最大值。
            sizeCtl = Integer.MAX_VALUE;
            return;// 結束
        }
        // 更新成員變數
        nextTable = nextTab;
        // 更新轉移下標,就是 老的 tab 的 length
        transferIndex = n;
    }
    // 新 tab 的 length
    int nextn = nextTab.length;
    // 建立一個 fwd 節點,用於佔位。當別的執行緒發現這個槽位中是 fwd 型別的節點,則跳過這個節點。
    ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
    // 首次推進為 true,如果等於 true,說明需要再次推進一個下標(i--),反之,如果是 false,那麼就不能推進下標,需要將當前的下標處理完畢才能繼續推進
    boolean advance = true;
    // 完成狀態,如果是 true,就結束此方法。
    boolean finishing = false; // to ensure sweep before committing nextTab
    // 死迴圈,i 表示下標,bound 表示當前執行緒可以處理的當前桶區間最小下標
    for (int i = 0, bound = 0;;) {
        Node<K,V> f; int fh;
        // 如果當前執行緒可以向後推進;這個迴圈就是控制 i 遞減。同時,每個執行緒都會進入這裡取得自己需要轉移的桶的區間
        while (advance) {
            int nextIndex, nextBound;
            // 對 i 減一,判斷是否大於等於 bound (正常情況下,如果大於 bound 不成立,說明該執行緒上次領取的任務已經完成了。那麼,需要在下面繼續領取任務)
            // 如果對 i 減一大於等於 bound(還需要繼續做任務),或者完成了,修改推進狀態為 false,不能推進了。任務成功後修改推進狀態為 true。
            // 通常,第一次進入迴圈,i-- 這個判斷會無法通過,從而走下面的 nextIndex 賦值操作(獲取最新的轉移下標)。其餘情況都是:如果可以推進,將 i 減一,然後修改成不可推進。如果 i 對應的桶處理成功了,改成可以推進。
            if (--i >= bound || finishing)
                advance = false;// 這裡設定 false,是為了防止在沒有成功處理一個桶的情況下卻進行了推進
            // 這裡的目的是:1. 當一個執行緒進入時,會選取最新的轉移下標。2. 當一個執行緒處理完自己的區間時,如果還有剩餘區間的沒有別的執行緒處理。再次獲取區間。
            else if ((nextIndex = transferIndex) <= 0) {
                // 如果小於等於0,說明沒有區間了 ,i 改成 -1,推進狀態變成 false,不再推進,表示,擴容結束了,當前執行緒可以退出了
                // 這個 -1 會在下面的 if 塊裡判斷,從而進入完成狀態判斷
                i = -1;
                advance = false;// 這裡設定 false,是為了防止在沒有成功處理一個桶的情況下卻進行了推進
            }// CAS 修改 transferIndex,即 length - 區間值,留下剩餘的區間值供後面的執行緒使用
            else if (U.compareAndSwapInt
                     (this, TRANSFERINDEX, nextIndex,
                      nextBound = (nextIndex > stride ?
                                   nextIndex - stride : 0))) {
                bound = nextBound;// 這個值就是當前執行緒可以處理的最小當前區間最小下標
                i = nextIndex - 1; // 初次對i 賦值,這個就是當前執行緒可以處理的當前區間的最大下標
                advance = false; // 這裡設定 false,是為了防止在沒有成功處理一個桶的情況下卻進行了推進,這樣對導致漏掉某個桶。下面的 if (tabAt(tab, i) == f) 判斷會出現這樣的情況。
            }
        }// 如果 i 小於0 (不在 tab 下標內,按照上面的判斷,領取最後一段區間的執行緒擴容結束)
        //  如果 i >= tab.length(不知道為什麼這麼判斷)
        //  如果 i + tab.length >= nextTable.length  (不知道為什麼這麼判斷)
        if (i < 0 || i >= n || i + n >= nextn) {
            int sc;
            if (finishing) { // 如果完成了擴容
                nextTable = null;// 刪除成員變數
                table = nextTab;// 更新 table
                sizeCtl = (n << 1) - (n >>> 1); // 更新閾值
                return;// 結束方法。
            }// 如果沒完成
            if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {// 嘗試將 sc -1. 表示這個執行緒結束幫助擴容了,將 sc 的低 16 位減一。
                if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)// 如果 sc - 2 不等於識別符號左移 16 位。如果他們相等了,說明沒有執行緒在幫助他們擴容了。也就是說,擴容結束了。
                    return;// 不相等,說明沒結束,當前執行緒結束方法。
                finishing = advance = true;// 如果相等,擴容結束了,更新 finising 變數
                i = n; // 再次迴圈檢查一下整張表
            }
        }
        else if ((f = tabAt(tab, i)) == null) // 獲取老 tab i 下標位置的變數,如果是 null,就使用 fwd 佔位。
            advance = casTabAt(tab, i, null, fwd);// 如果成功寫入 fwd 佔位,再次推進一個下標
        else if ((fh = f.hash) == MOVED)// 如果不是 null 且 hash 值是 MOVED。
            advance = true; // already processed // 說明別的執行緒已經處理過了,再次推進一個下標
        else {// 到這裡,說明這個位置有實際值了,且不是佔位符。對這個節點上鎖。為什麼上鎖,防止 putVal 的時候向連結串列插入資料
            synchronized (f) {
                // 判斷 i 下標處的桶節點是否和 f 相同
                if (tabAt(tab, i) == f) {
                    Node<K,V> ln, hn;// low, height 高位桶,低位桶
                    // 如果 f 的 hash 值大於 0 。TreeBin 的 hash 是 -2
                    if (fh >= 0) {
                        // 對老長度進行與運算(第一個運算元的的第n位於第二個運算元的第n位如果都是1,那麼結果的第n為也為1,否則為0)
                        // 由於 Map 的長度都是 2 的次方(000001000 這類的數字),那麼取於 length 只有 2 種結果,一種是 0,一種是1
                        //  如果是結果是0 ,Doug Lea 將其放在低位,反之放在高位,目的是將連結串列重新 hash,放到對應的位置上,讓新的取於演算法能夠擊中他。
                        int runBit = fh & n;
                        Node<K,V> lastRun = f; // 尾節點,且和頭節點的 hash 值取於不相等
                        // 遍歷這個桶
                        for (Node<K,V> p = f.next; p != null; p = p.next) {
                            // 取於桶中每個節點的 hash 值
                            int b = p.hash & n;
                            // 如果節點的 hash 值和首節點的 hash 值取於結果不同
                            if (b != runBit) {
                                runBit = b; // 更新 runBit,用於下面判斷 lastRun 該賦值給 ln 還是 hn。
                                lastRun = p; // 這個 lastRun 保證後面的節點與自己的取於值相同,避免後面沒有必要的迴圈
                            }
                        }
                        if (runBit == 0) {// 如果最後更新的 runBit 是 0 ,設定低位節點
                            ln = lastRun;
                            hn = null;
                        }
                        else {
                            hn = lastRun; // 如果最後更新的 runBit 是 1, 設定高位節點
                            ln = null;
                        }// 再次迴圈,生成兩個連結串列,lastRun 作為停止條件,這樣就是避免無謂的迴圈(lastRun 後面都是相同的取於結果)
                        for (Node<K,V> p = f; p != lastRun; p = p.next) {
                            int ph = p.hash; K pk = p.key; V pv = p.val;
                            // 如果與運算結果是 0,那麼就還在低位
                            if ((ph & n) == 0) // 如果是0 ,那麼建立低位節點
                                ln = new Node<K,V>(ph, pk, pv, ln);
                            else // 1 則建立高位
                                hn = new Node<K,V>(ph, pk, pv, hn);
                        }
                        // 其實這裡類似 hashMap 
                        // 設定低位連結串列放在新連結串列的 i
                        setTabAt(nextTab, i, ln);
                        // 設定高位連結串列,在原有長度上加 n
                        setTabAt(nextTab, i + n, hn);
                        // 將舊的連結串列設定成佔位符
                        setTabAt(tab, i, fwd);
                        // 繼續向後推進
                        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);
                            // 和連結串列相同的判斷,與運算 == 0 的放在低位
                            if ((h & n) == 0) {
                                if ((p.prev = loTail) == null)
                                    lo = p;
                                else
                                    loTail.next = p;
                                loTail = p;
                                ++lc;
                            } // 不是 0 的放在高位
                            else {
                                if ((p.prev = hiTail) == null)
                                    hi = p;
                                else
                                    hiTail.next = p;
                                hiTail = p;
                                ++hc;
                            }
                        }
                        // 如果樹的節點數小於等於 6,那麼轉成連結串列,反之,建立一個新的樹
                        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;
                    }
                }
            }
        }
    }
}
複製程式碼

程式碼加註釋比較長,有興趣可以逐行對照,有 2 個判斷樓主看不懂為什麼這麼判斷,知道的同學可以提醒一下。

然後,說說精華的部分。

  1. Cmap 支援併發擴容,實現方式是,將表拆分,讓每個執行緒處理自己的區間。如下圖:

併發程式設計——ConcurrentHashMap#transfer() 擴容逐行分析

假設總長度是 64 ,每個執行緒可以分到 16 個桶,各自處理,不會互相影響。

  1. 而每個執行緒在處理自己桶中的資料的時候,是下圖這樣的:

併發程式設計——ConcurrentHashMap#transfer() 擴容逐行分析

擴容前的狀態。

當對 4 號桶或者 10 號桶進行轉移的時候,會將連結串列拆成兩份,規則是根據節點的 hash 值取於 length,如果結果是 0,放在低位,否則放在高位。

因此,10 號桶的資料,黑色節點會放在新表的 10 號位置,白色節點會放在新桶的 26 號位置。

下圖是迴圈處理桶中資料的邏輯:

併發程式設計——ConcurrentHashMap#transfer() 擴容逐行分析

處理完之後,新桶的資料是這樣的:

image.png

總結

transfer 方法可以說很牛逼,很精華,內部多執行緒擴容效能很高,

通過給每個執行緒分配桶區間,避免執行緒間的爭用,通過為每個桶節點加鎖,避免 putVal 方法導致資料不一致。同時,在擴容的時候,也會將連結串列拆成兩份,這點和 HashMap 的 resize 方法類似。

而如果有新的執行緒想 put 資料時,也會幫助其擴容。鬼斧神工,令人讚歎。

相關文章