1. 程式人生 > 實用技巧 >深入探索JDK8的ConcurrentHashMap

深入探索JDK8的ConcurrentHashMap

前言

HashMap的唯一雞肋就是非執行緒安全,在如今的高併發場景下它能派上的用場也將越來越少,為了兼有HashMap高效的存取能力的同時又能保證執行緒安全滋生了ConcurrentHashMap。在JDK1.8以前,資料結構仍然是陣列、連結串列的方式,不過與Hashtable相比,它並不是對整個雜湊表上鎖,而是採用分段鎖,很好理解,將定義好容量大小的雜湊表均分成相等容量大小的一個小段,相當於一塊大蛋糕被平均分成若干個均等的小蛋糕,也就是說當某個執行緒進入到其中一個段進行操作,其他執行緒可以併發操作其他段,雖然效率上提升了不少,但卻不是最優的。探索ConcurrentHashMap底層實現是基於JDK1.8

,它是直接將雜湊表中某一個位置上的頭節點進行鎖定,是不是粒度比之前更小了,允許其他執行緒操作的節點更多了,效率自然也有不用說了。其實在這之前我看過很多有關於ConcurrentHashMap,發現它們要麼是抄襲嚴重,要麼關鍵的點沒分析到位,總是點到為止,很少能見到有一兩個亮眼或對某片程式碼有疑問的文章,所以這篇文章還是花費了挺多時間的,不過至少解除了心中的疑慮!

資料結構


    // 執行緒安全的HashMap
    public class ConcurrentHashMap<K,V> extends AbstractMap<K,V> implements ConcurrentMap<K,V>, Serializable {
        
        // 雜湊表的最大容量
        private static final int MAXIMUM_CAPACITY = 1 << 30;


        // 雜湊表的預設初始容量
        private static final int DEFAULT_CAPACITY = 16;

        /**
         * 我們都知道定義一個數組的大小是 int 型別,那麼也就意味著最大的陣列大小應該是Integer.MAX_VALUE,但是這裡為啥要減去8呢?
         * 查閱資源發現大部分的人都在說8個位元組是用來儲存陣列的大小,半信半疑
         * 分配最大陣列,某些VM會在陣列中儲存header word,按照上面的說法指的應該是陣列的大小
         * 若嘗試去分配更大的陣列可能會造成 OutOfMemoryError: 定義的陣列大小超過VM上限
         * 不同的作業系統對於不同的JDK可能分配的記憶體會有所差異,所以8這個數字可能是為了保險起見
         */
        private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;


        /**
         * 雜湊表的預設併發級別
         * JDK8中該欄位並未使用,只是為了相容以前的版本
         */
        private static final int DEFAULT_CONCURRENCY_LEVEL = 16;

        /**
         * 預設的載入因子
         * 設定成0.75是對空間與時間的一個權衡(折中),載入因子過大會減少空間開銷,增加查詢成本
         */
        private static final float LOAD_FACTOR = 0.75f;

        /**
         * 當新增節點後連結串列的長度超過該數值時會將連結串列轉換為紅黑樹,提升查詢速度,但同時記憶體使用會增大,因為樹節點的大小約是常規節點的兩倍
         * 
         * 為什麼是8?
         * 在節點良好分佈的情況下,基本不會用到紅黑樹。而在理想情況下的隨機雜湊,節點分佈遵循泊松分佈,連結串列下的長度達到8已經是非常小的概率,超過8的概率我們認為是幾乎不可能發生的事情
         * 不過HashMap還是做了預防措施,當連結串列的長度達到8時會被轉換成紅黑樹,至於為什麼不是7,個人認為8更合適,應該儘可能的提升效能.
         */
        static final int TREEIFY_THRESHOLD = 8;

        /**
         * 當紅黑樹的節點個數小於該數值時,紅黑樹將轉換回連結串列
         * 這裡有個點很重要,當初我以為紅黑樹在刪除節點後長度就會變小,那應該會按照這個指標來將其變成單向連結串列結構,可惜不是,紅黑樹在刪除節點前會判斷是否此樹過小,若過小則轉換為連結串列,若不是則刪除節點並進行自我平衡,所以只有在重新雜湊時* 才會判斷該數值!!!!
         * 
         * 為什麼不是7?
         * 若是頻繁地新增刪除新增刪除元素,那麼HashMap將在轉換中消耗很大的效能,而7的空隙讓它有一個很好的緩衝
         */
        static final int UNTREEIFY_THRESHOLD = 6;


        /**
         * 當連結串列的長度大於8時:
         * 若雜湊表的容量大於64,則將連結串列轉換成紅黑樹
         * 若雜湊表的容量小於64,資料結構保持不變,對雜湊表進行擴容,擴容時原來的節點可能在舊的索引上,有可能在新的索引上(原來的索引 + 舊的容量大小)
         * 至少應該是4 * TREEIFY_THRESHOLD,防止重新雜湊和樹化閾值(TREEIFY_THRESHOLD)產生衝突,因為如果連結串列的長度剛好達到8,這個時候轉成紅黑樹,而如果又剛好發生擴容,那麼此顆紅黑樹又將可能被拆分成連結串列
         * 所以一開始的紅黑樹轉化有可能相當於白做了,所以又加上了陣列容量為64的限定條件,只能說32比16更適合作為一個限定條件
         * 在雜湊表容量很小的情況下,隨著不斷的新增節點,連結串列的長度會越來越大,也會有越來越多的連結串列,當長度超過一定的閾值之後便需要轉換成紅黑樹,而在擴容時又需要拆解成連結串列,這些都是需要一定的成本,所以在容量較小的情況下直接選擇擴容
         */
        static final int MIN_TREEIFY_CAPACITY = 64;

        /**
         * 擴容過程中每個執行緒負責的最小容量個數,簡單來說,就是每個執行緒至少要負責16個位置來將其移動到新雜湊表中,第一個執行緒是從後面開始數16個,即tab.lenght - 1 ~ tab.length - 17 
         * 如果舊雜湊表的容量大小小於或等於16,那麼只會有一個執行緒發生擴容和遷移節點,由於擴容和遷移節點發生在transfer方法裡,雖然在除錯程式碼中有多個執行緒進入到該方法中,但並不能說明多個執行緒同時擴容和遷移
         * 最終發生擴容遷移的仍然只有一個執行緒,這其中是通過Unsafe來控制的
         * 總結:若雜湊表的容量大小小於或等於16,那麼最終只會有一個執行緒發生擴容並遷移節點
         */
        private static final int MIN_TRANSFER_STRIDE = 16;

        /**
         * 常量值,用於生成郵戳,標識當前執行緒正在擴容
         * 目的是為了確保多執行緒下只有一個執行緒會發生擴容,不會有多個執行緒同時在發生擴容操作,不過可以有多個執行緒幫助遷移雜湊表的節點
         * 最終將該左移後的郵戳 + 1 + 擴容的執行緒數(1個) + 幫忙遷移(多個) 構建成sizeCtl屬性,至於為什麼前面要加上1,個人覺得如果不加上1的話,那麼當容量達到最大的情況下sizeCtl = -1,而此值又表示在初始化狀態下...所以是不是不太合理?
         */
        private static int RESIZE_STAMP_BITS = 16;

        /**
         * 常量值,限制幫助遷移雜湊表節點的執行緒個數,不需要所有執行緒都幫忙遷移
         */
        private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;

        /**
         * 常量值,與郵戳進行計算來判斷ConcurrentHashMap是否擴容完成/幫助遷移的執行緒是否超過上限,總之就是用計算後的結果來與sizeCtl進行對比
         */
        private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;

        /**
         * 標識當前節點是ForwardingNode物件,即標識當前節點已經遷移到新雜湊表中了
         */
        static final int MOVED     = -1;

        /**
         * 標識當前節點是紅黑樹,也就是當前節點使用TreeBin物件來包裹紅黑樹的根節點,而在HashMap中是直接使用TreeNode
         * TreeBin物件中還額外加入了類似讀寫鎖的概念,當有執行緒先使用讀操作,其他執行緒的寫操作會阻塞直到讀操作完成,而如果先使用寫操作,其他執行緒的讀操作不會被阻塞,只不過使用了連結串列的方式進行查詢,因為寫操作可能會使紅黑樹的根節點發生變化
         * 具體可參考TreeBin#contendedLock和TreeBin#lockRoot這兩個方法
         * 總結:通過在TreeBin物件中的hash = -2 來標識當前節點是紅黑樹
         */
        static final int TREEBIN   = -2;

        /**
         * 標識ReservationNode物件,該物件主要用來上鎖
         * 當呼叫compute相關方法時需要傳入一個mappingFunction函式表示式,該表示式主要用於計算指定鍵對應的值,在計算過程中其他使用者更新或插入的操作的執行緒可能會發現阻塞(同一個索引處)
         * 但是對於查詢方法來說是不會發生阻塞,那麼這個情況下查詢會返回null
         */
        static final int RESERVED  = -3; // hash for transient reservations
        
        /**
         * 通過與hash值 & 計算來保證hash值不會出現負數
         */
        static final int HASH_BITS = 0x7fffffff;

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

        /**
         * 雜湊表
         * 採用懶載入,只有在第一次插入節點後才開始初始化雜湊表
         */
        transient volatile Node<K,V>[] table;

        // 新雜湊表,當把舊雜湊表的所有節點遷移到新雜湊表後,那麼就會把該值賦給table,最終在將該值給置null
        private transient volatile Node<K,V>[] nextTable;

        /**
         * 在沒有執行緒競爭的情況下用於統計雜湊表的節點個數,若出現競爭則使用counterCells陣列
         */
        private transient volatile long baseCount;

        /**
         * 用於控制雜湊表的初始化與擴容,嚴格來說,是隻會有一個執行緒發生擴容,其他執行緒幫助遷移雜湊表的節點
         * 1. 當值為-1時,表示雜湊表正在初始化
         * 2. 當值為-(1 + 活躍的執行緒數)時,表示雜湊表正在發生擴容,活躍的執行緒數指的是一個發生擴容的執行緒 加上 幫助遷移雜湊表的執行緒,擴容時使用
         *    此時的數值中高16位表示郵戳,標識ConcurrentHashMap正在擴容中,而且只會有一個執行緒正在擴容,其他執行緒幫助遷移節點,低16位表示遷移的執行緒數
         * 3. 此屬性還可以當作雜湊表的容量大小值,在呼叫ConcurrentHashMap建構函式時使用
         * 4. 此屬性還可以當作雜湊表的閾值,即當雜湊表中節點的個數超過閾值後就會發生擴容,類似HashMap#threshold,在初始化雜湊表後使用
         */
        private transient volatile int sizeCtl;

        /**
         * 由於採用的從後面開始遍歷,索引呈現遞減,所以此屬性可以說是剩餘未遷移節點的數量/索引
         * 因為存在多個執行緒幫忙遷移節點,所以可能存在競爭的情況,同時要去處理同一塊區間,每個執行緒預設分配16個節點,所以此屬性必須要使用Unsafe去更新值,也就是說只有一個執行緒會獲得某個區間的使用權
         * 當此屬性等於0時表示所有的節點遷移完成
         */
        private transient volatile int transferIndex;

        /**
         * 通過標識來控制加鎖(1)或釋放鎖(0),控制CounterCell陣列
         * 1. CounterCell陣列初始化的時候需要上鎖,防止多執行緒同時初始化
         * 2. CounterCell陣列擴容的時候需要上鎖
         * 3. 若CounterCell陣列的某個索引上為null,就要建立CounterCell物件,否則多個執行緒併發建立
         */
        private transient volatile int cellsBusy;

        /**
         * counterCells陣列是LongAdder高效能實現的必殺器,當發生執行緒競爭的情況,會將該執行緒隨機分配到某個索引上,若索引上已經存在counterCells物件了,那麼
         * 就將當前執行緒所攜帶的節點個數值進行累加,若不存在counterCells物件,那麼就建立counterCells物件包裝節點個數值,每個執行緒都與一個counterCells物件繫結在一起
         * 所以不同索引處的執行緒就可以併發修改節點個數值,減少了執行緒之間的競爭,提高了效率,最終再將counterCells陣列的所有counterCells物件的節點個數值相加起來,在與baseCount相加就是最終的結果了
         * counterCells陣列在多次累加衝突的情況下會發生擴容,陣列的最大容量是當前計算機的CPU數量
         * LongAdder的吞吐量比AtomicLong高,但消耗的記憶體空間自然就更多了
         */
        private transient volatile CounterCell[] counterCells;

        /**
         * 1. 發現沒有,與HashMap相比,少了個閾值,即當雜湊表的節點個數超過閾值後會發生擴容,所以應該是有其中某個屬性包含了閾值的含義,即sizeCtl
         * 2. 雖然ConcurrentHashMap提供了帶有載入因子的建構函式,但實際上在計算過程中並未使用指定的載入因子進行計算,這個設計有點不合理...要麼就摒棄
         */
    }

建構函式

JDK8中的ConcurrentHashMap即使你指定了2的冪次方作為指定容量,但最終的結果卻不是(雖然在JDK11以後做了修改),所以在對容量沒有要求的情況下最好使用預設。


    // 預設初始化
    public ConcurrentHashMap() {

    }

    /**
     * 指定初始化容量
     * 此時sizeCtl = 雜湊表的容量大小
     * @param initialCapacity 指定初始容量
     */
    public ConcurrentHashMap(int initialCapacity) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException();
        /**
         * 這裡的程式碼很奇怪,也就是說即使你傳入的引數是個2的冪次方,結果的容量大小並不是指定的容量大小,為什麼就不能像HashMap那樣子做呢?
         * 於是去看了Oracle官方的bug庫,也有人提出針對ConcurrentHashMap(int,float,int)建構函式與當前建構函式傳入的引數是一樣的,而得出的sizeCtl容量大小卻是兩個
         * 如ConcurrentHashMap(22,0.75,1) -> 32   ,  ConcurrentHashMap(22)  -> 64 很矛盾...
         * 雖然在JDK11/12版本中做了修改,但我還是覺得寫法沒有HashMap中看的舒服...
         * 這裡貼下Oralce官方有關ConcurrentHashMap建構函式bug的地址:https://bugs.java.com/bugdatabase/view_bug.do?bug_id=8202422
         */
        int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY : tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
        this.sizeCtl = cap;
    }

    /**
     * 指定集合插入到雜湊表
     * 此時sizeCtl = 雜湊表的預設容量大小
     * @param m 指定集合
     */
    public ConcurrentHashMap(Map<? extends K, ? extends V> m) {
        this.sizeCtl = DEFAULT_CAPACITY;
        putAll(m);
    }

    /**
     * 指定初始化容量、載入因子、併發級別
     * 雖然這裡傳入了指定的載入因子,但實際上在初始化閾值時並未使用指定載入因子
     * @param initialCapacity 指定初始容量
     * @param loadFactor 載入因子
     * @param concurrencyLevel 併發級別
     */
    public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) {
        if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
            throw new IllegalArgumentException();
        if (initialCapacity < concurrencyLevel)  
            initialCapacity = concurrencyLevel;
        long size = (long)(1.0 + (long)initialCapacity / loadFactor);
        int cap = (size >= (long)MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : tableSizeFor((int)size);
        this.sizeCtl = cap;
    }


簡單方法


    /**
     * 獲取最小(最接近指定容量大小)2的冪次方
     * @param c 指定容量大小
     * @return 最小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;
    }

    /**
     * 初始化雜湊表或擴容或幫助遷移雜湊表
     * @param size 指定容量大小
     */
    private final void tryPresize(int size) {
        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 (tab == null || (n = tab.length) == 0) {
                n = (sc > c) ? sc : c; // 取容量的最大值
                /**
                 * 通過CAS方式判斷是否有其他執行緒正在初始化雜湊表,即是否有其他執行緒正在構造陣列
                 * 若有的話,則CAS將返回false,後續就會退出方法
                 * 若沒有的話,則CAS將返回true,同時修改sizeCtl = -1,表示當前執行緒正在構造雜湊表
                 * 雜湊表構造完成後,修改sizeCtl成閾值(3/4),即雜湊表發生擴容的標識
                 */
                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;
                            sc = n - (n >>> 2); // n - n/4 = 3n/4 (4分之3)  即sc變成了雜湊表容量大小的3/4
                        }
                    } finally {
                        sizeCtl = sc; // sizeCtrl 等於4分之3的雜湊表容量大小
                    }
                }
            }
            else if (c <= sc || n >= MAXIMUM_CAPACITY) // c 表示最接近指定容量大小,sc 表示閾值,表明新增的節點的個數並未超過閾值,不用進行擴容,直接退出
                break;
            else if (tab == table) {
                int rs = resizeStamp(n); // 生成郵戳
                 // 原始碼中有多出如下程式碼片段,但實際上是有些錯誤的,這些錯誤可以在Oracle官方看到,在addCount方法中提供了詳細的介紹
                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);
                }
                else if (U.compareAndSwapInt(this, SIZECTL, sc,  (rs << RESIZE_STAMP_SHIFT) + 2))
                    transfer(tab, null);
            }
        }
    }

    /**
     * 結合指定變數生成郵戳
     * @param n 指定變數
     * @return 郵戳
     */
    static final int resizeStamp(int n) {
        return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
    }

    /**
     * 將舊雜湊表中的所有節點遷移到新雜湊表中
     * 擴容機制:
     * 1. 只會有一個執行緒發生擴容,其他執行緒幫忙遷移節點,這麼多執行緒一起遷移雜湊表,自然是要有個規則,每個執行緒負責至少遷移16個節點,那麼接下來考慮的是哪個執行緒負責哪16個節點
     * 2. 所有的執行緒都會去獲取transferIndex的值,此值表示還剩餘多少節點未分配,這些執行緒就開始搶以步伐為16的區間,誰搶到就算誰的,直到所有的節點都搶空了...有沒有像超市大媽...
     * 3. 沒有搶到區間的執行緒自然就退出了,搶到的執行緒就開始從後往前一一遷移節點到新雜湊表中,索引呈現遞減的趨勢,遷移完成的節點用fwd標識,以允許其他執行緒通過get方法訪問到節點,如果沒遷移完成則會迴圈獲取
     * 4. 在遷移過程中,不管是紅黑樹還是連結串列都會使用建立新節點的方式進行遷移,目的是為了其他執行緒能夠讀取節點,也就是說即使在擴容過程中,仍然允許併發讀
     * 5. 對於不為null的節點如果是連結串列則在建立新節點過程中會使用lastRun的方式進行遷移,可以減少新節點的建立,而對於紅黑樹來說則不行,因為紅黑樹的結構更為複雜,有可能為了減少新節點的建立的操作可能會引發迴圈引用,反而增加了開銷
     * 6. 每個執行緒把區間內的節點都遷移完成後,還要再去看看還有沒有可分配的區間,如果沒有且不是最後一個執行緒則直接退出,如果是最後一個執行緒則還要再把整個雜湊表遍歷一遍再次檢查下是否有遺漏的節點沒遷移;如果還存在可分配的區間則繼續搶
     *    繼續遷移,直到沒有可分配的區間了
     * 
     * @param tab 舊雜湊表
     * @param nextTab 新雜湊表
     */
    private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
        int n = tab.length, stride;
        /**
         * stride在英文單詞中指的是步伐,在程式碼中的含義是表示在雜湊表發生擴容過程中,既然是擴容,那麼就有資料從舊雜湊表遷移到新雜湊表的這樣一種動作
         * 那麼就要思考到底是要如何遷移,是由當前執行緒去處理所有的節點呢,還是如何? 繼續分析
         * 如果是由當前執行緒去處理所有的節點,那麼在保證執行緒安全的前提下,就必須要求其他執行緒不能進行任何的插入刪除更新操作,否則可能造成資料的不一致,這樣子的方式無疑降低了效率
         * 所以它允許其他執行緒幫忙處理舊雜湊表的遷移,那麼多的執行緒一起處理遷移必須要制定要規則,否則容易造成混亂,至於規則請看後續分析...
         * 
         * stride表示每個執行緒應該處理多少個數組,即每個執行緒要負責遷移多少個數組到新雜湊表中,其實這裡還有一些分析:
         * NCPU指的是計算機的處理器個數,至少會有一個處理器,即NCPU = 1,如果是這種情況,那麼stride = n = tab.length,也就是當前執行緒要遷移所有的陣列,為什麼呢?
         * 因為如果允許其他執行緒來幫忙處理遷移的話,那麼在單處理器下當前執行緒就需要先暫停手頭上的工作,發生上下文切換,然後由其他執行緒去處理,這樣子做是不是奇葩了...
         * 如果NCPU > 1 表示有多個處理器,那麼就可以在不用暫停當前執行緒的情況下允許其他執行緒幫忙遷移,而且/NCPU可以為每個處理器平均分配
         *
         * 總結:stride的值總是會大於等於MIN_TRANSFER_STRIDE(16),所以說每個執行緒至少要處理16個節點
         */
        if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
            stride = MIN_TRANSFER_STRIDE;
        if (nextTab == null) { // nextTab == null表示還未開始遷移,要先構建新雜湊表
            try {
                @SuppressWarnings("unchecked")
                Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1]; // 構建新雜湊表,同HashMap一樣,與舊雜湊表的容量相比還是2倍大小!!!
                nextTab = nt;
            } catch (Throwable ex) {      // 這裡會發生異常猜測可能是由於記憶體溢位了
                sizeCtl = Integer.MAX_VALUE;
                return;
            }
            nextTable = nextTab;
            transferIndex = n;
        }
        /**
         * nextn:新雜湊表的容量大小
         * advance: 是否可以遷移下一個節點,前提是先分配到了指定遷移節點的區間
         * finishing: 擴容是否完成,只有最後一個遷移完成的執行緒會修改此值
         */
        int nextn = nextTab.length;
        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;
            /**
             * 1.advance表示是否可以遷移下一個節點,不過這有一個前提是分配到了指定遷移節點的區間,不然一切都是白說,如果成功分配到了,那麼才開始考慮是否遷移節點
             *   還有一點因為中途有可能出現節點遷移失敗的情況,那麼就要重複去遷移此節點,此時的advance是false
             *   遷移是從雜湊表的尾部開始,即從 i = tab.length - 1 處開始遷移,依次遞減,每個執行緒預設至少遷移16個節點
             * 2. bound表示遷移節點的結束索引,也就是隨著索引的遞減到bound後就表示執行緒已經遷移完指定的區間了,那麼此時會去判斷是否還有未遷移的區間呢,通過transferIndex是否等於0來判斷
             *   若有自然就獲取下一個遷移的區間,若沒有自然就退出了
             * 3. i自然表示要遷移節點的索引值,當 i < bouond就表示指定區間已經遷移完成了,那麼要去獲取下一個指定區間了
             *
             * 所以總體來說,下面的while程式碼片段是在獲取指定遷移節點的區間,沒有的話就走下面的if語句了,有的話就會更新transferIndex的值,表示這個區間我拿走了,以便其他執行緒知道,不會發生重複區間的遷移,隨後就開始
             * 一個一個的往新雜湊表遷移節點了
             *
             * 以下假設採用預設遷移16個節點
             * 
             * 假設雜湊表的容量是64,第一個執行緒進來,也就是要求擴容的執行緒,advance預設是true,接著--i > bound的結果自然是false,接著判斷transferIndex是否為0,此時的transferIndex = 64
             * 接著更新transferIndex的值,如果更新成功的話,那說明此區間由當前執行緒負責了,如不成功則說明此區間已經被其他執行緒拿下來,那麼當前執行緒就需要重新去拿區間了,分配到區間後就更新bound與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;
                }
            }
            /**
             * 1. i < 0,此時i只可能等於-1,表示給當前執行緒分配的指定遷移節點的區間的所有節點都已經遷移完成了,且已經沒有可分配給當前執行緒新的區間,也就是說雜湊表中的區間都已經分配完了,當前執行緒也都遷移完成了,可以功成身退了(退出)
             * 2. i >= n || i + n >= nextn,對於這兩個情況我沒有想到有什麼場景,也在Oracle的官方bug庫上看不到有關此處的問題,鑑於自己的能力有限不敢去提,哈哈哈,說下我的疑惑
             *    i的值是受nextIndex所影響,而nextIndex是受transferIndex所影響,所以i也是受transferIndex所影響,所以i要發生等於或大於的情況,只有transferIndex發生變化,那麼transferIndex表示剩餘未分配節點的數量
             *    要讓它變化也只有在雜湊表的容量發生變化,那麼雜湊表發生變化就需要擴容,所以要影響這些變化就可能需要多個執行緒併發擴容,可這個結論與sizeCtl的設定初衷又是矛盾的,本身sizeCtl就是為了防止多個執行緒同時發生擴容
             *    所以我並不明白這裡的點,當然了,它這裡的寫法並不是錯誤的,我只是認為它不可能發生....
             */
            if (i < 0 || i >= n || i + n >= nextn) {
                int sc;
                if (finishing) { // 所有的節點確實都遷移完成了
                    nextTable = null;
                    table = nextTab;
                    sizeCtl = (n << 1) - (n >>> 1); // 所有的節點真正遷移完成了,此時的sizeCtl表示新雜湊表的閾值,為下一個擴容做準備
                    return;
                }
                if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) { // 針對上面提到的第一點,已經完成任務的可能退出了,那麼執行緒數就減去1
                 // 看看當前執行緒是否是最後一個退出的執行緒,因為第一個執行緒擴容時sizeCtl = (rs << RESIZE_STAMP_SHIFT) + 2,這是sizeCtl開始擴容時的初始化,所以節點遷移完成後的sizeCtl應該也要等於初始值
                    if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
                        return;
                    finishing = advance = true; // 表示雜湊表的所有節點都遷移完成了
                    /**
                     * 上面已經認定了雜湊表的所有節點都遷移完成了,但為什麼這裡還要再次檢查雜湊表呢,也就是重新遍歷整張表,說下我的想法,此想法未經過驗證
                     * 多執行緒下的環境是很複雜的,說不定會出現什麼么蛾子,雖然所有的節點都已經遷移完成了,但實際上當前執行緒只知道自己把該遷移的節點都遷移了,誰知道其他執行緒有沒有好好做事情
                     * 有沒有把該負責的區間都處理好了,當前執行緒確定不了,放心不下,所以只好在重新檢查一遍,檢查下是否有遺漏的,這也算是一種保守策略
                     */
                    i = n;
                }
            }
            else if ((f = tabAt(tab, i)) == null) // 如果舊雜湊表中指定索引處為null,則使用fwd物件來填充,通過fwd.hash == MOVED標識該位置已經遷移完畢
                advance = casTabAt(tab, i, null, fwd); // 在多執行緒的情況下有可能導致該節點遷移失敗,不過沒關係,會進行重複遷移
            else if ((fh = f.hash) == MOVED) // f.hash == MOVED說明當前節點是fwd,標識當前節點已經遷移,可以繼續遷移下一個節點了
                advance = true;
            else { // 走到這裡表明指定索引處存在未遷移的節點,那麼先使用頭節點作為鎖物件來上鎖,防止其他執行緒修改
                // 下面的程式碼片段是紅黑樹或連結串列要進行遷移的動作,其中會發生節點的複製(克隆)
                synchronized (f) {
                    if (tabAt(tab, i) == f) { // 有可能出現已經獲取到f指向當前節點,但未及時上鎖,而被其他執行緒修改或遷移了,所以這裡再上鎖後又判斷了一次,防止進行不必要的遷移
                        Node<K,V> ln, hn;
                        if (fh >= 0) { // fh:當前節點的雜湊值,fh > 0表示連結串列
                            /**
                             * 這裡的操作跟HashMap是類似的,將連結串列中的節點分為高位一條連結串列、低位一條連結串列,如何區分高位與低位就在於新雜湊表的容量大小對應的二進位制的最後一位(從右向左看) & 當前節點的hash的二進位制的對應位置的結果是否為1
                             * 若為1那麼自然是高位,否則就是低位,高位與低位的儲存區別在於在新雜湊表中儲存的位置就不同,低位在新雜湊表中儲存在原索引位置上,而高位則儲存在原索引 + 舊雜湊表的容量大小
                             * 在HashMap中已經詳解介紹了,可去翻看
                             *
                             * 接著在說下為什麼要使用lastRun,而不像HashMap那樣子處理呢?
                             * 因為ConcurrentHashMap是支援併發讀的,也就是說即使在擴容的情況下,其他執行緒依然可以進行讀取操作,那麼問題了,如果它按照HashMap那樣子的話,HashMap是直接將連結串列進行拆分
                             * 也就是說,有可能上一個執行緒插入了節點導致了擴容進而拆分連結串列,那麼下一個執行緒有可能因為連結串列高低位的拆分而查詢不到物件,就是有可能讀取不到資料,既然ConcurrentHashMap支援併發讀,那麼就不能直接
                             * 操作節點之間的關係,通常來說,可以遍歷連結串列,判斷節點的高低位然後建立新節點來關聯關係,這樣子肯定是可以的,不夠還有更優的辦法,它加入了lastRun,利用原來的節點可以重複的情況可以減少新節點的建立
                             * 簡單來說,比如連結串列呈現 0 -> 1 -> 0 -> 1 -> 0 -> 0 -> 0,其中1和0表示高低位,既然要分成高低兩條連結串列,那麼只需要把0的節點關聯起來即可,同理1的節點也是如此,那麼思考下後面3個連續的0是否需要重新
                             * 關聯關係,自然是不用了,在原來的連結串列上就已經是關聯好的,所以只要與前面兩個0的節點關聯起來就可以了,由於是這兩個節點是斷開的,所以只能新建立節點了,不然要影響併發讀了,最終只要新建立兩個節點,並與
                             * 最後的3個節點關聯起來就可以了,因為最後的3個節點的關係始終都沒有變化,始終都是一致的,要麼都是高位,要麼都是低位,所以這是可以利用的,這就是上面說的,可以重複利用原來的節點減少新節點的建立
                             * 說到這裡你們應該能明白lastRun的意思了吧,指的就是最後一次高低位變化的節點,要麼是高位,要麼是低位,總之從lastRun節點開始,後續的所有節點都跟lastRun同是一樣高(低)位,而lastRun之前的節點就需要
                             * 建立新節點來關聯關係了
                             *
                             */
                            int runBit = fh & n; // runBit 就是在計算高低位
                            Node<K,V> lastRun = f;
                            for (Node<K,V> p = f.next; p != null; p = p.next) { // p:當前節點的下一個節點
                                int b = p.hash & n; // b:當前節點的下一個節點的hash值
                                if (b != runBit) { // 計算出最後一次高低位變化的節點,lastRun節點後面的所有節點都跟lastRun一樣的高位或低位
                                    runBit = b;
                                    lastRun = p;
                                }
                            }
                            // 看看lastRun是高位還是低位,以便與之前的節點關聯關係
                            if (runBit == 0) {
                                ln = lastRun;
                                hn = null;
                            }
                            else {
                                hn = lastRun;
                                ln = null;
                            }
                            for (Node<K,V> p = f; p != lastRun; p = p.next) { // lastRun節點的後續節點不需要再關聯關係,因為它們都跟lastRun節點是一樣的高位或低位,利用了原來的節點,所以只要關聯lastRun節點之前的就可以了
                                int ph = p.hash; K pk = p.key; V pv = p.val; // ph:當前節點的hash值  pk:當前節點的key pv:當前節點的value
                                /**
                                 * 解釋下低位
                                 * 將連結串列中第一個低位的節點與lastRun關聯起來,即低位節點的下一個節點是lastRun節點,然後把低位節點當作第二個低位節點的下一個節點,第二個低位節點當作第三個低位節點的下一個節點,直到遇到lastRun節點
                                 * 假設原連結串列: A(0) -> B(1) -> C(0) -> D(1) -> E(0)(lastRun) -> F(0) -> G(0),其中0表示低位,1表示高位
                                 * 那麼低位連結串列:C(0) -> A(0) -> E(0) -> F(0) -> G(0)
                                 * 那麼高位連結串列:D(1) -> B(1)
                                 * 
                                 * 如果lastRun是高位的話,那麼也是同理,其實你仔細一看,總有一條連結串列會出現原來高位連結串列的倒序
                                 */
                                if ((ph & n) == 0)
                                    ln = new Node<K,V>(ph, pk, pv, ln); 
                                else
                                    hn = new Node<K,V>(ph, pk, pv, hn);
                            }
                            // 接下來就是將高低位兩條連結串列遷移到新雜湊表的位置上了
                            setTabAt(nextTab, i, ln);
                            setTabAt(nextTab, i + n, hn);
                            setTabAt(tab, i, fwd); // 當前節點遷移完成了用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;
                            /**
                             * 上面咱們理解的頭頭是道,就是應該利用lastRun來減少節點的建立,可是看下面的程式碼片段正好和HashMap差不多啊
                             * 如果你仔細看過HashMap中關於轉換成紅黑樹的過程的話,那麼你應該知道它是由原本的單鏈錶轉換雙向連結串列,為什麼要轉成雙向連結串列,因為連結串列中的某一個節點有可能變成根節點,它會將根節點變成
                             * 雜湊表的頭節點,這就造成了根節點的上下節點的關係發生變化,所以原來根節點的上下節點需要重新關聯關係,有點繞,也有點複雜,其實就是為了說明對於紅黑樹來說,它需要雙向連結串列,不能用單向連結串列
                             * 否則在移除節點時就要重頭開始判斷,降低效率。所以如果直接使用lastRun的話是不能滿足紅黑樹的,lastRun及其後續的節點仍然要上下節點的關聯關係,當然了,有人認為可以在遍歷
                             * lastRun讓其關聯關係就可以了,沒錯,是可以的,但你想想,紅黑樹的節點它不向連結串列那麼簡單,它包含了很多屬性,比如left、right、parent,有可能牽扯到已經新建立的節點,會顯得很亂,所以在這裡
                             * 直接遍歷建立是比較好的
                             */
                            for (Node<K,V> e = t.first; e != null; e = e.next) {
                                int h = e.hash;
                                /**
                                 * 假設原連結串列:A(0) -> B(1) -> C(0) -> D(1) -> E(0) -> F(0) -> G(0)
                                 * 低位連結串列: G <-> F <-> E <-> C <-> A
                                 * 高位連結串列: D <-> B
                                 * 結果很明顯,呈現高低位呈現倒序
                                 */
                                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;
                                }
                            }
                            // 判斷高位低連結串列的長度是否小於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;
                        }
                    }
                }
            }
        }
    }

    /**
     * 插入新節點或更新節點或刪除節點都要更新雜湊表的節點個數
     * 插入新節點還需要考慮是否要擴容或幫助遷移節點
     * @param x 插入的節點個數,-1的情況是刪除節點
     * @param check 用於判斷是否要考慮擴容
     */
    private final void addCount(long x, int check) {
        CounterCell[] as; long b, s;

        /**
         * 關於雜湊表節點個數的統計方式與思想跟LongAdder類是一樣的,雖然我對LongAdder類也是第一次見,但筆者也只准備說說它的思想,目前不會做其程式碼上的深入了,其實有關它的程式碼並不難
         * 
         * 為什麼使用LongAdder而不是使用AtomicLong?
         * 在多個執行緒併發插入元素而修改節點個數時,AtomicLong只會允許其中一個執行緒修改成功,沒修改成功的執行緒只能迴圈修改直到成功,這使得效率降低了很多了,而LongAdder中有兩個屬性,一個base屬性
         * 一個Cell陣列,base屬性就好比是AtomicLong中的value一樣,不過這個屬性是在沒有競爭的情況下使用,也就是說在沒有競爭的情況下LongAdder的效率和AtomicLong是一樣,因為並沒有用到cell陣列
         * 而有了競爭之後cell陣列就排上用場了,它用一個Cell物件將每個執行緒所攜帶的節點個數值,也就是每個執行緒插入的節點個數值包裝起來,簡單來說就是Cell物件中包含了節點個數的屬性值,並未每個執行緒取隨機數
         * 取隨機數的目的是為了隨機分配到陣列的位置上,計算索引的方式就跟hash & (n.lenght - 1)一樣,相當於每個執行緒都與Cell陣列中的某一個Cell物件繫結在一起了,多個不同索引處的執行緒就可以併發修改自己的屬性值
         * 最後在將所有Cell物件的屬性值加起來,在與base相加即可,Cell陣列還會發生擴容,最大長度是當前計算機的CPU個數
         * 
         * 總結:LongAdder優先考慮base,如果失敗了在使用Cell(CounterCell),LongAdder的吞吐量比AtomicLong高,但佔用的空間更多
         */
        if ((as = counterCells) != null || !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) { // 前者判斷表示之前發生過競爭,可以直接使用Cell物件,後者表示剛好發生第一次競爭,要去初始化Cell陣列
            CounterCell a; long v; int m;
            boolean uncontended = true;
            /**
             * as == null || (m = as.length - 1) < 0  表示Cell陣列還未初始化
             * (a = as[ThreadLocalRandom.getProbe() & m]) == null  Cell陣列已經初始化過了,若結果為true表示此索引處還未發生累加,後續會為它建立Cell物件
             * !(uncontended = U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x)  表示此索引處已經有Cell物件了,那麼就使用Cell物件進行累加操作,如果失敗了說明要進入到指定方法中重新生成隨機數並重新計算
             */
            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;
            }
        
            /**
             * 走到這裡說明已經使用Cell物件成功累加了節點個數值
             * check用於判斷是否要考慮擴容
             * 若check = -1表示並未插入新節點,並不需要考慮擴容
             * 若check = 1則還要是否發生衝突,發生衝突的話說明有多個執行緒在插入新節點,這個時候只要求修改節點個數成功的執行緒去考慮擴容就可以了,衝突的執行緒退出就可以了,因為擴容的執行緒在擴容結束了還會計算節點的個數
             * 不過沖突的執行緒退出只能在check = 1的情況下,畢竟這可能造成誤差,只是誤差只有一個
             * 若check > 1 即使執行緒之間發生衝突也會考慮擴容
             */
            if (check <= 1)
                return;
            s = sumCount(); // 統計雜湊表中的節點個數
        }
        if (check >= 0) {
            Node<K,V>[] tab, nt; int n, sc;
            /**
             * s > sizeCtl有兩種情況發生:
             * 1. 雜湊表中的節點個數達到閾值了,要進行擴容,此時的sc > 0,也就是下面else if執行語句
             * 2. 有其他執行緒正在發生擴容,此時的sc < 0,所以s很容易就大於sc,而當前執行緒就要判斷是否要幫助擴容,接下來重點來了!!!
             *    在上面我們反覆強調所謂的郵戳就是用來保證不會發生多個執行緒同時擴容的情況,也就是始終只有一個執行緒會發生擴容,其他執行緒幫忙遷移節點
             *    sc的高16位表示郵戳,低16位表示遷移節點的執行緒數 + 1,所以這裡的無符號右移16位就是為了獲取郵戳
             *    接下來的判斷sc == rs + 1 你會發現顯得莫名其妙,sc是個負數,而rs是個正數,怎麼可能相等,這一塊我也是耗費了很長時間,覺得頂尖大師怎麼可能會犯低階錯誤
             *    於是我翻遍了谷歌的所有有關ConcurrentHashMap的相關文章,終於有人說這已經再Oracle官方bug上提出問題並在JDK12上修復了
             *    提供Oracle官方提供的bug庫: https://bugs.java.com/bugdatabase/view_bug.do?bug_id=8214427
             *    所以正確的寫法應該是:sc == (rs << RESIZE_STAMP_SHIFT) + 1 || sc == ( rs << RESIZE_STAMP_SHIFT ) + MAX_RESIZERS
             *    第一個 sc == (rs << RESIZE_STAMP_SHIFT) + 1是在判斷正在擴容的執行緒是否結束擴容了,如果已經結果擴容的話,那麼結果為true,因為擴容的執行緒會使 sc =  (rs << RESIZE_STAMP_SHIFT) + 2
             *    而在擴容結束後用sc - 1,結果即是  sc = (rs << RESIZE_STAMP_SHIFT) + 1 
             *    第二個是在判斷幫助遷移節點的執行緒數是否超過了上限,如果是的話自然就不用再幫助了
             *    nextTable = null 表示擴容結束了或者執行緒正要開始擴容,即nextTable還未賦值,此時此刻可能出現兩種情況,所以要幫助擴容的話還需要加上其他條件,不過在nextTable還沒賦值之前其實誰也不知道新雜湊表的容量大小是多少
             *    相當於是認為也不知道到底要不要幫忙,所以這裡不好確認只能採用保守策略不幫忙了
             *    transferIndex = 0 表示要已經沒有要遷移的區間,所有的區間都已經分配給其他執行緒了,不用當前執行緒來操勞了
             */
            while (s >= (long)(sc = sizeCtl) && (tab = table) != null && (n = tab.length) < MAXIMUM_CAPACITY) {
                int rs = resizeStamp(n);
                if (sc < 0) { // 有其他執行緒正在擴容,是否要幫助遷移節點
                    /**
                     * Oracle JDK12中修復了此處的邏輯問題,正確寫法:sc == (rs << RESIZE_STAMP_SHIFT) + 1 || sc == ( rs << RESIZE_STAMP_SHIFT ) + MAX_RESIZERS
                     */
                    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)) // 幫忙去遷移節點,執行緒數加1
                        transfer(tab, nt);
                }
                else if (U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)) // 上面提到的第一種情況,開始擴容
                    transfer(tab, null);
                s = sumCount();
            }
        }
    }

    /**
     * 獲取指定雜湊表中指定索引處的節點
     * @param tab 指定雜湊表
     * @param i 指定索引
     * @return 結果值
     */
    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);
    }

    /**
     * 更新指定雜湊表中指定索引處的節點為指定節點
     * @param tab 指定雜湊表
     * @param i 指定索引
     * @param c 指定索引處的預期節點
     * @param v 指定索引處的新節點
     * @return 是否更新成功
     */
    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);
    }

    /**
     * 插入節點
     * @param key 鍵值
     * @param value 值
     * @return null或舊值
     */
    public V put(K key, V value) {
        return putVal(key, value, false);
    }

    /**
     * 插入節點
     * 若指定索引處不存在節點則直接插入
     * 若指定索引處存在節點,還要判斷是否有其他執行緒正在擴容,若不是則鎖住指定索引上的頭節點,防止多執行緒修改,若有其他執行緒正在擴容則幫助遷移節點
     * @param key 指定鍵
     * @param value 指定值
     * @param onlyIfAbsent 在鍵值對存在的情況下發生重複時新增是否不允許修改值,為true則表示不允許
     * @return 舊值或null
     */
    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; // 主要用於計算連結串列的長度,若長度超過8則需要轉換成紅黑樹
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            if (tab == null || (n = tab.length) == 0)
                tab = initTable(); // 初始化雜湊表
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { // (n - 1) & hash的原理就跟HashMap中一樣了,如果當前索引下為null則直接插入,由於多執行緒的影響可能會發生覆蓋
                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 = helpTransfer(tab, f);
            else {
                V oldVal = null;
                synchronized (f) { // 走到這裡說明當前索引存在節點,對其頭節點上鎖,使得其他執行緒不能刪除/更新相同索引處的節點,包含當前索引處的所有節點,當然除了讀取操作可以任意操作了,但這就可能導致讀取的資料可能不是最新的
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) { // 查詢連結串列
                            binCount = 1; // binCount用於計算連結串列的長度
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                if (e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek)))) { // hash值相同的則進行替換
                                    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) // 當連結串列的長度超過8時則需要轉換成紅黑樹
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount); // 更新節點個數並選擇性幫助擴容
        return null;
    }

    /**
     * 計算雜湊值
     * 與HashMap差別就在於 & HASH_BITS,如果不 & HASH_BITS的值有可能hash會出現負數的情況,而在程式碼中多處通過hash < 0(負數)來判斷是紅黑樹還是連結串列,所以為了避免負數帶來效率上的影響
     * 通過 & HASH_BITS 避免最高位永遠是0,也就是說hash值永遠是正數
     * @param h 雜湊值
     * @return 計算後的雜湊值
     */
    static final int spread(int h) {
        return (h ^ (h >>> 16)) & HASH_BITS;
    }

    

    // 剩下的方法基本都是跟遍歷獲取有關係,其中還跟ForkJoin關聯起來了,就不做一一分析了,畢竟有6000行的程式碼,咱們有更重要的事要做...


總結

  • ConcurrentHashMap中並未對讀操作(get)進行加鎖,所以說在多執行緒下可以併發讀、併發讀寫,但這可能會造成讀取到的資料並不是最新的,所以ConcurrentHashMap並不具備強一致性。

  • HashMap在將連結串列裝成紅黑樹後,紅黑樹的根節點變成了頭節點,而ConcurrentHashMap在將連結串列裝換成紅黑樹後,並未將根節點放到雜湊表的頭節點上。

  • ConcurrentHashMap#transfer對連結串列節點的遷移中採用了lastRun(最後一次高低位變換的節點),其實現是利用原連結串列上的節點可以重複的情況來減少新節點的建立,因為從lastRun節點開始,後續的所有節點都跟lastRun同是一樣高(低)位,而lastRun之前的節點就不一樣,所以建立新節點。

  • ConcurrentHashMap擴容機制(摘自transfer方法,不理解的地方可直接看方法分析):

    • 只會有一個執行緒發生擴容,其他執行緒幫忙遷移節點,這麼多執行緒一起遷移雜湊表,自然是要有個規則,每個執行緒負責至少遷移16個節點,那麼接下來考慮的是哪個執行緒負責哪16個節點。

    • 所有的執行緒都會去獲取transferIndex的值,此值表示還剩餘多少節點未分配,這些執行緒就開始搶以步伐為16的區間,誰搶到就算誰的,直到所有的節點都搶空了...有沒有像超市大媽...。

    • 沒有搶到區間的執行緒自然就退出了,搶到的執行緒就開始從後往前一一遷移節點到新雜湊表中,索引呈現遞減的趨勢,遷移完成的節點用fwd標識,以允許其他執行緒通過get方法訪問到節點,如果沒遷移完成則會迴圈獲取。

    • 在遷移過程中,不管是紅黑樹還是連結串列都會使用建立新節點的方式進行遷移,目的是為了其他執行緒能夠讀取節點,也就是說即使在擴容過程中,仍然允許併發讀。

    • 對於不為null的節點如果是連結串列則在建立新節點過程中會使用lastRun的方式進行遷移,可以減少新節點的建立,而對於紅黑樹來說則不行,因為紅黑樹的結構更為複雜,有可能為了減少新節點的建立的操作可能會引發迴圈引用,反而增加了開銷。

    • 每個執行緒把區間內的節點都遷移完成後,還要再去看看還有沒有可分配的區間,如果沒有且不是最後一個執行緒則直接退出,如果是最後一個執行緒則還要再把整個雜湊表遍歷一遍再次檢查下是否有遺漏的節點沒遷移;如果還存在可分配的區間則繼續搶繼續遷移,直到沒有可分配的區間了。

  • ConcurrentHashMap統計節點個數的機制(摘自addCount方法):在多個執行緒併發插入元素而修改節點個數時,AtomicLong只會允許其中一個執行緒修改成功,沒修改成功的執行緒只能迴圈修改直到成功,這使得效率降低了很多了,而LongAdder中有兩個屬性,一個base屬性,一個Cell陣列。base屬性就好比是AtomicLong中的value一樣,不過這個屬性是在沒有競爭的情況下使用,也就是說在沒有競爭的情況下LongAdder的效率和AtomicLong是一樣,因為並沒有用到cell陣列,而有了競爭之後cell陣列就排上用場了,它用一個Cell物件將每個執行緒所攜帶的節點個數值,也就是每個執行緒插入的節點個數值包裝起來,簡單來說就是Cell物件中包含了節點個數的屬性值,並未每個執行緒取隨機數。取隨機數的目的是為了隨機分配到陣列的位置上,計算索引的方式就跟hash & (n.lenght - 1)一樣,相當於每個執行緒都與Cell陣列中的某一個Cell物件繫結在一起了,多個不同索引處的執行緒就可以併發修改自己的屬性值,最後在將所有Cell物件的屬性值加起來,在與base相加即可,Cell陣列還會發生擴容,最大長度是當前計算機的CPU個數。

結束語

經過這次閱讀ConcurrentHashMap底層程式碼的經歷來看,想給讀者一個忠告。對於一些複雜的知識點最好是能夠親身去經歷,比如自己嘗試閱讀,當然了,這其中可以參考別人的文章、書籍,但是千萬千萬不能盲目的相信,一定要經過自己的驗證,你不能保證別人的知識點的正確性,所以一定要自己閱讀並驗證,第一次閱讀原始碼或許很辛苦,我基本上都是一行一行的理解,後續原始碼看多了你自然就通暢了,不要畏懼,這是你強大的必經道路之一,等你總結並分享這些知識點的時候你會很有成就感,信心十足,就算以後面試遇到相關問題你也不會怕,所以一定要相信自己!

參考資料

https://zhuanlan.zhihu.com/p/62299547?utm_source=wechat_session&utm_medium=social&utm_oi=666460142338445312

https://lequ7.com/2019/07/06/java/JDK-yuan-ma-nei-xie-shi-er-zhi-bing-fa-ConcurrentHashMap-xia-pian/