JUC源碼解析-ConcurrentHashMap1.8

前言

1.8後的ConcurrentHashMap與之前有截然不同的設計,之前是分段鎖的思想,通過採用分段鎖Segment減少熱點域來提高併發效率。1.8利用CAS+Synchronized來保證併發更新的安全,底層採用數組+鏈表+紅黑樹的存儲結構。


在此再一次膜拜Doug Lea大神,高山仰止。1.8的ConcurrentHashMap有6313行代碼,之前大概是1000多行。
這篇文章也只是概括了部分功能。

本文多介紹的是併發部分,對於底層的散列表操作,紅黑樹操作,這在之前分析HashMap的文章時已有詳細介紹,ConcurrentHashMap關於這些是與HashMap相通的。

HashMap源碼分析

紅黑樹

HashMap源碼解析-紅黑樹操作

這裏先來說一下CAS+volatile的組合,這兩個是整個JUC包的基石。volatile 讀的內存語義:當讀一個volatile變量時,JMM會把線程u敵營的本地內存置爲無效,線程接下來將從主內存中讀取值。volatile寫的內存語義:當寫一個volatile變量時,JMM會到主內存中取讀值。JSR-133增強volatile內存語義:之前舊的JMM允許volatile變量與普通變量重排序,之後嚴格限制編譯器和處理器對volatile變量與普通變量的重排序,確保volatile寫-讀與鎖的釋放-獲取有相同語義。也就是說:寫線程A在寫這個volatile變量之前所有可見的共享變量的值都將立即變得對讀線程B可見。A寫一個volatile變量,隨後B讀到這個volatile變量,這個過程實質上是線程A通過主內存向線程B發送消息。而CAS同時具有volatile讀/寫的內存語義,以Intel X86來說,就是利用在CMPXCHG指令前添加lock前綴來實現。

volatile的讀寫和CAS可以實現線程之間的通信,整合到一起就實現了Concurrent包得以實現的基石。在閱讀JUC下的類時會發現一個通用的模式:volatile的共享變量,CAS原子更新實現線程同步,二者搭配來實現線程之間的通信。很多操作都是先讀volatile變量此時的最新值,賦給局部變量,然後一頓操作,最後CAS進行同步,若過程中共享變量被其它線程更改則會導致CAS失敗,重新嘗試。

相關概念

table

所有數據都被存放在table數組中,大小是2的整數次冪,存儲的元素分爲三種類型

  • TreeBin 用於包裝紅黑樹結構的結點類型 ,它繼承了Node,代表它也是個節點,hash值爲-2
  • ForwardingNode 擴容時存放的結點類型,併發擴容的實現關鍵之一 ,是一個標記,代表此處已完成擴容,hash值爲-1。
  • Node 普通結點類型

     


    Node
    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        volatile V val; 
        volatile Node<K,V> next;
        
        ......
        }

value和next都用volatile修飾,保證併發的可見性
ForwardingNode

    static final class ForwardingNode<K,V> extends Node<K,V> {
        final Node<K,V>[] nextTable;
        ForwardingNode(Node<K,V>[] tab) {
            super(MOVED, null, null, null);
            this.nextTable = tab;
        }

ForwardingNode作用在擴容期間,hash值爲MOVED 值爲-1,當table[ i ] 是個ForwardingNode節點時,代表該位置節點已經移至新數組。

nextTable

擴容時新生成的數組,其大小爲原數組的兩倍

sizeCtl

    /**
     * Table initialization and resizing control.  When negative, the
     * table is being initialized or resized: -1 for initialization,
     * else -(1 + the number of active resizing threads).  Otherwise,
     * when table is null, holds the initial table size to use upon
     * creation, or 0 for default. After initialization, holds the
     * next element count value upon which to resize the table.
     */
    private transient volatile int sizeCtl;

用於table數組初始化擴容控制,下面來看看它是如何控制的:


接下來的思路是沿着sizeCtl來跟蹤源碼,但是很多方法具有多種功能,比如addCount...會牽扯很多其它的概念,這裏就把他們先剔除出去,先沿着一條簡單的線來解讀


sizeCtl在初始化與擴容中的作用

1,初始化:ConcurrentHashMap有五個構造器,不考慮構造時指定集合的,其他四個都沒有在初始化期間創建table數組對象,而是將這一操作下放到第一次調用put插入鍵值對時。sizeCtl決定了table數組的大小,無參構造器則sizeCtl爲默認值0,若傳入了初始值大小,經過tableSizeFor將sizeCtl改爲比傳入值大的最小2的n次冪,如傳15返回16,17返回32。

    final V putVal(K key, V value, boolean onlyIfAbsent) {
.......省略
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
......省略

initTable會被調用:這裏利用 循環CAS 確保只有一個線程能夠初始化table數組

    private final Node<K,V>[] initTable() {
        Node<K,V>[] tab; int sc;
        while ((tab = table) == null || tab.length == 0) {
            if ((sc = sizeCtl) < 0)
                Thread.yield(); // lost initialization race; just spin
            else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
                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;
                        sc = n - (n >>> 2);
                    }
                } finally {
                    sizeCtl = sc;
                }
                break;
            }
        }
        return tab;
    }

讀取voaltile的sizeCtl 值賦給局部變量 sc = sizeCtl,之後當一個線程cas設置sizeCtl值爲-1成功,之後的線程都將被拒絕,通過執行Thread.yield()。也就是說只允許一個線程執行初始化table 數組操作。sc == 0則table大小爲16,否則就爲sizeCtl大小的值。數組創建完成後sizeCtl = n - (n >>> 2),相當於原先值的0.75,這之後sizeCtl代表閥值

2,接下來看看它在擴容上的控制: 擴容-是transfer方法

先從put入手

put

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

    // onlyIfAbsent爲true:相當於putIfAbsent,即key存在就不更換value
    final V putVal(K key, V value, boolean onlyIfAbsent) {
        // key與value都不能爲null
        if (key == null || value == null) throw new NullPointerException();
        //(h ^ (h >>> 16)) & HASH_BITS;//0x7fffffff;保證了hash >= 0.
        int hash = spread(key.hashCode()); //計算出hash
        int binCount = 0;
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            if (tab == null || (n = tab.length) == 0)
 // 初始化table數組操作,若sizeCtl爲0則table大小爲16,否則爲sizeCtl大小
                tab = initTable();
//通過hash得到數組下標,若該位置爲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
            }
//MOVED爲-1,代表該位置的頭節點爲forwarding nodes,表明該位置正在進行擴容
//helpTransfer,讓該線程幫助進行擴容操作。
            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;
                            for (Node<K,V> e = f;; ++binCount) {
                                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;
                            }
                        }
                    }
                }
                // 判斷鏈表是否需要轉變爲樹;若key存在則返回舊的value值
                // 若key不存在,則說明構造了一個新節點,跳出循環,調用addCount
                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);
        return null;
    }

(直接在代碼裏註釋顯示不清,所以下面就是在分析代碼邏輯)

  1. key與value都不能爲null
  2. spread計算key的hash值。(h ^ (h >>> 16)) & HASH_BITS;//0x7fffffff;保證了hash >= 0.
  3. 循環+cas,循環cas是Java相較於synchronized的另一種鎖實現,之前文章介紹過。
  • 如果tab == null執行initTable操作,上面介紹過。
  • 利用tabAt取出i處頭節點賦給f,若爲null則利用casTabAt設置頭節點。
  • 若f的hash == MOVED,說明有線程在i處正在執行擴容操作,執行helpTransfer,該線程幫助執行擴容任務,之後再新數組中再添加值。
  • 以上情況都不是,利用synchronized 鎖住頭節點f確保線程安全,區分是鏈表還是樹,執行插入操作;如果是鏈表,那麼在遍歷過程中++binCount,最後如果binCount > 8,調用treeifyBin樹化

    4.在成功插入了一個新的元素後,addCount會被調用,這個方法一共做了兩件事,增加個數,擴容。

關於插入的線程安全:插入位置爲空則利用CAS來將新Node賦給table[ i ],否則synchronized鎖住頭節點對象,後序對該位置鏈/樹的更改由鎖保護。

addCount

兩個功能:增加個數,檢測是否需要擴容。這裏主要分析擴容

    private final void addCount(long x, int check) {
        CounterCell[] as; long b, s;
// CounterCell,baseCount與size等計算map元素個數的方法有關,之後介紹;
        if ((as = counterCells) != null ||
            !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
.....省略.........................................
        if (check >= 0) {
            Node<K,V>[] tab, nt; int n, sc;
            // 擴容條件,個數到達閥值
            while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
                   (n = tab.length) < MAXIMUM_CAPACITY) {
// n不同則返回值不同,它的返回值被當作是當前table的標識,擴容期間sizeCtl的高16爲就爲該值,
// 低16爲等於當前擴容線程數加一
                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);
                }
                //sizeCtl之前代表閥值,更改後高16位爲標識,低16位爲擴容線程數加一
                else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                             (rs << RESIZE_STAMP_SHIFT) + 2))
//第二個參數爲null會初始化新數組nextTable,這裏確保並發現nextTable數組初始化的安全性
                    transfer(tab, null); 
//代表當前元素個數,並不是準確個數,之後分析ConcurrentHashMap的元素個數統計問題
                s = sumCount(); 
            }
        }
    }

一開始說過這段源碼的解析是沿着sizeCtl這條線,來看看它的作用的,所以這裏省略了addCount的其他功能。
前面sizeCtl在經過initTable後,代表的是閥值,當元素個數>=sizeCtl後進行擴容。

while循環的進入條件是 s >= (long)(sc = sizeCtl):擴容前sizeCtl代表閥值時,大於等於則代表要擴容。擴容期間sizeCtl小於0,擴容結束後sizeCtl代表新數組的閥值;該表達式爲true,代表需要擴容或是擴容未結束需要幫助擴容。

擴容需要確保擴容數組nextTable初始化的安全,通過CAS競爭修改sizeCtl的值,只會有一個成功的線程,其調用transfer(tab,null),注意第二個參數接收的是新數組nextTable,傳入null則會創建新數組。這裏修改後的sizeCtl其高16位是當前數組擴容的標識,這個標識與需要擴容的數組的大小有關。

sizeCtl此前代表閥值,併發下只會有一個線程成功更改sizeCtl,之後sizeCtl值小於0,之後的線程在本次擴容未完成時會幫助進行擴容,每增加一個擴容線程就cas將sizeCtl +1 ,是否是本次的判斷就是通過sizeCtl的高16位的標識。

每次循環開始出先調用resizeStamp,參數爲此時table數組大小,由此計算得到該數組的擴容標識,若數組正在擴容,則此時sizeCtl的高16位將等於 rs。

當檢測到sizeCtl < 0 時代表數組正在擴容,接下來對狀態進行判斷以決定是否需要去幫忙,下面來分析下究竟對哪些狀態進行了判斷:

1,(sc >>> RESIZE_STAMP_SHIFT) != rs 擴容期間sizeCtl高16位的值就是resizeStamp根據擴容數組大小計算的,resizeStamp根據傳入值不同返回值不同,若擴容完成table = nextTable,則 resizeStamp返回值改變,若該判斷若爲true,則說明擴容已完成,或是非本次擴容,可能是當前線程滯後過程時間導致的。

2,sc == rs + 1 ,擴容時sizeCtl的低16位的大小等於擴容線程個數加一,也就是說若擴容線程爲4,則sizeCtl低16位大小爲5。這樣設計的原因是:在一個線程完成分配給它的擴容區域後,會重新申請另一塊,若數組已無可分配的區域則該線程退出擴容操作,也就是transfer方法,退出前通過CAS將sizeCtl減一,成功後他會判斷自己是否是最後一個擴容線程,通過 

(sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT

這一表達式來判斷,也就是sizeCtl-2後的低16位爲0則代表當前線程是對後一個擴容線程,對於最後一個擴容線程來說它退出前需要將nextTable賦給table,nextTable置爲null,sizeCtl修改爲新數組大小的閥值。

回到這裏 sc == rs + 1 爲true的化達標什麼?代表在本線程獲取sizeCtl值的那一刻,最後一個擴容線程已將sizeCtl減一,即將進行收尾工作,擴容操作即將全部完成,所以本線程沒必要再來幫忙了。

3,sc == rs + MAX_RESIZERS:MAX_RESIZERS 表示最大的擴容線程數 —— (1 << 16) - 1,擴容線程數不能超過該值。

4,(nt = nextTable) == null: 擴容完成後會將nextTable置爲null,當然還存在nextTable還未初始化的可能,不管那種情況當前線程都不能執行幫助擴容操作。

5,transferIndex <= 0:每個線程都會在table上被分配一個大小爲stride的區間,該區間的擴容操作是由該線程負責,transferIndex與該過程有關,當其<=0則代表數組已全部分配完,不需要該線程的幫忙了;

關於resizeStamp方法

    private static int RESIZE_STAMP_BITS = 16;

    static final int resizeStamp(int n) {
        return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
    }

numberOfLeadingZeros返回數的二進制中左側0的個數,比如傳入2左側有30各0,就返回30;傳入32,左側有26個0就返回26;
resizeStamp: n不同則返回值不同,它的返回值被當作是當前table的標識,擴容期間sizeCtl的高16爲就爲該值,低16爲等於當前擴容線程數加一。

比如傳入16,返回 rs =1000000000011011,首個擴容線程cas將rs左移16位再加2,sizeCtl變爲10000000000110110000000000000010,此時sizeCtl < 0,後16位的大小假設爲2,則代表目前有2-1個線程在執行擴容操作。
 

上面的分析可以看到sizeCtl在併發擴容期間起到的重要作用,其是volatile的,對它的修改操作也是使用CAS來確保安全,它的不同值代表不同的狀態。


總結一下sizeCtl的變化

table 初始化:
  1,根據你調用的構造函數的不同,比如無參則sizeCtl = 0,initTable中將數組初始化爲16;
    若傳了大小,則先經tableSizeFor改變大小確保爲2的n次冪,之後賦給sizeCtl,
    initTable中將數組初始化爲sizeCtl大小
  2,=-1 在初始化數組期間,即initTable裏爲了保證只有一個線程能夠初始化table數組,
       線程會利用cas將sizeCtl改爲-1,之後的線程檢測到sizeCtl< 0會退回到就緒狀態
  3,數組初始化完成後sizeCtl變爲爲閥值,大小爲0.75倍數組大小
table 擴容:
  第一個執行擴容的線程會將sizeCtl變爲< 0,擴容期間sizeCtl高16位代表本次擴容的標識,不同擴容數組大小標識不同,
  低16位數大小代表擴容線程數減一,假設爲N則代表有N-1個線程在執行擴容操作。

下面的源碼分析可以看出,很多方法會判斷sizeCtl的正負,<0則代表正在擴容,>0則代表閥值

在上面以sizeCtl爲線的分析中,出現很多方法,還有一些方法的功能沒有分析全,下面來分析分析它們


擴容

transfer

    private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
        int n = tab.length, stride;
        //根據cpu個數及MIN_TRANSFER_STRIDE值來計算stride,MIN_TRANSFER_STRIDE爲16
        // stride的大小代表該線程所負責的table數組擴容範圍
        if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
            stride = MIN_TRANSFER_STRIDE; // subdivide range
        if (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 = nextTab;
            transferIndex = n;
        }
        .........

如果nextTab == null,新建一個新數組大小爲原數組的2倍。多線程下應該只允許一個線程創建nextTab數組,那麼如何實現?在前面提到的addCount方法裏,併發下只有一個線程會成功通過cas改變sizeCtl值,改變後的sizeCtl < 0,之後調用transfer(tab, null),該線程會初始化nextTable數組,其他線程在檢測到sizeCtl變化後,若擴容沒完成會幫助進行擴容操作,調用transfer(tab, nt),nt爲創建後的nextTab數組,這樣就實現了多線程並行擴容。

接下來看看第二部分:

每個線程會被從後往前分配一塊數組區域,線程的任務就是將該區域所有元素移到新數組中,每個移除完的位置會被放置一個ForwordingNode節點,用以標識此處已完成擴容操作,線程處理完分配區域後會重新申請一塊,直到無處可分或整個數組擴容已完成則退出,推出前將sizeCtl減一,最後一個退出的線程負責收尾工作,如將nextTable賦給table等。

將 for 循環裏的代碼分爲五塊來說,下面是第一塊

        int nextn = nextTab.length;
// table[i]位置擴容結束後會將該位置替換爲ForwardingNode,標識該位置已經擴容結束
        ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);

// advance控制擴容線程再區域內的移動以及區域的分配工作,下面結合代碼來說
        boolean advance = true; 
// 當最後一個線程擴容結束,會將finishing 設爲true,從而進入收尾操作的代碼塊中
        boolean finishing = false;

// 擴容區域的分配是從後往前。i就是該線程在屬於自己的stride區裏移動的指針,
// bound就是該區域的下邊界(包含)
        for (int i = 0, bound = 0;;) {
            Node<K,V> f; int fh;
            while (advance) {
                int nextIndex, nextBound;
--i >= bound 爲true代表該線程還沒處理完其所被分配的擴容區域
finishing爲true,代表當前線程是唯一的擴容線程了,其他的擴容線程已經退出,
最後留下的線程在檢測到自己是最後一個擴容線程後會將finishing置爲true,執行table重賦值操作,
邏輯在第二塊代碼中
                if (--i >= bound || finishing)
                    advance = false;
transferIndex就是前一個stride區域的下邊界值(被包含在前一塊區域裏),
它爲0代表數組中沒有剩下的空間可分配了,這裏將i置爲-1,爲了進入下面第二塊代碼
                else if ((nextIndex = transferIndex) <= 0) {
                    i = -1; 
                    advance = false;
                }
運行到這裏則代表table數組中還有未分配的區域,CAS將transferIndex置爲新區域的下邊界,
得到該區域的邊界bound與開始位置i
                else if (U.compareAndSwapInt
                         (this, TRANSFERINDEX, nextIndex,
                          nextBound = (nextIndex > stride ?
                                       nextIndex - stride : 0))) {
                    bound = nextBound;
                    i = nextIndex - 1;
                    advance = false;
                }
            }

for 循環裏線程每次處理table[ i ]位置上的鏈表或樹的擴容操作,完成後再次循環移動另一位置。for循環整體代碼分爲5塊,上面貼的是第一塊。

我們說 ConcurrentHashMap 允許多線程併發擴容,是否意味着多個線程同時操作同一位置處的鏈/樹,並非如此,爲了線程安全一個位置的擴容操作只交給一個線程獨立完成,完成後放置標記節點,以此來告訴其它線程此處已完成,線程的通信通過CAS,那麼併發擴容指的是?指的是數組分爲多個區域,每個區域交給一個線程來處理,整個數組的擴容操作由多個線程同時進行。

在方法代碼一開始計算了一個stride值,它根據你的cpu數與數組大小計算得來,最小值爲16,它的作用是?每個線程擴容都會從數組中得到一塊區域,這塊區域的轉移工作歸該線程負責,完成就去重新申請下一塊區域,這塊區域大小就是stride。
比如第一個線程他的區域是[n-1,n-stride],第二個線程的區域[n-stride-1, n-2*stride]。
上述功能的具體實現在代碼中已詳細說明。

接下來看看第二塊:能進入該塊代碼說明數組已經被線程們分配完了,等他們全部執行完自己的stride區域,擴容就完成了。

            if (i < 0 || i >= n || i + n >= nextn) { //爲true代表擴容結束
                int sc;
最後一個擴容線程處理結束後會將finishing置爲true,之後再循環一次,最終會進入下面的if代碼塊
將nextTable 歸零,新數組nextTab賦給table,sizeCtl重新變爲閥值,爲新數組大小的0.75
                if (finishing) {
                    nextTable = null;
                    table = nextTab;
                    sizeCtl = (n << 1) - (n >>> 1); 相當於0.75
                    return;
                }
線程檢測到已無區域可分,則退出transfer方法,退出前cas將sizeCtl減一,若當前線程是最後一個擴容線程,
則將finishing置爲true。重申:sizeCtl後16位大小等於當前擴容線程數+1
                if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
這裏如果相等代表此線程是最後一個擴容線程
                    if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
                        return;
                    finishing = advance = true;
                    i = n; // 再次循環一次,會執行if (finishing){}裏代碼,之後退出,擴容結束
                }
            }

接下來第三塊:

            else if ((f = tabAt(tab, i)) == null)
                advance = casTabAt(tab, i, null, fwd);

如果該位置爲空,則用cas添加一個ForwardingNode節點,之前說過它是個標記,hash值爲-1,它標誌着該位置爲不需要擴容操作。
ConcurrentHashMap運行中有些線程在插入,有些線程在執行擴容,如何避免相互影響?

  1. 在put中發現當前位置節點hash爲-1,也就是ForwardingNode,那麼put線程轉而執行helpTransfer操作,幫助執行擴容,擴容完成後再插入;
  2. 具體插入與擴容代碼是被synchronized保護的,而它們的鎖都是頭節點對象。假設一種場景:若A線程在table[ i ]執行擴容操作不過並沒有完成,所以頭節點並不是ForwardingNode,不過A已經獲取到頭節點對象的鎖,此時B線程要往table[ i ]處插入節點,於是B阻塞,之後table[ i ]位置擴容結束,B獲取到了鎖會繼續執行插入操作嗎?不會,因爲table[ i ]擴容結束後頭節點變爲ForwardingNode,這就是在synchronized 代碼中再次校隊頭節點的必要性,即if (tabAt(tab, i) == f)判斷。對於B線程他會在putval的for代碼塊內再循環一次,會檢測到此時頭節點的hash值已爲-1,執行helpTransfer操作,幫助進行擴容,整體擴容完成後再插入節點。

來看看第四塊:

            else if ((fh = f.hash) == MOVED)
                advance = true; // already processed

表明該位置已經擴容結束,重賦值advance爲true,再次循環處理下一位置

最後第五塊:

            else {
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        Node<K,V> ln, hn;
                        if (fh >= 0) {
                  該位置的鏈表按runBit的值分爲兩類,
                            int runBit = fh & n;
                            Node<K,V> lastRun = f;
                  按runBit節點分成兩類,每當遇到不同於之前節點值就標記爲lastRun
                            for (Node<K,V> p = f.next; p != null; p = p.next) {
// 這裏就是保持數組爲2的倍數的原因,不用再對每個元素重新計算hash,這一部分其實與HashMap相通,
// 關於我的HashMap分析文章,下面已給出鏈接。
                                int b = p.hash & n; 
                                if (b != runBit) {
                                    runBit = b;
                                    lastRun = p;
                                }
                            }
                            if (runBit == 0) {
                                ln = lastRun;
                                hn = null;
                            }
                            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);//hash,key,value,next
                                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) {
                            ........省略樹的操作,邏輯與上面類似
                    }
                }
            }

這一塊是真正執行擴容操作的一塊,被synchronized保護,鎖是頭節點,獲取鎖後會再次對頭節點進行校隊,確保沒有改變。

畫了個圖來說明上面代碼的操作,假設有七個節點,其中2,5,7, 8節點runBit爲0.

1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8

在上面代碼經歷第一個for循環後,lastRun爲7,其runBit爲0,其後lastRun被賦值給ln,那麼hn爲null。

第二個for循環後,2爲ln,5爲hn,在該過程中節點會被倒序

5 -> 2 -> 7 -> 8

 

6 -> 4 -> 3 -> 1

最後利用setTabAt將以ln爲頭的鏈表放到nextTabi 位,即仍放在原爲不動,將以hn爲頭節點的鏈表放到nextTabi+n 位置,接着將tabi 位改爲fwd,即標記節點ForwardingNode,告訴其他線程該位置已經擴容完成。最後將advance = true,繼續循環去找下一位置擴容,看第一段代碼的邏輯,當前線程若已經將被分配的區域處理完,會退出嗎?不會,會在剩下的區域再分配一塊區域讓其來處理。



上面提過在putVal時如果發現該位置頭節點hash爲-1,即ForwardingNode節點,調用helpTransfer,幫助擴容,擴容結束後再執行插入操作。

helpTransfer

    final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
        Node<K,V>[] nextTab; int sc;
三個判斷條件判斷的是擴容是否結束,ForwardingNode再創建時持有nextTable數組的引用,
nextTable會在擴容結束後被置爲null。
        if (tab != null && (f instanceof ForwardingNode) &&
            (nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
            int rs = resizeStamp(tab.length); // 本次擴容的標識,數組大小不變則rs不變
循環的這些判斷條件爲tue的話表明擴容未結束
            while (nextTab == nextTable && table == tab &&
                   (sc = sizeCtl) < 0) {  擴容時sizeCtl一定小於0
1,校驗標識,resizeStamp的參數大小不變則值相等
2,sc == rs + 1 說明最後一個擴容線程正在執行收尾工作,你沒必要來幫忙了。
3,sc == rs + MAX_RESIZERS 說明擴容線程數超過最大值
4,transferIndex <= 0在上面分析過,擴容中transferIndex表示最近一個被分配的stride區域的下邊界,
<=0代表數組被分配完了
                if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                    sc == rs + MAX_RESIZERS || transferIndex <= 0)
                    break;
cas配合while循環構成自旋CAS,是線程安全的保證。將sizeCtl+1,
sizeCtl此時的低16位爲N=擴容線程數+1
                if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
                    transfer(tab, nextTab);
                    break;
                }
            }
            return nextTab;
        }
        return table;
    }

回到putVal邏輯,在擴容操作整體完成後,線程回到putVal中,再次循環將節點插入。


Size

之前在解析addCount時有部分代碼被省略,省略的那部分代碼與ConcurrentHashMap的size操作有關。對於ConcurrentHashMap來說table中的節點數量是個不確定的值,你沒法停下所有正在執行各種操作的線程們來統計準確數字,也沒必要,所以折中一下返回個估計值。下面來看看如何統計出來的。


首先來看看一些相關內部類與變量:

    @sun.misc.Contended static final class CounterCell {
        volatile long value;
        CounterCell(long x) { value = x; }
    }
用於與CAS配合實現排他性,CAS從0改爲1代表獲取鎖
用於保護初始化CounterCell、初始化CounterCell數組以及對CounterCell數組進行擴容時的安全
    private transient volatile int cellsBusy;

初始大小爲2,每次擴容翻倍,存儲CounterCell對象,該對象有個value變量,用來存儲個數
該數組的大小上限與當前機器的CPU數量有關,它不會被主動初始化,
只有在調用fullAddCount()函數時纔會進行初始化
    private transient volatile CounterCell[] counterCells;

一個volatile變量用於記錄元素的個數,對這個變量的修改操作是基於CAS的,
每當插入元素或刪除元素時都會調用addCount()函數進行計數。
    private transient volatile long baseCount;

註解@sun.misc.Contended用於解決僞共享問題。所謂僞共享,即是在同一緩存行(CPU緩存的基本單位)中存儲了多個變量,當其中一個變量被修改時,就會影響到同一緩存行內的其他變量,導致它們也要跟着被標記爲失效,其他變量的緩存命中率將會受到影響。解決僞共享問題的方法一般是對該變量填充一些無意義的佔位數據,從而使它獨享一個緩存行。

 

mappingCount與size

這兩個方法都是統計個數的,不同在於size返回int,mappingCount返回long,文檔註釋建議使用mappingCount

    public long mappingCount() {
        long n = sumCount();
        return (n < 0L) ? 0L : n; // ignore transient negative values
    }

    public int size() {
        long n = sumCount();
        return ((n < 0L) ? 0 :
                (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
                (int)n);
    }

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

可以看出sumCount是關鍵。統計的方法就是遍歷counterCells將每個位置存儲的值相加再加上baseCount的值,和就是此時的個數估計值。


爲了搞清一開始說的三個變量的用途,回到addCount裏被我省略的部分:這前半部是爲了得出此時的元素個數 s,在下半部代碼中若 s 大於等於閥值sizeCtl會進行擴容。

首先若counterCells數組不爲null,調用sumCount計算元素個數,賦給 s;否則CAS增加baseCount += x,並將其賦給變量 s

若是CAS失敗再次判斷counterCells數組是否已初始化,已初始化則獲取當前線程的CounterCell,CAS增加其value值,最後調用sunCount計算個數賦給 s 。若是未初始化調用fullAddCount(x , true),若是CAS失敗調用fullAddCount(x , false)

fullAddCount方法:該函數負責初始化CounterCells和更新計數。第二個參數wasUncontended意思是:是否不存在競爭。CAS失敗調用該方法說明存在競爭所以傳false。

    private final void addCount(long x, int check) {
        CounterCell[] as; long b, s;
// counterCells未初始化就CAS更新baseCount
        if ((as = counterCells) != null ||
            !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
            CounterCell a; long v; int m;
            boolean uncontended = true;
//counterCells數組已初始化的話獲取當前線程的CounterCell,CAS將其增加x,若失敗調用fullAddCount
//未初始化的話也調用fullAddCount
            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(); // 統計總數
        }

該段說明引用自https://sylvanassun.github.io/2018/03/16/2018-03-16-map_family/,強烈推薦這一篇文章

ConcurrentHashMap的計數設計在一個低併發的情況下,就只是簡單地使用CAS操作來對baseCount進行更新,但只要這個CAS操作失敗一次,就代表有多個線程正在競爭,那麼就轉而使用CounterCell數組進行計數,數組內的每個ConuterCell都是一個獨立的計數單元。

每個線程都會通過ThreadLocalRandom.getProbe() & m尋址找到屬於它的CounterCell,然後進行計數。ThreadLocalRandom是一個線程私有的隨機數生成器,每個線程的probe都是不同的,可以認爲每個線程的probe就是它在CounterCell數組中的hash code。這種方法將競爭數據按照線程的粒度進行分離,相比所有競爭線程對一個共享變量使用CAS不斷嘗試在性能上要效率多。

fullAddCount()函數根據當前線程的probe尋找對應的CounterCell進行計數,如果CounterCell數組未被初始化,則初始化CounterCell數組和CounterCell。把CounterCell數組當成一個散列表,每個線程的probe就是hash code,散列函數是(n - 1) & probe

CounterCell數組的大小永遠是一個2的n次方,初始容量爲2,每次擴容的新容量都是之前容量乘以二,處於性能考慮,它的最大容量上限是機器的CPU數量。

所以說CounterCell數組的碰撞衝突是很嚴重的,因爲它的bucket基數太小了。而發生碰撞就代表着一個CounterCell會被多個線程競爭,爲了解決這個問題,Doug Lea使用無限循環加上CAS來模擬出一個自旋鎖來保證線程安全,自旋鎖的實現基於一個被volatile修飾的整數變量cellsBusy,該變量只會有兩種狀態:0和1,當它被設置爲0時表示沒有加鎖,當它被設置爲1時表示已被其他線程加鎖。這個自旋鎖用於保護初始化CounterCell、初始化CounterCell數組以及對CounterCell數組進行擴容時的安全。

CounterCell更新計數是依賴於CAS的,每次循環都會嘗試通過CAS進行更新,如果成功就退出無限循環,否則就調用ThreadLocalRandom.advanceProbe()函數爲當前線程更新probe,然後重新開始循環,以期望下一次尋址到的CounterCell沒有被其他線程競爭。

如果連着兩次CAS更新都沒有成功,那麼會對CounterCell數組進行一次擴容,這個擴容操作只會在當前循環中觸發一次,而且只能在容量小於上限時觸發。

該函數負責初始化CounterCells和更新計數

    private final void fullAddCount(long x, boolean wasUncontended) {
        int h;
        //爲0則代表該線程的ThreadLocalRandom還未初始化
        if ((h = ThreadLocalRandom.getProbe()) == 0) {
            ThreadLocalRandom.localInit();    // 初始化
            h = ThreadLocalRandom.getProbe(); //得到probe,用於counterCells數組尋址
            wasUncontended = true; // 非競爭
        }
        boolean collide = false; //衝突標誌
        for (;;) {
            CounterCell[] as; CounterCell a; int n; long v;
            // counterCells已初始化
            if ((as = counterCells) != null && (n = as.length) > 0) {
//先初始化CounterCell,value就是參數x即個數;cellsBusy相當於AQS中的同步狀態,
//即可以看成是一個鎖,獲取鎖操作就是cas將其從0改爲1;被鎖保護的代碼中,
//將CounterCell對象放進(n - 1) & h位置,再將cellsBusy = 0;成功就結束循環
                if ((a = as[(n - 1) & h]) == null) { //尋址位置爲空
                    if (cellsBusy == 0) {    // 鎖空閒
                         //創建CounterCell,將x保存在其value字段中
                        CounterCell r = new CounterCell(x);
                        // 獲取鎖
                        if (cellsBusy == 0 &&
                            U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
                            boolean created = false; // 對象是否成功放入數組尋址位置
                            try {               // Recheck under lock
                                CounterCell[] rs; int m, j;
                                //再次確認
                                if ((rs = counterCells) != null &&
                                    (m = rs.length) > 0 &&
                                    rs[j = (m - 1) & h] == null) {
                                    rs[j] = r; // 將創建的CounterCell對象放入尋址位置
                                    created = true; // 代表操作成功
                                }
                            } finally {
                                cellsBusy = 0; //釋放鎖
                            }
                            if (created) // 操作成功跳出循環
                                break;
                            說明存在競爭該位置已被其他線程放入了CounterCell,循環再試
                            continue; 
                        }
                    }
                    collide = false;
                }
在addCount中如果counterCells已初始化,之後會尋址得到CounterCell對象,CAS更新其value
若失敗會調用fullAddCount,wasUncontended參數爲false,表明存在競爭,這裏對於此的處理是再循環一次
                else if (!wasUncontended)  
                    wasUncontended = true; 設爲true再循環,每次循環得到不同probe
                //到這說明尋址位置非空,則CAS更新其value計數,成功就跳出循環
                else if (U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))
                    break;
                // 上面更新失敗到這裏檢查counterCells數組是否已經擴容,是否達到上限
                else if (counterCells != as || n >= NCPU)
//這裏採取的措施就是讓線程多循環幾次,因爲每次都會得到新的probe,這次不成功下次說不定就成功了
                    collide = false; 
                else if (!collide) //在最後手段擴容前再讓線程循環一次
                    collide = true;
//上面的措施會讓線程循環多次,若還不行說明數組太小競爭太激烈,需要擴容counterCells數組
                //先獲取鎖
                else if (cellsBusy == 0 &&
                         U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
                    try {
                        //擴容前的檢查,因爲數組可能已經被其它線程擴容了
                        if (counterCells == as) {// Expand table unless stale
                            CounterCell[] rs = new CounterCell[n << 1]; // 2倍
                            for (int i = 0; i < n; ++i)
                                rs[i] = as[i];
                            counterCells = rs;
                        }
                    } finally {
                        cellsBusy = 0;  // 釋放鎖
                    }
                    collide = false;
                    continue;  // 重新循環
                }
                // 每次循環都會重新計算probe值
                h = ThreadLocalRandom.advanceProbe(h);
            }
//上面是counterCells數組已初始化的情況,下面是未初始化情況的處理
            // 獲取鎖
            else if (cellsBusy == 0 && counterCells == as &&
                     U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
                boolean init = false; // 標識初始化是否成功
                try {                           // Initialize table
                    if (counterCells == as) {
                        CounterCell[] rs = new CounterCell[2]; // 初始大小爲2
                        rs[h & 1] = new CounterCell(x); //創建CounterCell對象
                        counterCells = rs;
                        init = true; // 成功
                    }
                } finally {
                    cellsBusy = 0; // 釋放鎖
                }
                if (init) // 初始化成功則跳出循環
                    break;
            }
// 上面獲取鎖失敗或者counterCells數組被其它線程擴容了,這裏的處理不是讓其循環再試,
// 而是在這裏CAS更新baseCount的值,若成功則跳出循環。
            else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x))
                break;                          // Fall back on using base
        }
    }

 


注:從上面一路分析下來,一個問題ConcurrentHashMap如何保證線程安全性?
在put中,真正添加節點的操作是被synchronized保護的,還有擴容時移動節點的操作也是被synchronized保護起來的,它們時安全的。那對於共享變量的操作呢?雖然它們都用了volatile修飾,但很多操作會根據它們原先的值來決定新值,這就需要在最後正確同步,採用自旋CAS確保這些共享變量的正確同步。
但是並不是自旋CAS原子改變同步狀態再配合synchronized就萬事大吉了。比如再addCount裏,一個線程CAS將sizeCtl更改,之後調用transfer,先初始化nextTable數組,但是這是多線程環境,情況遠比想象的要複雜,比如,在你未初始化完nextTable之前,其他線程transfer由於檢測到了sizeCtl<0,要來幫助擴容,若直接調用transfer(tab, nt);而此時nextTable還未初始化完成,那麼那些線程就會去執行初始化nextTable操作,這是不允許的,所以在源碼中會先對此時的狀態進行判斷,在判斷合格後,仍會先利用CAS嘗試更改sizeCtl,爲什麼?一方面是由於我們的設計——低16位的特殊含義,另一方面因爲狀態的改變會更改sizeCtl的值,比如擴容完成了,CAS競爭失敗代表sizeCtly發生改變,這些情況的發生可能在你條件判斷合格之後,那麼就不能讓該線程輕易去調用transfer,而是循環在次獲取最新值再進行判斷處理。從這裏也看出了sizeCtl的重要性,它與太多情況相關。

所以我認爲ConcurrentHashMap是以synchronized與CAS爲基,每種操作都充分考慮到不同的情況下實現的

參考

深入淺出ConcurrentHashMap1.8

ConcurrentHashMap源碼分析(JDK8版本)

更好地理解jdk1.8中ConcurrentHashMap實現機制

Map大家族的那點事兒

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章