拿捏了!ConcurrentHashMap!

insaneXs發表於2020-08-30

概述

本文將對JDK8中 ConcurrentHashMap 原始碼進行一定程度的解讀。解讀主要分為六個部分:主要屬性與相關內部類介紹、建構函式、put過程、擴容過程、size過程、get過程、與JDK7實現的簡單對比。希望對讀者學習ConcurrentHashMap有一定的幫助。
閱讀本文前,可能需要讀者對HashMap和紅黑樹等有基本的瞭解。

主要屬性和主要的內部類

主要屬性

常量

ConcurrentHashMap中常量一共分為以下幾個部分:

  • 容量相關:MAXIMUM_CAPACITY、DEFAULT_CAPACITY、MAX_ARRAY_SIZE
  • 相容JDK 7而保留的部分常量:DEFAULT_CONCURRENCY_LEVEL、LOAD_FACTOR
  • 紅黑樹升級和退化相關的常量:TREEIFY_THRESHOLD、UNTREEIFY_THRESHOLD、MIN_TREEIFY_CAPACITY
  • 擴容相關:MIN_TRANSFER_STRIDE、RESIZE_STAMP_BITS、MAX_RESIZERS、RESIZE_STAMP_SHIFT
  • 節點狀態常量:MOVED、TREEBIN、RESERVED
    /* ---------------- Constants -------------- */

    /**
     * HashMap的最大容量
     */
    private static final int MAXIMUM_CAPACITY = 1 << 30;

    /**
     * 預設容量
     */
    private static final int DEFAULT_CAPACITY = 16;

    /**
     * 陣列最大長度
     */
    static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;

    /**
     * 預設最大併發等級
     */
    private static final int DEFAULT_CONCURRENCY_LEVEL = 16;

    /**
     * 負載因子
     */
    private static final float LOAD_FACTOR = 0.75f;

    /**
     * 連結串列升級成紅黑樹的閾值
     */
    static final int TREEIFY_THRESHOLD = 8;

    /**
     * 紅黑樹退化成連結串列的閾值
     */
    static final int UNTREEIFY_THRESHOLD = 6;

    /**
     * 連結串列升級成樹需要滿足的最小容量,若不滿足,則會先擴容
     */
    static final int MIN_TREEIFY_CAPACITY = 64;

    //最小轉移步長
    private static final int MIN_TRANSFER_STRIDE = 16;

    //這個常量是用來計算HashMap不同容量有不同的resizeStamp用的
    private static int RESIZE_STAMP_BITS = 16;


    //最大參與擴容的執行緒數 相當大的一個數 基本上是不會觸及該上線的
    private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;

    //要對resizeStamp進行位移運算的一個敞亮
    private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;

    //特殊的節點雜湊值
    static final int MOVED     = -1; // hash for forwarding nodes
    static final int TREEBIN   = -2; // hash for roots of trees
    static final int RESERVED  = -3; // hash for transient reservations
    static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash

    //獲取CPU的數量
    static final int NCPU = Runtime.getRuntime().availableProcessors();

其中,因為JDK8中ConcurrentHashMap的實現方式和JDK7的不同,因此DEFAULT_CONCURRENCY_LEVEL已經沒有實際作用了。並且在JDK8中,LOAD_FACTOR也已經固定成了0.75f。
另外,MOVED,TREEBIN,RESERVED是用來表示特殊節點的雜湊值。該類特殊節點均不含實際元素,且其雜湊值被設定為負數和普通節點區分。
剩下的涉及擴容的常量我們在相關的章節中再介紹。

成員變數

通常成員變數都是會負責記錄當前類的狀態的,ConcurrentHashMap也是如此。因此瞭解清除成員變數的作用,對我們後續分析ConcurrentHashMap的操作流程很有感幫助。

    /* ---------------- Fields -------------- */

    /**
     * 底層陣列
     */
    transient volatile Node<K,V>[] table;
    //擴容時 使用的另一個陣列
    private transient volatile Node<K,V>[] nextTable;

    //統計size的一部分
    private transient volatile long baseCount;

    /**
     * sizeCtl與table的resize和init有關
     * sizeCtl = -1時,表示table正在init
     * sizeCtl < 0 且不等於-1時,表示正在resize
     * sizeCtl > 0 時,表示下次需要resize的閾值,即capacity * loadfactory
     */
    private transient volatile int sizeCtl;

    //記錄下一次要transfer對應的Index
    private transient volatile int transferIndex;


    //表示是否有執行緒正在修改CounterCells
    private transient volatile int cellsBusy;

    //用來統計size
    private transient volatile CounterCell[] counterCells;

    // views
    private transient KeySetView<K,V> keySet;
    private transient ValuesView<K,V> values;
    private transient EntrySetView<K,V> entrySet;


同樣的,成員變數也可以按作用分成幾類:

  • 用作底層資料結構的實現:Node<K,V>[] table
  • 用作統計元素的大小:baseCount、CounterCell[] counterCells、cellsBusy
  • 用作記錄ConcurrentHashMap的狀態:sizeCtl
  • 用作擴容記錄:Node<K,V>[] nextTable、transferIndex
  • 用作轉成其它型別的檢視:KeySetView<K,V> keySet、ValuesView<K,V> values、EntrySetView<K,V> entrySet

table陣列很好理解。因為HashMap的實現是基於陣列的,在衝突時通過鏈地址解決。因此所有的資料都以陣列為入口。
另一個陣列nextTable是在擴容時備用的。 如果瞭解Redis的資料結構的讀者,應該對這個不陌生,redis漸進式rehash就是通過兩個雜湊表加一個index實現的。而JDK8中在resize時,也採取了類似的方式(下文我們會介紹到:按步長逐步transfer)。

另外,比較重要的一個屬性就是sizeCtl。
如果看過Doug Lea老爺子在JUC下的其他類,經常會有一個特殊的變數表示當前物件的狀態。並且已CAS的方式去修改這個變數,實現自旋鎖的功能(例如:AQS中的state)。
這裡的sizeCtl就是一個富有特殊意義的變數。
當sizeCtl大於0時,表示擴容的閾值(沒錯,就是HashMap中threshold變數的作用),而且上文我們也瞭解到在JDK8中由於loadfactor已經被固定為0.75f。因此在正常狀態下(非擴容狀態),sizeCtl = oldCap >> 1 - (oldCap << 2)。 而sizeCtl == -1是一個特殊的狀態標誌,表示ConcurrentHashMap正在初始化底層陣列。
當sizeCtl為其他負數時,表示ConcurrentHashMap正在程式擴容,其中,高16位可以反應出擴容前陣列的大小,而後16位可以反應出此時參與擴容的執行緒數。

內部類

ConcurrentHashMap擁有大量的內部類,但其中大部分都是用來遍歷或是在Fork/Join框架中平行遍歷時使用的。這部分類內部類我們不在過多介紹。主要看CountCell和幾個Node的類。

CounterCell

首先,CounterCell是用來統計ConcurrentHashMap用的,其內部有個value,用來表示元素個數。size()函式就是通過累加countCells陣列中所有CounterCell的value值,再加上BaseCount得到的。相當於ConcurrentHashMap把size這個屬性拆散儲存在了個多個地方。

Node

同HashMap一樣,為了提高連結串列的遍歷速度,ConcurrentHashMap也引用了紅黑樹。而Node就表示連結串列中的節點,並且他還是其他節點的父類。

TreeNode

TreeNode表示紅黑樹中的節點,按照紅黑樹的標準,它還擁有父節點和左右子節點的屬性,此外還需要標識是否為紅節點。

TreeBin

TreeBin是一個特殊的節點,用來指向紅黑樹的根節點,並不儲存真實的元素,因此它的節點的雜湊值是一個固定的特殊值-2。

ForwardingNode

ForwardingNode和TreeBin一樣,並不儲存實際元素,而是指向nextTable,雜湊值也是一個特殊的固定值(-1)。它在擴容中會使用,表示這個桶上的元素已經遷移到新的陣列中去了。

ReservationNode

同樣是一個特殊值,在putIfAbsent時使用。因為put時需要對桶上的元素上物件鎖(ConcurrentHashMap並非是完全無鎖的,只是儘可能少的去使用鎖),這時就會新增一個臨時佔位用的節點ReservationNode。

建構函式

因為建構函式是公有的API,所以必須要和JDK7中保持一致。雖然其中的部分含義可能發生了一些變化。
我們看一下引數最全的建構函式。

    public ConcurrentHashMap(int initialCapacity,
                             float loadFactor, int concurrencyLevel) {
        if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
            throw new IllegalArgumentException();
        if (initialCapacity < concurrencyLevel)   // Use at least as many bins
            initialCapacity = concurrencyLevel;   // as estimated threads
        long size = (long)(1.0 + (long)initialCapacity / loadFactor);
        int cap = (size >= (long)MAXIMUM_CAPACITY) ?
            MAXIMUM_CAPACITY : tableSizeFor((int)size);
        this.sizeCtl = cap;
    }

上述方法中的三個引數分別是初始容量initialCapacity,負載因子loadfactor和並行等級concurrenyLevel。
首先,loadfactor負載因子在JDK8的ConcurrentHashMap執行時都已經固定為0.75f,因此這裡的引數只能在建立時,幫助確定初始的陣列容量。
同樣的,由於不在使用JDK7的Segement實現方式,因此這裡的concurrencyLevel不在用來確定Segement的數量。對於JDK8中的ConcurrentHashMap而言,鎖的粒度是對陣列的每個桶(理論上可以對每個桶進行併發操作),因此concurrencyLevel的含義也就是用來確定底層資料的初始容量。
這也正是size = (long)(1.0 + (long)initialCapacity / loadFactor);這行程式碼的意義(這裡的initialCapacity是取引數中initialCapacity和concurrenyLevel中的最大值)。

另外需要注意的一點是,size並不是最終我們陣列的容量,ConcurrentHashMap會通過tableSizeFor()方法找出大於等於size的最小2的冪次方數作為容量。(這和HashMap是一樣的,需要保證容量為2的冪次,因為之後的雜湊操作都是基於這一前提)。
最後,在得出了初始容量後,ConcurrentHashMap僅是將容量通過sizeCtl來儲存,而並沒有直接初始化陣列。陣列的初始化會被延遲到第一次put資料時(這樣設計可能是出於節省記憶體的目的)。

put過程

有了前文的鋪墊,我們就可以開始瞭解ConcurrentHashMap的put過程了。

先在這裡做個宣告,本文不會對紅黑樹的部分展開詳細分析,之後用連結串列升級成紅黑樹,紅黑樹退化成連結串列,在紅黑樹中查詢直接概括某些過程。

put()的具體實現都是由putVal()這個函式實現的。因此這裡我們對putVal()函式展開分析。

    final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        int hash = spread(key.hashCode());
        int binCount = 0;
        //for迴圈,一直嘗試,直到put成功
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            //tab未初始化,先初始化tab
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {//對應的bucket上還沒有元素
                //採用CAS嘗試PUT元素,如果此時沒有其它執行緒操作,這裡將會PUT成功
                if (casTabAt(tab, i, null,
                             new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }
            else if ((fh = f.hash) == MOVED)//如果tab正在擴容
                tab = helpTransfer(tab, f);
            else {//bucket上已經存在元素
                V oldVal = null;
                //只針對頭節點同步,不影響其他bucket上的元素,提高效率
                synchronized (f) {
                    //同步塊內在做一次檢查
                    if (tabAt(tab, i) == f) {//說明頭節點未發生改變,如果發生改變,則直接退出同步塊,並再次嘗試
                        if (fh >= 0) { //雜湊值大於0 說明是tab[i]上放的是連結串列 因為對於紅黑樹而言 tab[i]上放的是TreeBin一個虛擬的節點 其雜湊值固定為-2
                            binCount = 1;
                            for (Node<K,V> e = f;; ++binCount) {
                                //查詢連結串列,如果存在相同key,則更新,否則插入新節點
                                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;
                                }
                            }
                        }
                        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) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);
        return null;
    }

整體瞭解putVal()的流程

先整體的瞭解下putVal(),不對for()迴圈中程式碼具體分析。
第一步是校驗要put的Key/Value不能為null。因此ConcurrentHashMap和HashMap不同,不支援空的鍵值。
第二步是spread(key.hashCode())是對鍵的雜湊值做一個擾動,這裡通過h^(h>>>16)的演算法實現的,這樣做的目的有兩個,一是避免了設計不好的hashCode函式造成碰撞的概率加大,二是確保了擾動後的雜湊值均為正數(因為負數雜湊值都是一些特殊的節點)。
第三步是for()迴圈,這裡通過CAS+自選保證執行緒安全,暫時先不具體分析。
第四步addCount()應該是表示成功往ConcurrentHashMap新增了元素後,讓更新元素的數量(當然,我們可以猜想對於替換節點的情況,應該是不會執行這一步的)。這個方法的具體分析我們放在擴容的步驟中。

分析for()迴圈中的程式碼

for()迴圈中的程式碼 同樣分成了四個部分:

第一步:如果底層陣列還沒有初始化,通過initTable()初始化陣列

initTable()方法如下:

private final Node<K,V>[] initTable() {
        Node<K,V>[] tab; int sc;
        //同樣 採用不斷重試的方式,而非直接使用鎖
        while ((tab = table) == null || tab.length == 0) {
            //sizeCtl < 0 表示table正在被初始化或是reszie
            if ((sc = sizeCtl) < 0)
                //當前執行緒先等待
                Thread.yield(); // lost initialization race; just spin
            else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { //使用CAS操作更新sizeCtl,標記為正在初始化中
                //由於採用了CAS操作,因此該塊的方法可以認為是執行緒安全的
                try {
                    if ((tab = table) == null || tab.length == 0) {//初始化
                        int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                        @SuppressWarnings("unchecked")
                        Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                        table = tab = nt;
                        //表示下次需要擴容的值 (1 - 1/4) = 0.75f
                        sc = n - (n >>> 2);
                    }
                } finally {
                    sizeCtl = sc;
                }
                break;
            }
        }
        return tab;
    }

initTable()不算複雜,首先為了避免多個執行緒同時進行初始化,需要通過sizeCtl進行控制。
當執行緒發現sizeCtl<0時,就知道此時已經有其他執行緒在初始化了,那麼它會主動讓出CPU時間,等待初始化完成。
如果sizeCtl並不是小於0,說明暫時沒有其他執行緒在初始化,這時候要先通過CAS更新sizeCtl的值為-1(相當於搶佔了自旋鎖),然後開始初始化底層陣列並設定為table,然後計算下次擴容的閾值存放在sizeCtl(具體值為n - (n >>>2),即容量的0.75*n)。

第二步:如果已經初始化但是對應桶上元素為null,那麼嘗試CAS更新

首先,這裡確定桶的演算法是通過之前spread()得到的雜湊值h和陣列容量n進行一次h & (n -1)
這個方式和HashMap是相同的,因為n是2的冪,換成二進位制,就是高位為1之後的低位全為0的數,那麼這個數減1就成了全為1的一個數。以這樣的方式代替取餘的運算不僅計算更快,也能更好的利用雜湊值雜湊。
如果,這一步CAS失敗,說明此時有其他執行緒也在操作該桶,那麼當前執行緒在下次for()迴圈時會進入下列的第三和第四步中。

第三步:說明已經初始化且桶上有元素,那麼判斷元素是否為ForwardNode

如果執行緒發現自己要操作的桶上的節點是ForwardNode(可以通過其特殊的雜湊值判斷),那麼就說明此時ConcurrentHashMap擴容,執行緒可能會加入幫助擴容。具體的我們放在擴容的部分介紹。

第四步:說明桶上元素是正常元素,那麼就要比對這個桶所有元素,進行更新或插入

這裡說明該桶上存放的是正常的元素(TreeBin雖然是一個特殊節點,但也是正常狀態下存在的節點),為了執行緒安全,這裡需要對桶上的元素進行上鎖synchronized(f)。然後在遍歷桶上所有的元素,選擇更新或者插入。
第一,需要注意的是,上鎖後的第一件事就是進行double-check的判斷,看上鎖過程中頭節點是否發生了變化。這很重要,如果頭節點發生了變化,那麼對之前的頭節點f上鎖是無法保證執行緒安全的。
第二,對於桶上是連結串列的情況(f.hash > 0),ConcurrentHashMap會遍歷連結串列,比較連結串列的各個節點,如果之前存在相同的key,那麼替換該節點的value值(儲存節點的舊值用於返回)。如果不存在相同的key,那麼建立新的節點插入連結串列(注意,ConcurrentHashMap用的是尾插發,即插入連結串列尾部)。
第三,針對是TreeBin的節點,說明桶上關聯的是紅黑樹,則通過紅黑樹的方式進行插入或更新。

擴容過程

擴容過程過程可能要比put過程要稍微複雜一些。首先我們從上文提到的addCount()函式開始分析。

addCount()更新元素的容器個數

當ConcurrentHashMap新增了元素之後,需要通過addCount()更新元素的個數。
並且如果發現元素的個數達到了擴容閾值(sizeCtl),那麼將進行resize()操作。

    private final void addCount(long x, int check) {
        CounterCell[] as; long b, s;

        //更新size
        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();
        }
        //resize
        if (check >= 0) {
            Node<K,V>[] tab, nt; int n, sc;
            //不斷CAS重試
            while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
                   (n = tab.length) < MAXIMUM_CAPACITY) {//需要resize
                //為每個size生成一個獨特的stamp 這個stamp的第16為必為1 後15位針對每個n都是一個特定的值 表示n最高位的1前面有幾個零
                int rs = resizeStamp(n);
                //sc會在庫容時變成 rs << RESIZE_STAMP_SHIFT + 2;上面說了rs的第16位為1 因此在左移16位後 該位的1會到達符號位 因此在擴容是sc會成為一個負數
                //而後16位用來記錄參與擴容的執行緒數
                //此時sc < 0 說明正在擴
                if (sc < 0) {
                    /**
                     * 分別對五個條件進行說明
                     * sc >>> RESIZE_STAMP_SHIFT != rs 取sc的高16位 如果!=rs 則說明HashMap底層資料的n已經發生了變化
                     * sc == rs + 1  此處可能有問題  我先按自己的理解 覺得應該是  sc == rs << RESIZE_STAMP_SHIFT + 1; 因為開始transfer時 sc = rs << RESIZE_STAMP_SHIFT + 2(一條執行緒在擴容,且之後有新執行緒參與擴容sc均會加1,而一條執行緒完成後sc - 1)說明是參與transfer的執行緒已經完成了transfer
                     * 同理sc == rs + MAX_RESIZERS 這個應該也改為 sc = rs << RESIZE_STAMP_SHIFT + MAX_RESIZERS 表示參與遷移的執行緒已經到達最大數量 本執行緒可以不用參與
                     * (nt = nextTable) == null 首先nextTable是在擴容中間狀態才使用的陣列(這一點和redis的漸進式擴容方式很像) 當nextTable 重新為null時 說明transfer 已經finish
                     * transferIndex <= 0 也是同理
                     * 遇上以上這些情況 說明此執行緒都不需要參與transfer的工作
                     * PS: 翻了下JDK16的程式碼 這部分已經改掉了 rs = resizeStamp(n) << RESIZE_STAMP_SHIFT 證明我們的猜想應該是正確的
                     */
                    if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                        sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                        transferIndex <= 0)
                        break;
                    //否則該執行緒需要一起transfer
                    if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                        transfer(tab, nt);
                }
                //說明沒有其他執行緒正在擴容 該執行緒會將sizeCtl設定為負數 表示正在擴容
                else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                             (rs << RESIZE_STAMP_SHIFT) + 2))
                    transfer(tab, null);
                s = sumCount();
            }
        }
    }

如上文所說,這個方法有兩個作用,一是更新元素個數,二是判斷是否需要resize()。

更新size()

我們可以單獨看addCount中更新size的部分

        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();
        }

首先判斷countCells是否已經被初始化,如果沒有被初始化,那麼將嘗試在size的更新操作放在baseCount上。如果此時沒有衝突,那麼CAS修改baseCount就能成功,size的更新就落在了baseCount上。
如果此時已經有countCells了,那麼會根據執行緒的探針隨機落到countCells的某個下標上。對size的更新就是更新對應CountCells的value值。
如果還是不行,將會進入fullAddCount方法中,自旋重試直到更新成功。這裡不對fullAddCount展開介紹,具體操作也類似,size的變化要麼累加在對應的CountCell上,要麼累加在baseCount上。
這裡說一下我個人對ConcurrentHashMap採用這麼複雜的方式進行計數的理解。因為ConcurrenthHashMap是出於吞吐量最大的目的設計的,因此,如果單純的用一個size直接記錄元素的個數,那麼每次增刪操作都需要同步size,這會讓ConcurrentHashMap的吞吐量大大降低。
因為,將size分散成多個部分,每次修改只需要對其中的一部分進行修改,可以有效的減少競爭,從而增加吞吐量。

resize()

對於resize()過程,我其實在程式碼的註釋中說明的比較詳細了。
首先,是一個while()迴圈,其中的條件是元素的size(由上一步計算而來)已經大於等於sizeCtl(說明到達了擴容條件,需要進行resize),這是用來配合CAS操作的。
接著,是根據當前陣列的容量計算了resizeStamp(該函式會根據不同的容量得到一個確定的數)。得到的這個數會在之後的擴容過程中被使用。
然後是比較sizeCtl,如果sizeCtl小於0,說明此時已經有執行緒正在擴容,排除了幾種不需要參與擴容的情況(例如,擴容已經完成,或是參與的擴容執行緒數已經到最大值,具體情況程式碼上的註解已經給出了分析),剩下的情況當前執行緒會幫助其他執行緒一起擴容,擴容前需要修改CAS修改sizeCtl(因為在擴容時,sizeCtl的後16位表示參與擴容的執行緒數,每當有一個執行緒參與擴容,需要對sizeCtl加1,當該執行緒完成時,對sizeCtl減1,這樣比對sizeCtl就可以知道是否所有執行緒都完成了擴容)。
另外如果sizeCtl大於0,說明還沒有執行緒參與擴容,此時需要CAS修改sizeCtl為rs << RESIZE_STAMP_SHIFT + 2(其中rs是有resizeStamp(n)得到的),這是一個負數,上文也說了這個數的後16位表示參與擴容的執行緒,當所有執行緒都完成了擴容時,sizeCtl應該為rs << RESIZE_STAMP_SHIFT + 1。這是我們結束擴容的條件,會在後文看到。

transfer()

transfer()方法負責對陣列進行擴容,並將資料rehash到新的節點上。這一過程中會啟用nextTable變數,並在擴容完成後,替換成table變數。

   private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
        //stride是步長,transfer會依據stride,把table分為若干部分,依次處理,好讓多執行緒能協助transfer
        int n = tab.length, stride;
        if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
            stride = MIN_TRANSFER_STRIDE; // subdivide range
        if (nextTab == null) {            // initiating //nextTab等於null表示第一個進來擴容的執行緒
            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和transferIndex表示擴容的中間狀態
            nextTable = nextTab;
            transferIndex = n;
        }
        int nextn = nextTab.length;
        ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
        boolean advance = true; // advance 表示是否可以繼續執行下一個stride
        boolean finishing = false; // to ensure sweep before committing nextTab finish表示transfer是否已經完成 nextTable已經替換了table

        //開始轉移各個槽
        for (int i = 0, bound = 0;;) {
            Node<K,V> f; int fh;
            //STEP1  判斷是否可以進入下一個stride 確認i和bound
            //通過stride領取一部分的transfer任務,while迴圈就是確認邊界
            while (advance) {
                int nextIndex, nextBound;
                if (--i >= bound || finishing) //認領的部分已經被執行完(一個stride執行完)
                    advance = false;
                else if ((nextIndex = transferIndex) <= 0) { //transfer任務被認領完
                    i = -1;
                    advance = false;
                }
                else if (U.compareAndSwapInt
                         (this, TRANSFERINDEX, nextIndex,
                          nextBound = (nextIndex > stride ?
                                       nextIndex - stride : 0))) { //認領一個stride的任務
                    bound = nextBound;
                    i = nextIndex - 1;
                    advance = false;
                }
            }

            /**
             * i < 0 說明要轉移的桶 都已經處理過了
             *
             *
             * 以上條件已經說明 transfer已經完成了
             */
            if (i < 0 || i >= n || i + n >= nextn) { //transfer 結束
                int sc;
                if (finishing) {//如果完成整個 transfer的過程 清空nextTable 讓table等於擴容後的陣列
                    nextTable = null;
                    table = nextTab;
                    sizeCtl = (n << 1) - (n >>> 1); //0.75f * n 重新計算下次擴容的閾值
                    return;
                }
                if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {//一個執行緒完成了transfer
                    //如果還有其他執行緒在transfer 先返回
                    if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
                        return;
                    //說明這是最後一個在transfer的執行緒 因此finish標誌被置為 true
                    finishing = advance = true;
                    i = n; // recheck before commit
                }
            }
            else if ((f = tabAt(tab, i)) == null) //如果該節點為null,則對該節點的遷移立馬完成,設定成forwardNode
                advance = casTabAt(tab, i, null, fwd);
            else if ((fh = f.hash) == MOVED)
                advance = true; // already processed
            else { //開始遷移該節點
                synchronized (f) {//同步,保證執行緒安全
                    if (tabAt(tab, i) == f) { //double-check
                        Node<K,V> ln, hn; //ln是擴容後依舊保留在原index上的node連結串列;hn是移到index + n 上的node連結串列
                        if (fh >= 0) { //普通連結串列
                            int runBit = fh & n;
                            Node<K,V> lastRun = f;
                            //這一次遍歷的目的是找到最後一個一個節點,其後的節點hash & N 都不發生改變
                            //例如 有A->B->C->D,其hash & n 為 0,1,1,1 那就是找到B點
                            //這樣做的目的是之後對連結串列進行拆分時 C和D不需要單獨處理 維持和B的關係 B移動到新的tab[i]或tab[i+cap]上即可
                            //還有不理解的可以參考我的測試程式碼:https://github.com/insaneXs/all-mess/blob/master/src/main/java/com/insanexs/mess/collection/TestConHashMapSeq.java
                            for (Node<K,V> p = f.next; p != null; p = p.next) {
                                int b = p.hash & n;
                                if (b != runBit) {
                                    runBit = b;
                                    lastRun = p;
                                }
                            }
                            //如果runBit == 0 說明之前找到的節點應該在tab[i]
                            if (runBit == 0) {
                                ln = lastRun;
                                hn = null;
                            }
                            //否則說明之前的節點在tab[i+cap]
                            else {
                                hn = lastRun;
                                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;
                                //這裡不再繼續使用尾插法而是改用了頭插法 因此連結串列的順序可能會發生顛倒(lastRun及之後的節點不受影響)
                                if ((ph & n) == 0)
                                    ln = new Node<K,V>(ph, pk, pv, ln);
                                else
                                    hn = new Node<K,V>(ph, pk, pv, hn);
                            }
                            //將新的連結串列移動到nextTab的對應座標中
                            setTabAt(nextTab, i, ln);
                            setTabAt(nextTab, i + n, hn);
                            //tab上對應座標的節點變為ForwardingNode
                            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);
                                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的程式碼比較長,我們也一部分一部分的分析各段程式碼的作用。
首先,最先發起擴容的執行緒需要對陣列進行翻倍,然後將翻倍後得到的新陣列通過nextTable變數儲存。並且啟用了transferIndex變數,初始值為舊陣列的容量n,這個變數會被用來標記已經被認領的桶的下標。
擴容過程是從後往前的,因此transferIndex的初始值才是n。並且整個擴容過程依據步長stride,被拆分成個部分,執行緒從後往前依次領取一個部分,所以每次有執行緒領取任務,transferIndex總是要被減去一個stride。
當執行緒認領的一個步長的任務完成後,繼續去認領下一個步長,直到transferIndex < 0,說明所有資料都被認領完。
當參與擴容的執行緒發現沒有其他任務能被認領,那麼就會更新sizeCtl為 sizeCtl-1 (說明有一條執行緒退出擴容)。最後一條執行緒完成了任務,發現sizeCtl == (resizeStamp(n) << RESIZE_STAMP_SHIFT + 2) ,那麼說明所有的執行緒都完成了擴容任務,此時需要將nextTable替換為table,重置transferIndex,並計算新的sizeCtl表示下一次擴容的閾值。

上面介紹了執行緒每次認領一個步長的桶數負責rehash,這裡介紹下針對每個桶的rehash過程。
首先,如果桶上沒有元素或是桶上的元素是ForwardingNode,說明不用處理該桶,繼續處理上一個桶。
對於桶上存放正常的節點而言,為了執行緒安全,需要對桶的頭節點進行上鎖,然後以連結串列為例,需要將連結串列拆為兩個部分,這兩部分存放的位置是很有規律的,如果舊陣列容量為oldCap,且節點之前在舊陣列的下標為i,那麼rehash連結串列中的所有節點將放在nextTable[i]或者nextTable[i+oldCap]的桶上(這一點可以從之前雜湊值中比n最高位還靠前的一位來考慮,當前一位為0時,就落在nextTable[i]上,而前一位為1時,就落在nextTable[i+oldCap])。
同理紅黑樹也會被rehash()成兩部分,如果新的紅黑樹不滿足成樹條件,將會被退化成連結串列。
當一個桶的元素被transfer完成後,舊陣列相關位置上會被放上ForwardingNode的特殊節點表示該桶已經被遷移過。且ForwardingNode會指向nextTable。

由於不滿足樹化條件而引起的擴容

當一個桶上的連結串列節點數大於8,但是陣列容量又小於64時,ConcurrentHashMap會優先選擇擴容而非樹化,具體的方法在tryPresize()中。整體流程和addCount()方法類似,這裡不再贅述。

後話

如果讀者夠仔細的話,會發現在擴容這一段Doug Lea老爺子其實也留了些BUG下來。
一個是在addCount中判斷rs和sc關係的時候,一部分條件老爺子忘記了加位移操作。這部分程式碼如下:

sc == rs + 1 || sc == rs + MAX_RESIZERS

這一部分的等式均差了一個位移的運算。

另一個是在tryPresize()方法中,while裡的最後一個else if中 sc < 0的條件應該是永遠不成立的,因為while的條件就是sc >=0。

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;
    if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
        transfer(tab, nt);
    }

上面兩部分程式碼,我在OPENJDK 16版本中確認過,確實已經修改過了。

size()過程

size()過程其實相對簡單,上文在addCount()已經介紹過了,為了保證ConcurrentHashMap的吞吐量,元素個數被拆成了多個部分儲存在countCells和baseCount中。那麼求size()其實就是將這幾部分資料累積。

    final long sumCount() {
        CounterCell[] as = counterCells; CounterCell a;
        long sum = baseCount;
        //counterCells 不為空,說明此時有其他執行緒在更新陣列
        if (as != null) {
            for (int i = 0; i < as.length; ++i) {
                if ((a = as[i]) != null)
                    sum += a.value;
            }
        }
        return sum;
    }

get過程

相對於put過程,get()可以說十分簡單了。

    public V get(Object key) {
        Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
        //?在hash值得基礎上再做一次雜湊,具體目的不明
        int h = spread(key.hashCode());
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (e = tabAt(tab, (n - 1) & h)) != null) {
            //根據雜湊的值 得到tab中的元素,因為tabAt保證了可見性,因此可以認為多執行緒下資料沒有問題
            if ((eh = e.hash) == h) {
                if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                    return e.val;
            }
            //雜湊值小於0 說明節點正在遷移或是為樹節點 為ForwardNode或是TreeBin 可以以多型的方式由不同實現根據不同的情況去查詢
            else if (eh < 0)
                return (p = e.find(h, key)) != null ? p.val : null;
            //正常連結串列的查詢
            while ((e = e.next) != null) {
                if (e.hash == h &&
                    ((ek = e.key) == key || (ek != null && key.equals(ek))))
                    return e.val;
            }
        }
        return null;
    }

和HashMap的get過程基本一直(除了對hash值的擾動方式不一樣)。
整體流程就是計算鍵的雜湊值屬於哪個桶,然後查詢該桶的所有元素,獲取key相等的節點(連結串列直接遍歷,紅黑樹用樹的方式查詢),並返回。

與JDK7實現的簡單對比

文章的最後,我們看一下JDK8中的 ConcurentHashMap 與JDK7版本中的不同,也算是一個總結。
其實,最大的差異就是JDK 8中不在使用Segment。因為其他所有的差異都是為了適應新的方式而做出的調整。
譬如resize()時的不同(JDK7中只用對對應的Segment上鎖,就可以用HashMap的方式進行resize())。
又譬如二者在size()方法上的不同(JDK7中會先累加三次各個段的size(),如果其中資料發生了變化,說明此時有其他執行緒在操作,為了資料強一致性會上全鎖(所有segment上鎖)統計size)。
雖然,JDK8中的ConcurrentHashMap實現上更為複雜, 但這樣的好處也是顯而易見的。那就是讓ConcurrentHashMap的併發等級或者說吞吐量達到了最大話。
更多JDK原始碼分析可以見我的GitHub專案:read-jdk
如果文章有錯誤,也歡迎指正。

相關文章