ConcurrentHashMap 核心源碼解析 0 前言 1 繼承體系 2 屬性 3 構造方法 4 put 5 transfer - 擴容 6 總結

人只應當忘卻自己而愛別人,這樣人才能安靜、幸福高尚。
——托爾斯泰《安娜•卡列尼娜》

0 前言

線程安全的 Map - ConcurrentHashMap,讓我們一起研究和 HashMap 相比有何差異,爲何能保證線程安全呢.

1 繼承體系


與 HashMap 很相似,數組、鏈表結構幾乎相同,都實現了 Map 接口,繼承了 AbstractMap 抽象類,大多數的方法也都是相同的,ConcurrentHashMap 幾乎包含 HashMap所有方法.

2 屬性

  • bin數組.第一次插入時才延遲初始化.大小始終是2的冪.由迭代器直接訪問.


  • 下一個要用的 table;僅在擴容時非null


  • 基本計數器值,主要在沒有爭用時使用,也用作table初始化競爭期間的反饋.通過CAS更新


  • table 初始化和擴容的控制
    如果爲負,則表將被初始化或擴容:
    -1用於初始化
    -N 活動的擴容線程數
    否則,當table爲null時,保留創建時要使用的初始表大小,或者默認爲0.
    初始化後,保留下一個要擴容表的元素計數值.


  • 擴容時要拆分的下一個表索引(加1)


  • 擴容和/或創建 CounterCell 時使用的自旋鎖(通過CAS鎖定)


  • Table of counter cells。 如果爲非null,則大小爲2的冪.


  • Node節點:保存key,value及key的hash值的數據結構,其中value和next都用volatile修飾,保證可見性
  • 一個特殊的Node節點,轉移節點的 hash 值都是 MOVED,-1.其中存儲nextTable的引用.在transfer期間插入bin head的節點.只有table發生擴容的時候,ForwardingNode纔會發揮作用,作爲一個佔位符放在table中表示當前節點爲null或則已經被移動,


3 構造方法

3.1 無參

  • 使用默認的初始表大小(16)創建一個新的空map


3.2 有參

  • 創建一個新的空map,其初始表大小可容納指定數量的元素,而無需動態調整大小。



    -創建一個與給定map具有相同映射的新map


注意 sizeCtl 會暫先維護一個2的冪次方的值的容量.

實例化ConcurrentHashMap時帶參數時,會根據參數調整table的大小,假設參數爲100,最終會調整成256,確保table的大小總是2的冪次方

tableSizeFor

  • 對於給定的所需容量,返回2的冪的表大小


table 的延遲初始化

ConcurrentHashMap在構造函數中只會初始化sizeCtl值,並不會直接初始化table,而是延緩到第一次put操作table初始化.但put是可以併發執行的,是如何保證 table 只初始化一次呢?

private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    // 進入自旋
    while ((tab = table) == null || tab.length == 0) {
        // 若某線程發現sizeCtl<0,意味着其他線程正在初始化,當前線程讓出CPU時間片
        if ((sc = sizeCtl) < 0) 
            Thread.yield(); // 失去初始化的競爭機會; 直接自旋
        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 = n - (n >>> 2);
                }
            } finally {
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

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

4 put

table已經初始化完成,put操作採用CAS+synchronized實現併發插入或更新操作.

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;
        // step1. table 爲 null或空時進行初始化
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        // step 2. 若當前數組索引無值,直接創建
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            // CAS 在索引 i 處創建新的節點,當索引 i 爲 null 時,即能創建成功,結束循環,否則繼續自旋
            if (casTabAt(tab, i, null,
                         new Node<K,V>(hash, key, value, null)))
                break;                   // no lock when adding to empty bin
        }
        // step3. 若當前桶爲轉移節點,表明該桶的點正在擴容,一直等待擴容完成
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        // step4. 當前索引位置有值
        else {
            V oldVal = null;
            // 鎖定當前槽點,保證只會有一個線程能對槽點進行修改
            synchronized (f) {
                // 這裏再次判斷 i 位置數據有無被修改
                // binCount 被賦值,說明走到了修改表的過程
                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;
                            }
                        }
                    }
                    // 紅黑樹,這裏沒有使用 TreeNode,使用的是 TreeBin,TreeNode 只是紅黑樹的一個節點
                    // TreeBin 持有紅黑樹的引用,並且會對其加鎖,保證其操作的線程安全
                    else if (f instanceof TreeBin) {
                        Node<K,V> p;
                        binCount = 2;
                        // 滿足if的話,把老的值給oldVal
                        // 在putTreeVal方法裏面,在給紅黑樹重新着色旋轉的時候
                        // 會鎖住紅黑樹的根節點
                        if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                       value)) != null) {
                            oldVal = p.val;
                            if (!onlyIfAbsent)
                                p.val = value;
                        }
                    }
                }
            }
            // binCount不爲空,並且 oldVal 有值的情況,說明已新增成功
            if (binCount != 0) {
                // 鏈表是否需要轉化成紅黑樹
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                // 槽點已經上鎖,只有在紅黑樹或者鏈表新增失敗的時候
                // 纔會走到這裏,這兩者新增都是自旋的,幾乎不會失敗
                break;
            }
        }
    }
    // step5. check 容器是否需要擴容,如果需要去擴容,調用 transfer 方法擴容
    // 如果已經在擴容中了,check有無完成
    addCount(1L, binCount);
    return null;
}

4.2 執行流程

  1. 若數組空,則初始化,完成之後,轉2
  2. 計算當前桶位是否有值
    • 無,則 CAS 創建,失敗後繼續自旋,直到成功
    • 有,轉3
  3. 判斷桶位是否爲轉移節點(擴容ing)
    • 是,則一直自旋等待擴容完成,之後再新增
    • 否,轉4
  4. 桶位有值,對當前桶位加synchronize鎖
    • 鏈表,新增節點到鏈尾
    • 紅黑樹,紅黑樹版方法新增
  5. 新增完成之後,檢驗是否需要擴容

通過自旋 + CAS + synchronize 鎖三板斧的實現很巧妙,給我們設計併發代碼提供了最佳實踐!

5 transfer - 擴容

在 put 方法最後檢查是否需要擴容,從 put 方法的 addCount 方法進入transfer 方法.

主要就是新建新的空數組,然後移動拷貝每個元素到新數組.

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
    // 如果新數組爲空,初始化,大小爲原數組的兩倍,n << 1
    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 = 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
    // 自旋,i 值會從原數組的最大值遞減到 0
    for (int i = 0, bound = 0;;) {
        Node<K,V> f; int fh;
        while (advance) {
            int nextIndex, nextBound;
            // 結束循環的標誌
            if (--i >= bound || finishing)
                advance = false;
            // 已經拷貝完成
            else if ((nextIndex = transferIndex) <= 0) {
                i = -1;
                advance = false;
            }
            // 每次減少 i 的值
            else if (U.compareAndSwapInt
                     (this, TRANSFERINDEX, nextIndex,
                      nextBound = (nextIndex > stride ?
                                   nextIndex - stride : 0))) {
                bound = nextBound;
                i = nextIndex - 1;
                advance = false;
            }
        }
        // if 任意條件滿足說明拷貝結束了
        if (i < 0 || i >= n || i + n >= nextn) {
            int sc;
            // 拷貝結束,直接賦值,因爲每次拷貝完一個節點,都在原數組上放轉移節點,所以拷貝完成的節點的數據一定不會再發生變化
            // 原數組發現是轉移節點,是不會操作的,會一直等待轉移節點消失之後在進行操作
            // 也就是說數組節點一旦被標記爲轉移節點,是不會再發生任何變動的,所以不會有任何線程安全的問題
            // 所以此處直接賦值,沒有任何問題。
            if (finishing) {
                nextTable = null;
                table = nextTab;
                sizeCtl = (n << 1) - (n >>> 1);
                return;
            }
            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
            }
        }
        else if ((f = tabAt(tab, i)) == null)
            advance = casTabAt(tab, i, null, fwd);
        else if ((fh = f.hash) == MOVED)
            advance = true; // already processed
        else {
            synchronized (f) {
                // 節點的拷貝
                if (tabAt(tab, i) == f) {
                    Node<K,V> ln, hn;
                    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;
                            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;
                    }
                    // 紅黑樹的拷貝
                    else if (f instanceof TreeBin) {
                        // 紅黑樹的拷貝工作,同 HashMap 的內容,代碼忽略
                        ...
                        // 在老數組位置上放上 ForwardingNode 節點
                        setTabAt(tab, i, fwd);
                        advance = true;
                    }
                }
            }
        }
    }
}

執行流程

  1. 首先把原數組的值全部拷貝到擴容之後的新數組,先從數組的隊尾開始拷貝
  2. 拷貝數組的槽點時,先把原數組槽點鎖住,成功拷貝到新數組時,把原數組槽點賦值爲轉移節點
  3. 這時如果有新數據正好需要 put 到該槽點時,發現槽點爲轉移節點,就會一直等待,所以在擴容完成之前,該槽點對應的數據是不會發生變化的
  4. 從數組的尾部拷貝到頭部,每拷貝成功一次,就把原數組中的節點設置成轉移節點
    直到所有數組數據都拷貝到新數組時,直接把新數組整個賦值給數組容器,拷貝完成。

6 總結

ConcurrentHashMap 作爲一個併發 map,是面試必問點,也是工作中必須掌握的併發容器.

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