一篇文章讓你徹底理解ConcurrentHashMap(jdk 1.8)

1.爲什麼要用ConcurrentHashMap

HashMap線程不安全,而Hashtable是線程安全,但是它使用了synchronized進行方法同步,插入、讀取數據都使用了synchronized,當插入數據的時候不能進行讀取(相當於把整個Hashtable都鎖住了,全表鎖),當多線程併發的情況下,都要競爭同一把鎖,導致效率極其低下。而在JDK1.5後爲了改進Hashtable的痛點,ConcurrentHashMap應運而生。
在學習ConcurrentHashMap之前,建議先學習HashMap,HashMap底層原理及源碼分析(詳細)(jdk1.7 && jdk 1.8)

2.ConcurrentHashMap爲什麼高效?

ConcurrentHashMap採用CAS和synchronized來保證併發安全,數據結構跟HashMap1.8的結構一樣,數組+鏈表/紅黑二叉樹。
synchronized只鎖定當前鏈表或紅黑二叉樹的首節點,這樣只要hash不衝突,就不會產生併發,效率又提升N倍。
看ConcurrentHashMap 源碼之前,必須要知道什麼是CAS

JDK1.8的ConcurrentHashMap的結構圖如下:

在這裏插入圖片描述

TreeBin 在實際的ConcurrentHashMap“數組”中,存放的是TreeBin對象,而不是TreeNode對象,這是與HashMap的區別。另外這個類還帶有了讀寫鎖。在構造TreeBin節點時,僅僅指定了它的hash值爲TREEBIN常量,裏面有TreeNode屬性,相當於是一個封裝類。
Node 鏈表結點,實現了Map.Entry<K,V>接口。

3.ConcurrentHashMap 屬性

  • transient volatile Node<K,V>[] table

鍵值對桶數組;

  • private transient volatile Node<K,V>[] nextTable

rehash擴容時用到的新鍵值對數組;

  • private transient volatile long baseCount

記錄當前鍵值對總數,通過CAS更新,對所有線程可見;

  • private transient volatile int sizeCtl

sizeCtl表示鍵值對總數閾值,通過CAS更新, 對所有線程可見
當sizeCtl < 0時,表示多個線程在等待擴容,-1代表正在初始化tabl e,-N代表有N- 1 個線程正在 進行擴容 ;
當sizeCtl = 0時,默認值;
當sizeCtl > 0時,表示擴容的閾值;
在其他情況下,如果table還未初始化(table == null),sizeCtl表示table進行初始化的數組大小(所以從構造函數傳入的initialCapacity在經過計算後會被賦給它)。如果table已經初始化過了,則表示下次觸發擴容操作的閾值,算法stzeCtl = n - (n >>> 2),也就是n的75%,與默認負載因子(0.75)的HashMap一致。

  • private transient volatile int cellBusy

鎖標誌;

  • private transient volatile CounterCell[] counterCells

counter cell表,長度總爲2的冪次,計算ConcurrentHashMap元素個數時使用;

  • static final int MOVED = - 1

ForwardingNode的hash值。ForwardingNode,一個特殊的Node節點,hash值爲-1,其中存儲nextTable的引用,用於指向下一個table。
只有table發生擴容的時候,ForwardingNode纔會發揮作用,作爲一個佔位符放在table中表示當前節點爲null或者鏈表已經被移動

  • static final int TREEBIN = - 2

樹根節點的hash值 ;

  • static final int RESERVED = - 3

ReservationNode的hash值

4.Unsafe與CAS

在ConcurrentHashMap中,隨處可以看到U, 大量使用了U.compareAndSwapXXX的方法,這個方法是利用一個CAS算法實現無鎖化的修改值的操作,他可以大大降低鎖代理的性能消耗。這個算法的基本思想就是不斷地去比較當前內存中的變量值與你指定的一個變量值是否相等,如果相等,則接受你指定的修改的值,否則拒絕你的操作。因爲當前線程中的值已經不是最新的值,你的修改很可能會覆蓋掉其他線程修改的結果。

三個核心方法
ConcurrentHashMap定義了三個原子操作,用於對指定位置的節點進行操作。正是這些原子操作保證了ConcurrentHashMap的線程安全。

 @SuppressWarnings("unchecked")
 	//獲得在i位置上的node結點
    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);
    }
	//利用CAS算法設置i位置上的Node節點。之所以能實現併發是因爲他指定了原來這個節點的值是多少
	//在CAS算法中,會比較內存中的值與你指定的這個值是否相等,如果相等才接受你的修改,否則拒絕你的修改
	//因此當前線程中的值並不是最新的值,這種修改可能會覆蓋掉其他線程的修改結果
    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);
    }

	//利用volatile方法設置節點位置的值
    static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
        U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
    }

5.ConcurrentHashMap的構造方法

1)無參的構造函數,使用默認的table(長度爲16)創建一個新的map,這種構造方法經常使用。

 public ConcurrentHashMap() {
    }

2)有參的構造函數,將sizeCtl取爲大於initialCapacity並且最接近initialCapacity的2的n次方。

 public ConcurrentHashMap(int initialCapacity) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException();
        int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
                   MAXIMUM_CAPACITY :
                   tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
        this.sizeCtl = cap;
    }

6.table 的延遲初始化

創建ConcurrentHashMap對象之後,並沒有初始化數組(table),只是初始化sizeCtl的值(有參的構造函數),只有當第一次進行put的時候,會根據sizeCtl的值進行初始化,put操作是併發執行的,怎麼保證table只初始化一次呢?

 private final Node<K,V>[] initTable() {
        Node<K,V>[] tab; int sc;
        //進入自旋
        while ((tab = table) == null || tab.length == 0) {
            //開始的時候sizeCtl要麼等於0,要麼大於0,小於0說明正在初始化
            if ((sc = sizeCtl) < 0)
                Thread.yield(); // 讓出cpu資源,直接自旋
             //cas操作,將sc設置爲-1,如果一個線程cas成功的話,會對table進行初始化  
            else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
                try {
                //判斷table是否爲空,如果爲空,就進行初始化,雙重檢驗
                    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 = 0.75n
                        sc = n - (n >>> 2);
                    }
                } finally {
                	//將閾值賦給sizeCtl
                    sizeCtl = sc;
                }
                break;
            }
        }
        return tab;
    }

執行第一次put操作的線程會執行Unsafe.compareAndSwapInt方法修改sizeCtl爲-1,有且只有一個線程能夠修改成功,而其它線程只能通過Thread.yield()讓出CPU時間片等待table初始化完成。

7.put操作

大致流程:

  1. 若數組空,則初始化,完成之後,轉2
  2. 計算當前桶位是否有值
    1)無,則 CAS 添加,失敗後繼續自旋,直到成功,結束自旋
    2)有,轉3
  3. 判斷數組元素是否爲轉移節點(ForwardingNode)
    1)是,說明正在擴容,則幫助擴容,之後再新增
    2)否,轉4
  4. 桶位有值,對當前桶位加synchronize鎖,判斷是鏈表還是紅黑樹
    1) 鏈表,新增節點到鏈尾
    2) 紅黑樹,紅黑樹版方法新增
  5. 新增完成之後,統計size,檢查是否需要擴容
 public V put(K key, V value) {
        return putVal(key, value, false);
    }

    /** Implementation for put and putIfAbsent */
    final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        //計算hash值
        int hash = spread(key.hashCode());
        int binCount = 0;
         //自旋保證新增可以成功
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            //如果table爲空,進行初始化
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
            //不爲空,就判斷數組中索引爲i的值是否爲空
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                //爲空,進行CAS,添加新節點,成功之後,結束自旋
                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) {
                        //fh,哈希值大於等於0,說明是鏈表中的結點
                        if (fh >= 0) {
                            binCount = 1;
                            //遍歷鏈表,如果key相等的話進行覆蓋
                            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;
                                }
                                //沒有相等的key,插入尾結點
                                Node<K,V> pred = e;
                                //判斷是否爲尾結點,並把結點放到尾部
                                if ((e = e.next) == null) {
                                    pred.next = new Node<K,V>(hash, key,
                                                              value, null);
                                    break;
                                }
                            }
                        }
                        //哈希值是-2,說明是紅黑樹
                        else if (f instanceof TreeBin) {
                            Node<K,V> p;
                            binCount = 2;
                            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                           value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                    }
                }
                //binCount不等於0,說明添加成功,判斷是否需要轉換成紅黑樹,並且是否是覆蓋的原有的值
                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        //樹化
                        treeifyBin(tab, i);
                    //oldVal不爲空,說明,原來有一個key,返回oldVal
                    if (oldVal != null)
                        return oldVal;
                    // 槽點已經上鎖,只有在紅黑樹或者鏈表新增失敗的時候
               		// 纔會走到這裏,這兩者新增都是自旋的,幾乎不會失敗
                    break;
                }
            }
        }
        //統計size,並檢查是否需要擴容
        addCount(1L, binCount);
        return null;
    }

8.擴容(transfer)

在 put 方法最後檢查是否需要擴容,從 put 方法的 addCount 方法進入transfer 方法,主要就是新建新的空數組,然後移動拷貝每個元素到新數組.在擴容過程中,依然支持併發更新操作;也支持併發插入。

擴容原理

ConcurrentHashMap擴容的時候,如果有其他線程進行put操作,會幫助一起擴容(主要負責遷移數據),一個線程負責(按從後往前的順序)一個stride部分,將數據遷移到新的table中 。

什麼時候會觸發擴容?

  • 如果新增節點之後,所在的鏈表的元素個數大於等於8,則會調用treeifyBin把鏈表轉換爲紅黑樹。在轉換結構時,若tab的長度小於MIN_TREEIFY_CAPACITY,默認值爲64,則會將數組長度擴大到原來的兩倍,並觸發transfer,重新調整節點位置。(只有當tab.length >= 64, ConcurrentHashMap纔會使用紅黑樹。)
  • 新增節點後,addCount統計tab中的節點個數大於閾值(sizeCtl),會觸發擴容。

addCount方法

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

 private final void addCount(long x, int check) {
        CounterCell[] as; long b, s;
       //利用CAS更新baseCount,這個下面會詳細講
        ......

		//check就是結點數量,有新元素加入成功才檢查是否要擴容
        if (check >= 0) {
            Node<K,V>[] tab, nt; int n, sc;
            //s表示加入新元素後容量大小,計算已省略。
        	 // s(元素個數)大於等於sizeCtl,觸發擴容
            while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
                   (n = tab.length) < MAXIMUM_CAPACITY) {
                // 擴容標誌位,下面會詳細講
                // n不同則返回值不同,它的返回值被當作是當前table的標識,擴容期間sizeCtl的高16爲就爲該值,
				// 低16爲等於當前擴容線程數加一  
                int rs = resizeStamp(n);
                 //sc<0表示已經有線程在進行擴容工作
                if (sc < 0) {
                	 // 擴容已經結束,中斷循環
                    if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                        sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                        transferIndex <= 0)
                        break;
                    // 進行擴容,並設置sizeCtl,表示擴容線程 + 1
                    if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                        transfer(tab, nt);
                }
                 // 觸發擴容(第一個進行擴容的線程)
           		 // 並設置sizeCtl爲rs << RESIZE_STAMP_SHIFT) + 2告知其他線程
           		//sizeCtl之前代表閥值,更改後高16位爲標識,低16位爲擴容線程數加一
                else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                             (rs << RESIZE_STAMP_SHIFT) + 2))
                   
					//第二個參數爲null會初始化新數組nextTable,確保只有一個線程新建table
                    transfer(tab, null);
                 // 統計個數,用於循環檢測是否還需要擴容
                s = sumCount();
            }
        }
    }

resizeStamp()

該函數返回一個用於數據校驗的標誌位,意思是對長度爲n的table進行擴容。它將n的前導零(最高有效位之前的零的數量)和1 << 15做或運算,這時低16位的最高位爲1,其他都爲n的前導零。

private static int RESIZE_STAMP_BITS = 16;

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

初始化sizeCtl(擴容操作被第一個線程首次進行)的算法爲(rs << RESIZE_STAMP_SHIFT) + 2(addCount方法裏),首先RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS = 16,那麼rs << 16等於將這個標誌位移動到了高16位,這時最高位爲1,所以sizeCtl此時是個負數,然後加二(至於爲什麼是2,還記得有關sizeCtl的說明嗎?1代表初始化狀態,所以實際的線程個數是要減去1的)代表當前有一個線程正在進行擴容,

這樣sizeCtl就被分割成了兩部分,高16位是一個對n的數據校驗的標誌位,低16位表示參與擴容操作的線程個數 + 1。
可能會有讀者有所疑惑,更新進行擴容的線程數量的操作爲什麼是sc + 1而不是sc - 1,這是因爲對sizeCtl的操作都是基於位運算的,所以不會關心它本身的數值是多少,只關心它在二進制上的數值,而sc + 1會在低16位上加1。
在這裏插入圖片描述

總結一下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則代表閥值

transfer方法

當ConcurrentHashMap容量不足的時候,需要對table進行擴容。這個方法的基本思想跟HashMap是很像的,但是由於它是支持併發擴容的,所以要複雜的多。原因是它支持多線程進行擴容操作,而並沒有加鎖。我想這樣做的目的不僅僅是爲了滿足concurrent的要求,而是希望利用併發處理去減少擴容帶來的時間影響。因爲在擴容的時候,總是會涉及到從一個“數組”到另一個“數組”拷貝的操作,如果這個操作能夠併發進行,那真真是極好的了。

整個擴容操作分爲兩個部分

  • 第一部分是構建一個nextTable,它的容量是原來的兩倍,這個操作是擴容的第一個線程完成的。會保證第一個發起數據遷移的線程,nextTab 參數爲 null,之後再調用此方法的時候,nextTab 不會爲 null。
  • 第二個部分就是將原來table中的元素複製到nextTable中,這裏允許多線程進行操作。

先來看一下單線程是如何完成的:

它的大體思想就是遍歷、複製的過程。首先根據運算得到需要遍歷的次數i,然後利用tabAt方法獲得i位置的元素:

  • 如果這個位置爲空,就在原table中的i位置放入forwardNode節點,這個也是觸發併發擴容的關鍵點;

  • 如果這個位置是Node節點(fh>=0),如果它是一個鏈表的頭節點,就構造一個反序鏈表,把他們分別放在nextTable的i和i+n的位置上

  • 如果這個位置是TreeBin節點(fh<0),也做一個反序處理,並且判斷是否需要untreefi,把處理的結果分別放在nextTable的i和i+n的位置上

遍歷過所有的節點以後就完成了複製工作,這時讓nextTable作爲新的table,並且更新sizeCtl爲新容量的0.75倍 ,完成擴容。

private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
        //n爲舊tab的長度,stride爲步長(就是每個線程遷移的節點數)
        int n = tab.length, stride;
        // 根據當前機器的CPU數量來決定每個線程負責的bucket數
    	// 避免因爲擴容線程過多,反而影響到性能
        //單核步長爲1,多核爲(n>>>3)/ NCPU,最小值爲16
        if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
            stride = MIN_TRANSFER_STRIDE; // subdivide range
        //nextTab爲空,則將table擴爲原來的兩倍
        if (nextTab == null) {            // initiating
            try {
                @SuppressWarnings("unchecked")
                Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
                nextTab = nt;
            } catch (Throwable ex) {      // try to cope with OOME
                sizeCtl = Integer.MAX_VALUE;
                return;
            }
            nextTable = nextTab;
            //transferIndex也爲全局屬性,用於控制遷移位置
            transferIndex = n;
        }
        int nextn = nextTab.length;
        //ForwardingNode是正在被遷移的Node,它的key,value,next都爲null
        //hash爲MOVED,其中有個nextTable屬性指向新tab[]
        ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
        //advance爲true,可以繼續遷移下一個節點,false則停止遷移
        boolean advance = true;
        //是否結束遷移
        boolean finishing = false; // to ensure sweep before committing nextTab
        //i是當前遷移位置的索引,bound是遷移的邊界,是從後往前的順序
        for (int i = 0, bound = 0;;) {
        // 這個循環使用CAS不斷嘗試爲當前線程分配任務
   		// 直到分配成功或任務隊列已經被全部分配完畢
    	// 如果當前線程已經被分配過bucket區域
    	// 那麼會通過--i指向下一個待處理bucket然後退出該循
            Node<K,V> f; int fh;
            while (advance) {
                int nextIndex, nextBound;
          		// --i表示將i指向下一個待處理的bucket
		        // 如果--i >= bound,代表當前線程已經分配過bucket區域
		        // 並且還留有未處理的bucket
                if (--i >= bound || finishing)
                    advance = false;
                 //transferIndex(上一次遷移的邊界)賦值給nextIndex(必執行),這裏transferIndex一旦小於等於0
                //則說明原數組的所有位置的遷移都有相應的線程去處理了,該線程可以不用遷移了
                else if ((nextIndex = transferIndex) <= 0) {
                    i = -1;
                    advance = false;
                }
                //將nextBound賦值給transferIndex,nextBound = nextIndex - stride(上一個邊界減去步長)
                //i = nextIndex - 1(上一個邊界-1變成開始遷移的位置)
                else if (U.compareAndSwapInt
                         (this, TRANSFERINDEX, nextIndex,
                          nextBound = (nextIndex > stride ?
                                       nextIndex - stride : 0))) {
                    //確定當前線程每次分配的待遷移桶的範圍爲[bound, nextIndex)                   
                    bound = nextBound;
                    i = nextIndex - 1;
                    advance = false;
                }
            }
            //i < 0說明所有遷移任務都已經分配給對應的線程了,
            if (i < 0 || i >= n || i + n >= nextn) {
                int sc;
                //finishing爲true,說明所有遷移完成,將nextTable設爲空,sizeCtl爲新tab.length * 0.75
                if (finishing) {
                    nextTable = null;
                    table = nextTab;
                    sizeCtl = (n << 1) - (n >>> 1);
                    return;
                }
                //該線程完成遷移,sizeCtl - 1,對應之前helpTransfer()中+1
                if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
                	// (resizeStamp << RESIZE_STAMP_SHIFT) + 2代表當前有一個擴容線程
            		// 相對的,(sc - 2) !=  resizeStamp << RESIZE_STAMP_SHIFT
            		// 表示當前還有其他線程正在進行擴容,所以直接返回
                    if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
                        return;
                    //如果相等,則說明所有線程都完成任務了,設置finish爲true
                    finishing = advance = true;
                    i = n; // recheck before commit
                }
            }
             //如果舊tab[i]爲null,則放入ForwardingNode,以通知其他現程
            else if ((f = tabAt(tab, i)) == null)
                advance = casTabAt(tab, i, null, fwd);
            //如果該節點爲ForwardingNode,則說明已經被遷移過了,就可以開始遷移下一個節點了    
            else if ((fh = f.hash) == MOVED)
                advance = true; // already processed
            else {
            	//遷移開始加鎖,這部分和1.8HashMap差不多,將一條鏈表拆分成兩條,一條是正序,另外一條是反序
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        Node<K,V> ln, hn;
                        if (fh >= 0) {
                            int runBit = fh & n;
                            Node<K,V> lastRun = f;
                            //找出最後一段完整的fh&n不變(擴容後位置不變)的鏈表,這樣最後這一段鏈表就不用重新創建新結點了。
                            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,說明位置沒有變,不等於0,說明位置變化爲oldLength + 原位置
                            if (runBit == 0) {
                                ln = lastRun;
                                hn = null;
                            }
                            else {
                                hn = lastRun;
                                ln = null;
                            }
                             //lastRun之前的結點因爲fh&n不確定,所以全部需要重新遷移。
                            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);
                           // 在老數組位置上放上 ForwardingNode 節點
                           // put 時,發現是 ForwardingNode 節點,就不會再動這個節點的數據了
                            setTabAt(tab, i, fwd);
                            advance = true;
                        }
                        //樹的遷移
                       。。。
                   
                    }
                }
            }
        }
    }

擴容理解之後,再來看一下helpTransfer方法

helpTransfer方法

只有在桶的頭節點的hash值爲MOVED(這時爲ForwardingNode)時纔會調用helpTransfer方法,功能是幫助遷移節點,這個方法被調用的時候,當前ConcurrentHashMap一定已經有了nextTable對象,首先拿到這個nextTable對象,調用transfer方法。回看上面的transfer方法可以看到,當本線程進入擴容方法的時候會直接進入複製階段。

final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
        Node<K,V>[] nextTab; int sc;
        //只有f的hash爲MOVED,纔會執行該方法,說明f節點是ForwardingNode
        //如果nextTable爲null,則表示遷移完成了,詳見transfer()
        //三個判斷條件判斷的是擴容是否結束,ForwardingNode再創建時持有nextTable數組的引用,
		//nextTable會在擴容結束後被置爲null。
        if (tab != null && (f instanceof ForwardingNode) &&
            (nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
             // 本次擴容的標識,數組大小不變則rs不變
             int rs = resizeStamp(tab.length);
             //循環的這些判斷條件爲tue的話表明擴容未結束,擴容時sizeCtl一定小於0
            while (nextTab == nextTable && table == tab &&
                   (sc = sizeCtl) < 0) {
                // 擴容已經結束,中斷循環
                if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                    sc == rs + MAX_RESIZERS || transferIndex <= 0)
                    break;
               // 每有一個線程來幫助遷移,sizeCtl就+1,初始值爲(rs << RESIZE_STAMP_SHIFT) + 2)
               //(ps:在tryPresize()設置),之後再transfer中會用到
                if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
                    transfer(tab, nextTab);
                    break;
                }
            }
            return nextTab;
        }
        return table;
    }

9.get()方法

get方法比較簡單,給定一個key來確定value的時候,必須滿足兩個條件 key相同 hash值相同,對於節點可能在鏈表或樹上的情況,需要分別去查找.

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());
        //根據hash值計算結點的位置
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (e = tabAt(tab, (n - 1) & h)) != null) {
            // 先嚐試判斷鏈表頭是否爲目標,如果是就直接返回
            if ((eh = e.hash) == h) {
                if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                    return e.val;
            }
            else if (eh < 0)
            	 // eh < 0代表這是一個特殊節點(TreeBin或ForwardingNode)
            	 // 所以直接調用find()進行遍歷查找
                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;
    }

10.Size

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

相關內部類與變量:

//CounterCell是一個簡單的內部靜態類,每個CounterCell都是一個用於記錄數量的單元
@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;

//Java 8聲明瞭一個volatile變量baseCount用於記錄元素的個數,對這個變量的修改操作是基於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爲空的話,就對baseCount進行CAS,加1
        if ((as = counterCells) != null ||
            !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
            CounterCell a; long v; int m;
            boolean uncontended = true;
            //counterCells未初始化,或者對baseCount進行CAS失敗的話
            //再次判斷counterCells沒有初始化
            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(),該函數負責初始化CounterCells和更新計數
                fullAddCount(x, uncontended);
                return;
            }
            if (check <= 1)
                return;
            //統計總數
            s = sumCount();
        }
        //檢查是否需要擴容,上面已經講過
        ....
  • ConcurrentHashMap的計數設計與LongAdder類似。在一個低併發的情況下,就只是簡單地使用CAS操作來對baseCount進行更新,但只要這個CAS操作失敗一次,就代表有多個線程正在競爭,那麼就轉而使用CounterCell數組進行計數,數組內的每個ConuterCell都是一個獨立的計數單元。
  • 每個線程都會通過ThreadLocalRandom.getProbe() & m尋址找到屬於它的CounterCell(類似hashMap),然後進行計數。ThreadLocalRandom是一個線程私有的僞隨機數生成器,每個線程的probe都是不同的(這點基於ThreadLocalRandom的內部實現,它在內部維護了一個probeGenerator,這是一個類型爲AtomicInteger的靜態常量,每當初始化一個ThreadLocalRandom時probeGenerator都會先自增一個常量然後返回的整數即爲當前線程的probe,probe變量被維護在Thread對象中),可以認爲每個線程的probe就是它在CounterCell數組中的hash code。
  • 這種方法將競爭數據按照線程的粒度進行分離,相比所有競爭線程對一個共享變量使用CAS不斷嘗試在性能上要效率高多了,這也是爲什麼在高併發環境下LongAdder要優於AtomicInteger的原因。
  • fullAddCount()函數根據當前線程的probe尋找對應的CounterCell進行計數,如果CounterCell數組未被初始化,則初始化CounterCell數組和CounterCell。該函數的實現與Striped64類(LongAdder的父類)的longAccumulate()函數是一樣的,把CounterCell數組當成一個散列表,每個線程的probe就是hash code,散列函數也僅僅是簡單的(n - 1) & probe。
  • CounterCell數組的大小永遠是一個2的n次方,初始容量爲2,每次擴容的新容量都是之前容量乘以二,處於性能考慮,它的最大容量上限是機器的CPU數量。
  • 所以說CounterCell數組的碰撞衝突是很嚴重的,因爲它的bucket基數太小了。而發生碰撞就代表着一個CounterCell會被多個線程競爭,爲了解決這個問題,Doug Lea使用無限循環加上CAS來模擬出一個自旋鎖來保證線程安全,自旋鎖的實現基於一個被volatile修飾的整數變量,該變量只會有兩種狀態:0和1,當它被設置爲0時表示沒有加鎖,當它被設置爲1時表示已被其他線程加鎖。這個自旋鎖用於保護初始化CounterCell、初始化CounterCell數組以及對CounterCell數組進行擴容時的安全。
  • CounterCell更新計數是依賴於CAS的,每次循環都會嘗試通過CAS進行更新,如果成功就退出無限循環,否則就調用ThreadLocalRandom.advanceProbe()函數爲當前線程更新probe,然後重新開始循環,以期望下一次尋址到的CounterCell沒有被其他線程競爭。
  • 如果連着兩次CAS更新都沒有成功,那麼會對CounterCell數組進行一次擴容,這個擴容操作只會在當前循環中觸發一次,而且只能在容量小於上限時觸發。

對於統計總數,只要能夠理解CounterCell的思想,就很簡單了。仔細想一想,每次計數的更新都會被分攤在baseCount和CounterCell數組中的某一CounterCell,想要獲得總數,把它們統計相加就是了。

參考:

Map大家族的那點事兒
JUC源碼解析-ConcurrentHashMap1.8
關於jdk1.8中ConcurrentHashMap的方方面面
ConcurrentHashMap源碼分析(JDK8版本)
ConcurrentHashMap1.8源碼tryPresize()和transfer()方法解析
看完這篇ConcurrentHashMap源碼解析,我又覺得能手撕面試官了

在這裏感謝上述博客大佬。
如有不足之處,歡迎指正,謝謝!

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