ConcurrentHashMap源碼閱讀筆記

HashMap是我們用的比較多的數據結構,但是它在高併發下面進行put操作時,很有可能會引起死循環,這主要是在它擴容的情況下,導致鏈表頭尾可能存在重複節點,而這時候解決的辦法有很多,如Hashtable和Collections.synchronizedMap(hashMap),但是這倆貨的性能是存在缺陷的,因爲都是鎖整個對象。
這時候ConcurrentHashMap出現了,他很好的彌補了HashMap的併發缺陷,也兼顧了上兩個方案的高性能讀寫。

Question :

  • 它在高併發下是如何做到的?
    • 如何做到高性能寫入?
    • 如何避免HashMap的擴容引發的血案[多線程下擴容會出現鏈表死循環]?

相關概念介紹

// 數組節點 , 初始化是16 
transient volatile Node<K,V>[] table;

// 默認爲null,擴容時新生成的數組,其大小爲原數組的兩倍。可以理解爲爲擴容所做的臨時變量,臨時用來做數據交換的,擴容完畢則設置爲null
private transient volatile Node<K,V>[] nextTable;
  // 一個基礎計數器,用於統計ConcurrentHashMap的計算次數
  private transient volatile long baseCount;
  /* 默認爲0,用來控制table的初始化和擴容操作,具體應用在後續會體現出來。
      -1 代表table正在初始化
      -N 表示有N-1個線程正在進行擴容操作
      其餘情況:
      1、如果table未初始化,表示table需要初始化的大小。
      2、如果table初始化完成,表示table的容量,默認是table大小的0.75倍,居然用這個公式算0.75(n - (n >>> 2))。 
  */
 private transient volatile int sizeCtl;

// 擴容時候需要用到的下標計數值,需要通過cas去設置的下標值
 private transient volatile int transferIndex;

put 方法

 /** Implementation for put and putIfAbsent */
    final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        // 通過Hash算法得到要存入Key的HashCode碼
        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,讓下一次循環繼續
                tab = initTable();
            // 判斷內存中的對象是否爲null,如果爲空則新創建一個鏈表,把該對象作爲首節點插入
            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數據遷移到新的table數據,而同時有線程在獲取老的數據裏面的值
            else if ((fh = f.hash) == MOVED)
                // 這裏據說是爲了未完成擴容的情況下,這裏會幫助另一個線程加速擴容
                /**
                  這是一個協助擴容的方法。這個方法被調用的時候,當前ConcurrentHashMap一定已
                  經有了nextTable對象,首先拿到這個nextTable對象,調用transfer方法。回看上面的
                  transfer方法      可以看到,當本線程進入擴容方法的時候會直接進入複製階段。
                /*
                tab = helpTransfer(tab, f);
            else {
                // 能夠進入到這裏的情況有以下幾種:
                // 1 它Hash到的下標鏈表已經有值了,有值了,也可能存在兩個條件 
                //           1.存在重複的Hash值,需要覆蓋,2.不存在重複的值,則需要將它添加到尾節點
                V oldVal = null;

                // 注意了,這裏使用了synchronized , 
                //猜想是因爲f是node鏈表,這裏是爲了防止這個鏈表在更新時出現數據不一致的問題.... 
                //這裏也就是在插入的時候會進行鏈表的鎖定,這時候就可以放心的對鏈表做操作了
                synchronized (f) {
                    // 通過CAS去獲取內存中的node節點對象
                    if (tabAt(tab, i) == f) {
                        // fh是當前key的hashCode
                        if (fh >= 0) {
                            // 表示計數
                            binCount = 1;
                            // 下面是循環這個鏈表節點,取出鏈表中的hash碼與當前key做比較
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                // 判斷鏈表中的Hash碼是否存在,存在則替換,不存在則添加到尾節點
                                if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                     (ek != null && key.equals(ek)))) {
                                    // 如果存在,則將老的值取出來,作爲返回出去的結果
                                    oldVal = e.val;
                                    // 在這個值爲false的情況下,進行替換,表示是否覆蓋
                                    if (!onlyIfAbsent)
                                        // 將新的值賦給這個鏈表
                                        e.val = value;
                                    break;
                                }
                                Node<K,V> pred = e;
                                //如果鏈表中不存在這個key相關的節點,則默認插入這個鏈表的尾部
                                if ((e = e.next) == null) {
                                    pred.next = new Node<K,V>(hash, key,
                                                              value, null);
                                    break;
                                }
                            }
                        }
                        // 這裏會判斷當前節點是否是Tree節點,
                        // 這一種情況會出現在鏈表大小達到8個的時候,會將node轉化成TreeBin。
                        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;
    }


// 初始化table的方法
private final Node<K,V>[] initTable() {
        Node<K,V>[] tab; int sc;
        // 
        while ((tab = table) == null || tab.length == 0) {
            // 如果當前sizeCtl標識小於0(-1表示正在初始化)時,則線程
            if ((sc = sizeCtl) < 0)
                // 表示讓出CPU,處於就緒狀態。
                Thread.yield(); // lost initialization race; just spin
             // compareAndSwapInt -> CAS 原子性操作,通過原子操作將當前表格設置爲初始化
            //這個方法有四個參數,其中第一個參數爲需要改變的對象,第二個爲偏移量(即之前求出來的valueOffset的值),
            //第三個參數爲期待的值(這裏默認爲0),第四個爲更新後的值(-1上面概念中提到-1表示table正在初始化)
            else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
                try {
                    // 這裏會先判斷table是否爲null,因爲害怕其他線程先一步已經創建好了.
                    if ((tab = table) == null || tab.length == 0) {
                        // 默認初始化table大小,DEFAULT_CAPACITY = 16
                        int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                        @SuppressWarnings("unchecked")
                        // 構建一個上面指定的數組大小
                        Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                        // 將這個變量賦給一個全局變量,就是爲了避免上面  if ((tab = table) == null)的情況
                        table = tab = nt;
                        // 這裏會設定一個閥值,就是當前的0.75,可以這麼理解                    
                        sc = n - (n >>> 2);
                    }
                } finally {
                    // 初始化完成之後.將這個閥值賦給全局變量
                    sizeCtl = sc;
                }
                break;
            }
        }
        // 返回創建的table
        return tab;
    }

    // 下面 4 個原子性操作
    // 獲取內存中的地址
    static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
         //getObjectVolatile 獲取obj對象中offset偏移地址對應的object型field的值,支持volatile load語義。
        // 第一個參數是讀取節點對象
        // 第二個參數是內存中的偏移量,也就是說位置
        return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
    }
    
      //compareAndSwapObject 
     
    /**
     * 在obj的offset位置比較object field和期望的值,如果相同則更新。這個方法
     * 的操作應該是原子的,因此提供了一種不可中斷的方式更新object field。
     * 
     *  @param obj the object containing the field to modify.
     *    包含要修改field的對象 
     * @param offset the offset of the object field within <code>obj</code>.
     *         <code>obj</code>中object型field的偏移量
     * @param expect the expected value of the field.
     *               希望field中存在的值
     * @param update the new value of the field if it equals <code>expect</code>.
     *               如果期望值expect與field的當前值相同,設置filed的值爲這個新值
     * @return true if the field was changed.
     *              如果field的值被更改
     */
 // public native boolean compareAndSwapObject(Object obj, long offset,Object expect,     Object update);

      static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
                                        Node<K,V> c, Node<K,V> v) {
        // 這裏傳入的第一個參數 是數組table
        // 第二個參數傳入的是數組的位置下標
        // 第三個參數是節點本身對象
        // 第四個是期望更新後的節點對象
        return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
    }

擴容方法的實現 :

 private final void addCount(long x, int check) {
        CounterCell[] as; long b, s;
        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();
        }
        // 這裏是否需要檢測擴容,因爲上面增加了一個值
        if (check >= 0) {
            Node<K,V>[] tab, nt; int n, sc;
            // s 表示當前數組大小.sizeCtl 表示達到閥值大小也就是初次的值 12 ,一旦滿足擴容條件
            while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
                   (n = tab.length) < MAXIMUM_CAPACITY) {
               // 計算一個機器碼
                int rs = resizeStamp(n);
                if (sc < 0) {
                    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);
                }
                // 通過cas設置SIZECTL的值,一旦設置成功,則滿足下列方法
                else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                             (rs << RESIZE_STAMP_SHIFT) + 2))
                    // 數據遷移
                    transfer(tab, null);
                // 計算總數
                s = sumCount();
            }
        }
    }



     // 數據遷移方法 , 也包括擴容
      private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
        // 獲得當前數組長度
        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
            try {
                @SuppressWarnings("unchecked")
                // n << 1 可以理解爲當前數組長度的兩倍遞增
                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;
            transferIndex = n;
        }
        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;
            while (advance) {
                int nextIndex, nextBound;
                // 這裏能夠觸發的情況是在下兩個條件執行完成之後,會爲i賦值一個默認的
                if (--i >= bound || finishing)
                    advance = false; // while不需要再循環了,已經得到了下標值了
                // 這裏是表示已經到最後一個的標誌
                else if ((nextIndex = transferIndex) <= 0) {
                    i = -1;
                    advance = false;
                }
                // 通過CAS去爲這個TRANSFERINDEX變量賦值
                // TRANSFERINDEX 擴容後的大小值
                // nextBound 
                else if (U.compareAndSwapInt
                         (this, TRANSFERINDEX, nextIndex,
                          nextBound = (nextIndex > stride ?
                                       nextIndex - stride : 0))) {
                    bound = nextBound;
                     // 將下一個座標值賦i,然外面的for循環根據這個下標去table中遷移數據
                    i = nextIndex - 1;
                    // 停止while的循環
                    advance = false;
                }
            }
            if (i < 0 || i >= n || i + n >= nextn) {
                int sc;
                // 這裏有點繞.何時會滿足這個條件?
                // 1. 當老數組全部數據遷移完畢之後,這時候會將finishing設置爲true
                // 2.會執行一次數據檢查,就是說再遍歷一次.看是否還有沒有遷移的值,直到檢查完畢之後,則會滿足這個條件,
                // 通俗一點來說,這個標記位表示所有遷移工作全部完成..
                if (finishing) {
                    // 將這個臨時變量設置爲null,下一次擴容再用
                    nextTable = null;
                    // 將新的數組賦值給老的
                    table = nextTab;
                    // 這裏是設置新數組大小的閥值,比如擴容到32了,他的閥值是32 * 75% 則是擴容條件
                    // (n >>> 1) 理解爲 0.75 ,總的理解就是上面的,實際上是32 - 8 ;
                    sizeCtl = (n << 1) - (n >>> 1);
                    return;
                }
                // 當執行到最後一個節點完成之後,將SIZECTL設置爲-1 表示正在初始化
                if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
                    if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
                        return;
                    finishing = advance = true;
                   // 這裏將i重新設置爲老數組的長度,是爲了檢查是否還有沒有需要提交的數據(PS:我也不是特別理解這一步的意義.. 重複檢查 ? )
                    i = n; // recheck before commit
                }
            }
            // 獲取table中的[i]下標鏈表,如果該鏈表爲空,則給他賦予默認值
            else if ((f = tabAt(tab, i)) == null)
                // 如果獲取到的節點鏈表爲空的情況,那就好辦了,直接賦值爲null,
                //新的數組也不用遷移 , 需要注意的是賦值的null對象是一個自定義的ForwardingNode節點
                // 他使用這個節點的意義應該是能夠快速標識出目前正處於擴容階段
                // 其他線程如果也在執行擴容的話,如果標識出該鏈表爲fwd類型的表示該鏈表已經遷移完成
                advance = casTabAt(tab, i, null, fwd);
            // 如果上面獲取到的鏈表的Hash碼爲-1,表示已經處理過
            // 這裏就表示取出來的鏈表節點爲ForwardingNode節點,表示遷移完成
            else if ((fh = f.hash) == MOVED)
                // 這裏是爲了重讀檢查設置的,爲null的節點,不做任何處理,只是爲了檢查一下
                advance = true; // already processed
            else {
                // 這裏開始遷移數據了.用的還是同步,防止鏈表出現更改的情況
                synchronized (f) {
                    // 這裏還是獲取i的下標節點
                    if (tabAt(tab, i) == f) {
                        // 這倆變量是用來做數據遷移的
                        // ln表示不遷移的數據鏈表,hn表示遷移的數據鏈表
                        Node<K,V> ln, hn;
                        // hash碼不爲0的時候
                        if (fh >= 0) {
                            // 這裏會將你的hashcode與老的數組大小做一次運算
                            // 這裏的運算決定了你的數據是需要遷移
                            // 如果運算出來得到的值爲0表示不遷移,如果不等於0 則默認遷移到新的數組那邊去
                            // 舉例 : 運算得到 16 這時候 i 是 15 ,因爲不爲0表示遷移到 16+15 = 31 的數組下標中去
                            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;
                                }
                            }
                           // 這裏就是爲0的表示不遷移,還是重新放入到當前下標i中
                            if (runBit == 0) {
                                ln = lastRun;
                                hn = null;
                            }
                            // 不爲0的時候
                            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);
                            }
                            // 這裏開始重新設置值
                            // 設置不遷移的數據,還是在原來的數組下標中
                            setTabAt(nextTab, i, ln);
                            // 需要遷移的數據通過當前下標+原來數組大小得到最終存放的下標
                            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);
                                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;
                        }
                    }
                }
            }
        }
    }

      // 計算當前數組中的總數
      final long sumCount() {
        CounterCell[] as = counterCells; CounterCell a;
        long sum = baseCount;
        if (as != null) {
            for (int i = 0; i < as.length; ++i) {
                if ((a = as[i]) != null)
                    sum += a.value;
            }
        }
        return sum;
    }

從看代碼中衍生的問題

  1. 在寫入的時候,我們會發現它最外層就是一個循環, 爲什麼就插入一個值也要用到一個循環呢?

     這就是爲了防止多線程寫入,在通過CAS插入值的時候,遇到失敗的情況下,通過自旋的方式,一直嘗試插入,直到成功爲止。
    
  2. get方法裏爲什麼需要用tabAt方法去讀取table[i],而不是直接用table[i]?

     雖然table是用volatile方式修飾的,在多線程的環境之下都能保持可見,但table是一個數組。
     不能確保數組裏面的節點內容也是最新的,也可能出現CPU緩存或者副本的情況,
     所以每次更新也是通過CAS去內存裏面直接更新,獲取也是直接從內存中直接獲取..
    
  3. 爲什麼擴容一定要按照2倍的方式?

     這樣做的好處就是方便數據遷移,也就是說在該下標值中的鏈表只要劃分出一半的數據出去
     (其實就是說通過Key的hashCode運算爲0的放入原來的位置,不等於0的劃分到當前下標+老的數組長度的位置),
     不用做過多的複雜計算就能夠完成擴容。
    
  4. 高併發下擴容是如何實現的?

     1. 在擴容的時候,會將當前鏈表進行鎖定,這樣可以避免HashMap中一旦滿足擴容條件,多個線程都會出現擴容競爭的情況,
     而ConcurrentHashMap則是會讓另一個線程幫助加速擴容這方面來,
     2. 爲了保證鏈表的一致性,採用了cas和synchronized進行加鎖的操作,保證每個鏈表都是原子性的操作.
     3.在進行老的table複製到新的table的時候,老的table會將已經清空鏈表設置爲
     ForwardingNode對象,很巧妙的實現了節點的併發移動。當多個線程同時擴容的時候,
     只要發現有節點中有ForwardingNode對象表示正在擴容,
     則會加入到幫助擴容裏面,而不是重新擴容,在已經擴容的基礎上,再去幫助未複製的節點進行擴容.
    

解答

  1. 如何做到高性能寫入?

     1. 藉助使用CAS來實現非阻塞無鎖的特點來實現線程安全的高效插入
     2. 基於鏈表的操作還是用了synchronized來保證線程安全,不過目前1.8的synchronized已經效率很高了.
     3. 其實也就是引入分段的概念.高併發下不會鎖住整個table數組,而是單個鏈表的頭節點,來保證安全,
    
  2. 如何避免HashMap的擴容引發的血案?

     1. 採用synchronized加鎖來保證了鏈表節點的線程安全操作
     2. 併發下擴容,多個線程擴容,並不會重複的擴容。只會幫助它繼續未完成擴容的節點,例如helpTransfer()方法。
        它利用ForwardingNode節點來標識當前鏈表是否已經遷移完畢,其他線程可以根據這個節點來幫助加速擴容。
    
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章