併發程式設計之 ConcurrentHashMap(JDK 1.8) putVal 原始碼分析

莫那·魯道發表於2018-04-30

併發程式設計之 ConcurrentHashMap(JDK 1.8) putVal 原始碼分析

前言

我們之前分析了Hash的原始碼,主要是 put 方法。同時,我們知道,HashMap 在併發的時候是不安全的,為什麼呢?因為當多個執行緒對 Map 進行擴容會導致連結串列成環。不單單是這個問題,當多個執行緒相同一個槽中插入資料,也是不安全的。而在這之後,我們學習了併發程式設計,而併發程式設計中有一個重要的東西,就是JDK 自帶的併發容器,提供了執行緒安全的特性且比同步容器效能好出很多。一個典型的代表就是 ConcurrentHashMap,對,又是 HashMap ,但是這個 Map 是執行緒安全的,那麼同樣的,我們今天就看看該類的 put 方法是如何實現執行緒安全的。

原始碼加註釋分析 putVal 方法

 /** Implementation for put and putIfAbsent */
    final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        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 = initTable();
            // 獲取對應下標節點,如果是kong,直接插入
            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
            }
            // 如果 hash 衝突了,且 hash 值為 -1,說明是 ForwardingNode 物件(這是一個佔位符物件,儲存了擴容後的容器)
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            // 如果 hash 衝突了,且 hash 值不為 -1
            else {
                V oldVal = null;
                // 同步 f 節點,防止增加連結串列的時候導致連結串列成環
                synchronized (f) {
                    // 如果對應的下標位置 的節點沒有改變
                    if (tabAt(tab, i) == f) {
                        // 並且 f 節點的hash 值 不是大於0
                        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;
                                }
                            }
                        }
                        // 如果 f 節點的 hasj 小於0 並且f 是 樹型別
                        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;
                            }
                        }
                    }
                }
                // 連結串列長度大於等於8時,將該節點改成紅黑樹樹
                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        // 判斷是否需要擴容
        addCount(1L, binCount);
        return null;
    }
複製程式碼

樓主在程式碼中寫了很多註釋,但是還是說一下步驟(該方法和HashMap 的高度相似,但是多了很多同步操作)。

  1. 校驗key value 值,都不能是null。這點和 HashMap 不同。
  2. 得到 key 的 hash 值。
  3. 死迴圈並更新 tab 變數的值。
  4. 如果容器沒有初始化,則初始化。呼叫 initTable 方法。該方法通過一個變數 + CAS 來控制併發。稍後我們分析原始碼。
  5. 根據 hash 值找到陣列下標,如果對應的位置為空,就建立一個 Node 物件用CAS方式新增到容器。並跳出迴圈。
  6. 如果 hash 衝突,也就是對應的位置不為 null,則判斷該槽是否被擴容了(-1 表示被擴容了),如果被擴容了,返回新的陣列。
  7. 如果 hash 衝突 且 hash 值不是 -1,表示沒有被擴容。則進行連結串列操作或者紅黑樹操作,注意,這裡的 f 頭節點被鎖住了,保證了同時只有一個執行緒修改連結串列。防止出現連結串列成環。
  8. 和 HashMap 一樣,如果連結串列樹超過8,則修改連結串列為紅黑樹。
  9. 將陣列加1(CAS方式),如果需要擴容,則呼叫 transfer 方法(非常複雜,以後再詳解)進行移動和重新雜湊,該方法中,如果是槽中只有單個節點,則使用CAS直接插入,如果不是,則使用 synchronized 進行同步,防止併發成環。

這裡說一說 initTable 方法:

    /**
     * Initializes table, using the size recorded in sizeCtl.
     */
    private final Node<K,V>[] initTable() {
        Node<K,V>[] tab; int sc;
        while ((tab = table) == null || tab.length == 0) {
            // 小於0說明被其他執行緒改了
            if ((sc = sizeCtl) < 0)
                // 自旋等待
                Thread.yield(); // lost initialization race; just spin
            // CAS 修改 sizeCtl 的值為-1
            else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
                try {
                    if ((tab = table) == null || tab.length == 0) {
                        // sc 在初始化的時候使用者可能會自定義,如果沒有自定義,則是預設的
                        int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                        // 建立陣列
                        Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                        table = tab = nt;
                        // sizeCtl 計算後作為擴容的閥值
                        sc = n - (n >>> 2);
                    }
                } finally {
                    sizeCtl = sc;
                }
                break;
            }
        }
        return tab;
    }


複製程式碼

該方法為了在併發環境下的安全,加入了一個 sizeCtl 變數來進行判斷,只有當一個執行緒通過CAS修改該變數成功後(預設為0,改成 -1),該執行緒才能初始化陣列。保證了初始化陣列時的安全性。

總結

ConcurrentHashMap 是併發大師 Doug Lea 的傑作,可以說鬼斧神工,總的來說,使用了 CAS 加 synchronized 來保證了 put 操作併發時的危險(特別是連結串列),相比 同步容器 hashTable 來說,如果容器大小是16,併發的效能是他的16倍,注意,讀的時候是沒有鎖的,完全併發,而 HashTable 在 get 方法上直接加上了 synchronized 關鍵字,效能差距不言而喻。

當然,樓主這篇文章可能之寫到了 ConcurrentHashMap 的皮毛,關於如何擴容,樓主沒有詳細介紹,而樓主在閱讀原始碼的收穫也很多,發現了很多有趣的東西,比如 ThreadLocalRandom 類在 addCount 方法中的應用,大家可以看看該類,非常的實用。

注意:這篇文章僅僅是 ConcurrentHashMap 的開頭,關於 ConcurrentHashMap 裡面的精華太多,值得我們好好學習。

good luck !!!!!

相關文章