帶你走進Java集合之ConcurrentHashMap

木木匠發表於2018-10-18

帶你走進Java集合之ConcurrentHashMap

一、概述

上一篇文章《帶你走進java集合之HashMap》分析了HashMap的實現原理,重點分析了HashMap是怎麼樣的一種資料結構,以及如何去插入,查詢,擴容等操作。相信經過上一篇文章的學習,大家應該對HashMap有了一定的瞭解,但是我們知道HashMap是一個執行緒不安全的集合,在多執行緒情況下使用HashMap會有很多問題,那麼我們如何使用一個執行緒安全的HashMap呢,接下來我們就介紹一個執行緒安全的Map,ConcurrentHashMap,我們來看看這個執行緒安全的Map道理如何使用,又是如何實現的。

二、HashMap和ConcurrentHashMap的對比

我們用一段程式碼證明下HashMap的執行緒不安全,以及ConcurrentHashMap的執行緒安全性。程式碼邏輯很簡單,開啟10000個執行緒,每個執行緒做很簡單的操作,就是put一個key,然後刪除一個key,理論上執行緒安全的情況下,最後map的size()肯定為0。

  Map<Object,Object> myMap=new HashMap<>();
        // ConcurrentHashMap myMap=new ConcurrentHashMap();
        for (int i = 0; i <10000 ; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                        double a=Math.random();
                        myMap.put(a,a);
                        myMap.remove(a);
                }
            }).start();
        }
        Thread.sleep(15000l);//多休眠下,保證上面的執行緒操作完畢。
        System.out.println(myMap.size());
複製程式碼

結果:

帶你走進Java集合之ConcurrentHashMap
這裡顯示Map的size=13,但是實際上map裡還有一個key。 同樣的程式碼我們用ConcurrentHashMap來執行下,結果如下:

帶你走進Java集合之ConcurrentHashMap
這裡就證明了ConcurrentHashMap是執行緒安全的,我們接下來從原始碼分析下ConcurrentHashMap是如何保證執行緒安全的,本次原始碼jdk版本為1.8。

三、ConcurrentHashMap原始碼分析

3.1 ConcurrentHashMap的基礎屬性

//預設最大的容量 
private static final int MAXIMUM_CAPACITY = 1 << 30;
//預設初始化的容量
private static final int DEFAULT_CAPACITY = 16;
//最大的陣列可能長度
static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
//預設的併發級別,目前並沒有用,只是為了保持相容性
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;
//和hashMap一樣,負載因子
private static final float LOAD_FACTOR = 0.75f;
//和HashMap一樣,連結串列轉換為紅黑樹的閾值,預設是8
static final int TREEIFY_THRESHOLD = 8;
//紅黑樹轉換連結串列的閥值,預設是6
static final int UNTREEIFY_THRESHOLD = 6;
//進行連結串列轉換最少需要的陣列長度,如果沒有達到這個數字,只能進行擴容
static final int MIN_TREEIFY_CAPACITY = 64;
//table擴容時, 每個執行緒最少遷移table的槽位個數
private static final int MIN_TRANSFER_STRIDE = 16;
//感覺是用來計算偏移量和執行緒數量的標記
private static int RESIZE_STAMP_BITS = 16;
//能夠調整的最大執行緒數量
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;
//記錄偏移量
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
//值為-1, 當Node.hash為MOVED時, 代表著table正在擴容
static final int MOVED     = -1;
//TREEBIN, 置為-2, 代表此元素後接紅黑樹
static final int TREEBIN   = -2;
//感覺是佔位符,目前沒看出來明顯的作用
static final int RESERVED  = -3;
//主要用來計算Hash值的
static final int HASH_BITS = 0x7fffffff; 
//節點陣列
transient volatile Node<K,V>[] table;
//table遷移過程臨時變數, 在遷移過程中將元素全部遷移到nextTable上
private transient volatile Node<K,V>[] nextTable;
//基礎計數器
private transient volatile long baseCount;
//table擴容和初始化的標記,不同的值代表不同的含義,預設為0,表示未初始化
//-1: table正在初始化;小於-1,表示table正在擴容;大於0,表示初始化完成後下次擴容的大小
private transient volatile int sizeCtl;
//table容量從n擴到2n時, 是從索引n->1的元素開始遷移, transferIndex代表當前已經遷移的元素下標
private transient volatile int transferIndex;
//擴容時候,CAS鎖標記
private transient volatile int cellsBusy;
//計數器表,大小是2次冪
private transient volatile CounterCell[] counterCells;
複製程式碼

上面就是ConcurrentHashMap的基本屬性,我們大部分和HashMap一樣,只是增加了部分屬性,後面我們來分析增加的部分屬性是起到如何的作用的。

3.2 ConcurrentHashMap的常用方法屬性

  • put方法
final V putVal(K key, V value, boolean onlyIfAbsent) {
        //key和value不允許為null
        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();
            //計算陣列的位置    
            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
            }//如果正在擴容
            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;
                                //這裡key相同直接覆蓋原來的節點
                                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,alue)) != 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;
                }
            }
        }
        //計數器,採用CAS計算size大小,並且檢查是否需要擴容
        addCount(1L, binCount);
        return null;
    }
複製程式碼

我們發現ConcurrentHashMap的put方法和HashMap的邏輯相差不大,主要是新增了執行緒安全部分,在新增元素時候,採用synchronized來保證執行緒安全,然後計算size的時候採用CAS操作進行計算。整個put流程比較簡單,總結下就是:

1.判斷key和vaule是否為空,如果為空,直接丟擲異常。

2.判斷table陣列是否已經初始化完畢,如果沒有初始化,進行初始化。

3.計算key的hash值,如果該位置為空,直接構造節點放入。

4.如果table正在擴容,進入幫助擴容方法。

5.最後開啟同步鎖,進行插入操作,如果開啟了覆蓋選項,直接覆蓋,否則,構造節點新增到尾部,如果節點數超過紅黑樹閾值,進行紅黑樹轉換。如果當前節點是樹節點,進行樹插入操作。

6.最後統計size大小,並計算是否需要擴容。

  • get方法
public V get(Object key) {
        Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
        //計算hash值
        int h = spread(key.hashCode());
        //如果table已經初始化,並且計算hash值的索引位置node不為空
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (e = tabAt(tab, (n - 1) & h)) != null) {
            //如果hash相等,key相等,直接返回該節點的value
            if ((eh = e.hash) == h) {
                if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                    return e.val;
            }//如果hash值為負值表示正在擴容,這個時候查的是ForwardingNode的find方法來定位到節點。
            else if (eh < 0)
                return (p = e.find(h, key)) != null ? p.val : null;
            //迴圈遍歷連結串列,查詢key和hash值相等的節點。
            while ((e = e.next) != null) {
                if (e.hash == h &&
                    ((ek = e.key) == key || (ek != null && key.equals(ek))))
                    return e.val;
            }
        }
        return null;
    }
複製程式碼

get方法比較簡單,主要流程如下:

1.直接計算hash值,查詢的節點如果key和hash值相等,直接返回該節點的value就行。

2.如果table正在擴容,就呼叫ForwardingNode的find方法查詢節點。

3.如果沒有擴容,直接迴圈遍歷連結串列,查詢到key和hash值一樣的節點值即可。

  • ConcurrentHashMap的擴容

ConcurrentHashMap的擴容相對於HashMap的擴容相對複雜,因為涉及到了多執行緒操作,這裡擴容方法主要是transfer,我們來分析下這個方法的原始碼,研究下是如何擴容的。

private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
        int n = tab.length, stride;
        //保證每個執行緒擴容最少是16,
        if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
            stride = MIN_TRANSFER_STRIDE; // subdivide range
        if (nextTab == null) {            // initiating
            try {
            //擴容2倍
                @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;
        }
        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;
                //如果該執行緒已經完成了
                if (--i >= bound || finishing)
                    advance = false;
                //設定擴容轉移下標,如果下標小於0,說明已經沒有區間可以操作了,執行緒可以退出了
                else if ((nextIndex = transferIndex) <= 0) {
                    i = -1;
                    advance = false;
                }CAS操作設定區間
                else if (U.compareAndSwapInt
                         (this, TRANSFERINDEX, nextIndex,
                          nextBound = (nextIndex > stride ?
                                       nextIndex - stride : 0))) {
                    bound = nextBound;
                    i = nextIndex - 1;
                    advance = false;
                }
            }
            //如果計算的區間小於0了,說明區間分配已經完成,沒有剩餘區間分配了
            if (i < 0 || i >= n || i + n >= nextn) {
                int sc;
                if (finishing) {//如果擴容完成了,進行收尾工作
                    nextTable = null;//清空臨時陣列
                    table = nextTab;//賦值原陣列
                    sizeCtl = (n << 1) - (n >>> 1);//重新賦值sizeCtl
                    return;
                }//如果擴容還在進行,自己任務完成就進行sizeCtl-1,這裡是因為,擴容是通過helpTransfer()和addCount()方法來呼叫的,在呼叫transfer()真正擴容之前,sizeCtl都會+1,所以這裡每個執行緒完成後就進行-1。
                if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
                //這裡應該是判斷擴容是否結束
                    if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
                        return;
                    //結束,賦值狀態
                    finishing = advance = true;
                    i = n; // recheck before commit
                }
            }//如果在table中沒找到,就用過渡節點
            else if ((f = tabAt(tab, i)) == null)
                //成功設定就進入下一個節點
                advance = casTabAt(tab, i, null, fwd);
            else if ((fh = f.hash) == MOVED)
                //如果節點不為空,並且該位置的hash值為-1,表示已經處理了,直接進入下一個迴圈即可
                advance = true; // already processed
            else {
            //這裡說明老table該位置不為null,也沒有被處理過,進行真正的處理邏輯。進行同步鎖
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        Node<K,V> ln, hn;
                        //如果hash值大於0
                        if (fh >= 0) {
                        //為運算結果
                            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;
                                }
                            }
                            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;
                                //這裡的邏輯和hashMap是一樣的,都是採用2個連結串列進行處理,具體分析可以檢視我分析HashMap的文章
                                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;
                        }
                    }
                }
            }
        }
    }
    
複製程式碼

ConcurrentHashMap的擴容還是比較複雜,複雜主要表現在,控制多執行緒擴容層面上,擴容的原始碼我沒有解析的很細,一方面是確實比較複雜,本人有某些地方也不是太明白,另一方面是我覺得我們研究主要是弄懂其思想,能搞明白關鍵程式碼和關鍵思路即可,只要不是重新實現一套類似的功能,我想就不用糾結其全部細節了。總結下ConcurrentHashMap的擴容步驟如下:

1.獲取執行緒擴容處理步長,最少是16,也就是單個執行緒處理擴容的節點個數。

2.新建一個原來容量2倍的陣列,構造過渡節點,用於擴容期間的查詢操作。

3.進行死迴圈進行轉移節點,主要根據finishing變數判斷是否擴容結束,在擴容期間通過給不同的執行緒設定不同的下表索引進行擴容操作,就是不同的執行緒,操作的陣列分段不一樣,同時利用synchronized同步鎖鎖住操作的節點,保證了執行緒安全。

4.真正進行節點在新陣列的位置是和HashMap擴容邏輯一樣的,通過位運算計算出新連結串列是否位於原位置或者位於原位置+擴容的長度位置,具體分析可以檢視我的這篇文章

四、總結

1.ConcurrentHashMap大部分的邏輯程式碼和HashMap是一樣的,主要通過synchronized和來保證節點插入擴容的執行緒安全,這裡肯定有同學會問,為啥使用synchronized呢?而不用採取樂觀鎖,或者lock呢?我個人覺得可能原因有2點:

  • a.樂觀鎖比較適用於競爭衝突比較少的場景,如果衝突比較多,那麼就會導致不停的重試,這樣反而效能更低。
  • b.synchronized在經歷了優化之後,其實效能已經和lock沒啥差異了,某些場景可能還比lock快。所以,我覺得這是採用synchronized來同步的原因。

2.ConcurrentHashMap的擴容核心邏輯主要是給不同的執行緒分配不同的陣列下標,然後每個執行緒處理各自下表區間的節點。同時處理節點複用了hashMap的邏輯,通過位執行,可以知道節點擴容後的位置,要麼在原位置,要麼在原位置+oldlength位置,最後直接賦值即可。

五、參考

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

六、推薦閱讀

Java鎖之ReentrantLock(一)

Java鎖之ReentrantLock(二)

Java鎖之ReentrantReadWriteLock

JAVA NIO程式設計入門(一)

JAVA NIO 程式設計入門(二)

JAVA NIO 程式設計入門(三)

帶你走進java集合之ArrayList

帶你走進java集合之HashMap

相關文章