從源碼探究 1.8 ConcurrentHashMap 的幾個使用過程中想到的問題

前言

水平有限,儘量深入

主要關注的點

  • put 方法相關
    • put 方法做了哪些事
    • 如何保證併發 put 安全(cas 和 synchronized 的使用)
    • 擴容相關
      • 擴容過程
      • 擴容如何保證併發安全性
  • get 方法線程安全
  • size 方法線程安全

使用 ConcurrentHashMap 中的一些疑問解析

put 方法相關

    public V put(K key, V value) {
        return putVal(key, value, false);
    }

    /** Implementation for put and putIfAbsent */
    final V putVal(K key, V value, boolean onlyIfAbsent) {
        // key 和 value 均不能爲 null
        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)
                // 通過 cas 操作,保證初始化的併發安全
                tab = initTable();
            // 如果要put 的 key 要放置的桶爲空,則直接將new 的 node ,利用 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
            }
            // 如果當前 table 正在擴容,那麼當前線程需要幫助進行擴容
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else {
                V oldVal = null;
                // 鎖住頭結點,這樣其他線程就無法操作這個桶上所有的 node
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) {
                            binCount = 1;
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                // 如果 key 已經存在,則覆蓋value
                                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;
                            }
                        }
                    }
                }
                // binCount >= 8,則代表當前的鏈表需要轉換爲紅黑樹
                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        // count + 1,並擴容
        addCount(1L, binCount);
        return null;
    }

put方法流程:

  1. key value 的 null 處理
  2. 計算 hash
  3. 如果 table 沒有初始化,則需要初始化
  4. 如果 key 對應的桶爲空,則需要 cas 將新節點作爲頭結點放在在桶中
  5. 如果當前 table 正在擴容,那麼當前線程需要幫忙擴容
  6. 如若沒有進 3.4.5分支,則需要根據 hashcode 判斷是 replace 或者是插入到鏈表/樹中
  7. 如果是插入操作
    1. 如果是插入鏈表,則還需要判斷是否轉化成紅黑樹
    2. 插入之後,需要通過 addCount 進行 count 的加一,以及擴容操作

併發環境下,put操作如何保證線程安全:

  1. 如果 key 對應的桶中,還沒有存放節點,那麼使用 cas 操作,將首節點設置到桶中。多線程條件下,只有一個線程能通過 cas 設置首節點。
  2. 如果 key 對應的桶中,已經存放了一些節點,那麼通過對首節點進行 synchronized 操作,保證同一個桶,同一個時間點,只有一個線程在操作。

擴容流程:
有兩個操作會引發擴容:

  • 當桶中節點由鏈表結構轉換爲紅黑樹時,treeifyBin 操作
  • 當插入節點後,addCount 操作
  • 如果一個線程在擴容過程中,另外一個線程要插入數據,則需要幫助擴容,進行 helpTransfer 操作

這三個操作進行擴容,核心都是調用方法:

private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab)

分析具體怎麼擴容之前,需要了解ConcurrentHashMap 很重要的一個參數:sizeCtl
這個參數在初始化table、擴容的過程中都有涉及到:
(1)、sizeCtl 爲 -1:初始化過程中
U.compareAndSwapInt(this, SIZECTL, sc, -1)
作用:將 sizeCtl 值設置爲 -1 表示集合正在初始化中,其他線程發現該值爲 -1 時會讓出CPU資源以便初始化操作儘快完成 。
(2)、sizeCtl > 0:初始化完成後
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2);
sizeCtl = sc;
作用:sizeCtl 用於記錄當前集合的負載容量值,也就是觸發集合擴容的極限值 。
(3)、sizeCtl <0:正在擴容時
//第一條擴容線程設置的某個特定基數
U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)
//後續線程加入擴容大軍時每次加 1
U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)
//線程擴容完畢退出擴容操作時每次減 1
U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)
作用:sizeCtl 用於記錄當前擴容的併發線程數情況,併發線程數可以通過 (sizeCtl & 0xFFFF)-1 來獲取。
注意:這裏有一些疑問,sizeCtl 參數的註釋裏寫的是,當 sizeCtl < 0的時候,併發線程數是 n 的話,sizeCtl = -(n+1)。這裏感覺有點問題,我自己看代碼的時候,確實不是註釋中寫的那樣子。第一條擴容線程設置的 siceCtl(計算方式爲:U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2) ),其實並不是-2,而是很大的一個負數。這個原因在於擴容時,sizeCtl初始化的方式:(rs << RESIZE_STAMP_SHIFT) + 2,而 rs 的計算方式是 Integer.numberOfLeadingZeros(table.length) | (1 << (RESIZE_STAMP_BITS - 1))。通過這些位運算之後,其實 rs 的低 16 位就是當前擴容的線程數+1

下面看下transfer 方法做了什麼:

  1. 根據 CPU 計算每個擴容線程分配的桶的數量
  2. 在一個 for循環中進行桶的遷移
    1. 首先是利用一個 while 循環,爲當前線程分配自己需要遷移的桶的區間
    2. 跳出 while 循環後,首先判斷本線程的是否還有遷移任務需要做,如果沒有的話,需要判斷本線程是不是最後一個負責遷移的線程,如果是的話,需要做一些收尾工作(置空成員變量 nextTable,更新成員變量 table 爲新的數組等);否則,直接 reurn,結束本線程的 transfer 方法。
    3. 然後需要判斷當前遷移的桶的頭結點是否爲null,爲 null 的話,直接插入 ForwardingNode 進行佔位
    4. 然後判斷當前節點是否已經被遷移了,是的話直接跳過這個桶
    5. 如果當前節點需要進行遷移的話,先把桶的頭結點鎖掉,然後進行遷移
      鏈表遷移過程:
      1. 首先遍歷鏈表,取到鏈表的尾結點,並得到尾結點是高位還是低位
      2. 然後通過頭插法,產生分別由高位節點和低位節點組成的兩個鏈表,然後把低位鏈表的頭結點設置到下標爲 i(i 是該節點在原數組的下標),把高位鏈表的頭結點設置到下標爲 i+n (i 是該節點在原數組的下標,n 是原數組的長度)

transfer 具體代碼:

    private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
        // stride 代表擴容時,每個擴容線程的「步」,即每個線程最大遷移的桶的數量
        int n = tab.length, stride;
        // 多核CPU情況下,stride = tab長度 * 8 / 核數,否則stride = table 長度,且stride 最小是 16
        if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
            stride = MIN_TRANSFER_STRIDE; // subdivide range
        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;
            // 下一個要遷移的索引(加一之後的值),等於 n 則表示,要從舊錶的末尾開始遷移數據到新表
            transferIndex = n;
        }
        int nextn = nextTab.length;
        // 在擴容的過程中,如果有其他線程嘗試進行讀操作,那麼通過 ForwardingNode 將讀操作轉發到新的 table 上去
        ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
        // 是否能夠向前推進到下一個區間,首次爲 true
        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;
            while (advance) {
                int nextIndex, nextBound;
                // 如果當前區間還未處理完成,則將 advance 置爲 false,跳出 while,繼續處理當前區間
                if (--i >= bound || finishing)
                    advance = false;
                // 如果當前區間已經處理完成,而且不存在其他區間可以分配,則將 i 置爲-1,將 advance 置爲 false  
                else if ((nextIndex = transferIndex) <= 0) {
                    // 當 transferIndex <=0,則表示已經沒有需要遷移的桶,這時候,將 i 置爲 -1,準備退出遷移工作
                    i = -1;
                    advance = false;
                }
                // 如果當前區間已經處理完成,而且存在其他區間可以分配,則申請下一個可分配的區間,然後將 advance 置爲false,跳出 while,進行遷移
                else if (U.compareAndSwapInt
                         (this, TRANSFERINDEX, nextIndex,
                          nextBound = (nextIndex > stride ?
                                       nextIndex - stride : 0))) {
                    // 將邊界置爲 nextIndex - stride 或者 0
                    bound = nextBound;
                    // 從最右邊開始處理
                    i = nextIndex - 1;
                    advance = false;
                }
            }
            // i<0 代表沒有需要遷移的桶
            // TODO i >= n ???
            // TODO i + n >= nextn ???
            if (i < 0 || i >= n || i + n >= nextn) {
                int sc;
                // 擴容完成
                if (finishing) {
                    // 清空成員變量
                    nextTable = null;
                    // 替換爲新的 table
                    table = nextTab;
                    // 更新 sizeCtl
                    sizeCtl = (n << 1) - (n >>> 1);
                    return;
                }
                //表示當前線程遷移完成了
                if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
                    // 如果當前線程是遷移工作不是最後一個線程,則直接 return,結束當前線程的工作
                    if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
                        return;
                    // 如果當前線程是遷移工作的最後一個線程,則將 finishing 標記置爲true,標記整個遷移工作已經結束
                    finishing = advance = true;
                    i = n; // recheck before commit
                }
            }
            // 如果當前遷移桶的頭結點爲null,則直接使用 ForwardNode 進行佔位
            else if ((f = tabAt(tab, i)) == null)
                advance = casTabAt(tab, i, null, fwd);
            // 當前節點已經處理過了
            else if ((fh = f.hash) == MOVED)
                advance = true; 
            // 進行遷移工作
            else {
                // 對節點上鎖,防止其他線程 putVal
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        // ln 高位桶;hn 低位桶,分別保存hash值的第X位爲0和1的節點
                        Node<K,V> ln, hn;
                        // 對鏈表進行遷移
                        if (fh >= 0) {
                            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;
                                }
                            }
                            // 如果最後一個節點是低位,則將 lastRun 賦值給 ln,負責將 lastRun 賦值給 hn
                            if (runBit == 0) {
                                ln = lastRun;
                                hn = null;
                            }
                            else {
                                hn = lastRun;
                                ln = null;
                            }
                            // 遍歷鏈表,根據高低位,以 ln,hn 爲尾結點,進行頭插
                            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);
                            }
                            // 設置低位 node 組成的鏈表的頭結點
                            setTabAt(nextTab, i, ln);
                            // 設置高位 node 組成的鏈表的頭結點
                            setTabAt(nextTab, i + n, hn);
                            // 原 table 的節點使用 FowardingNode 佔位
                            setTabAt(tab, i, fwd);

                            advance = true;
                        }
                        // TODO 紅黑樹以後再補充
                        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;
                        }
                    }
                }
            }
        }
    }

transfer 方法遷移過程相關示意圖:
在這裏插入圖片描述
擴容過程中的其他線程操作行爲:

  1. put 方法
    • 需要幫助一起擴容,擴容完之後才能進行put操作
  2. get 方法
    • 藉助volatile,不需要加鎖。不過需要通過 ForwardingNode 轉換到新的 table 上去進行 get 操作。

get 方法線程安全

get 方法不需要加鎖,原因是 Node<K,V>[] table 和 Node對象中的 val 和 next 字段都是 volatile 修飾的,不存在髒讀的問題。

參考

  • https://blog.csdn.net/ZOKEKAI/article/details/90051567
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章