死磕 java集合之ConcurrentHashMap原始碼分析(三)——刪除元素全解析(內含彩蛋)

彤哥讀原始碼發表於2019-04-13

?歡迎關注我的公眾號“彤哥讀原始碼”,檢視更多原始碼系列文章, 與彤哥一起暢遊原始碼的海洋。


本章接著上兩章,連結直達:

死磕 java集合之ConcurrentHashMap原始碼分析(一)

死磕 java集合之ConcurrentHashMap原始碼分析(二)


刪除元素

刪除元素跟新增元素一樣,都是先找到元素所在的桶,然後採用分段鎖的思想鎖住整個桶,再進行操作。

public V remove(Object key) {
    // 呼叫替換節點方法
    return replaceNode(key, null, null);
}

final V replaceNode(Object key, V value, Object cv) {
    // 計算hash
    int hash = spread(key.hashCode());
    // 自旋
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0 ||
                (f = tabAt(tab, i = (n - 1) & hash)) == null)
            // 如果目標key所在的桶不存在,跳出迴圈返回null
            break;
        else if ((fh = f.hash) == MOVED)
            // 如果正在擴容中,協助擴容
            tab = helpTransfer(tab, f);
        else {
            V oldVal = null;
            // 標記是否處理過
            boolean validated = false;
            synchronized (f) {
                // 再次驗證當前桶第一個元素是否被修改過
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) {
                        // fh>=0表示是連結串列節點
                        validated = true;
                        // 遍歷連結串列尋找目標節點
                        for (Node<K,V> e = f, pred = null;;) {
                            K ek;
                            if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                            (ek != null && key.equals(ek)))) {
                                // 找到了目標節點
                                V ev = e.val;
                                // 檢查目標節點舊value是否等於cv
                                if (cv == null || cv == ev ||
                                        (ev != null && cv.equals(ev))) {
                                    oldVal = ev;
                                    if (value != null)
                                        // 如果value不為空則替換舊值
                                        e.val = value;
                                    else if (pred != null)
                                        // 如果前置節點不為空
                                        // 刪除當前節點
                                        pred.next = e.next;
                                    else
                                        // 如果前置節點為空
                                        // 說明是桶中第一個元素,刪除之
                                        setTabAt(tab, i, e.next);
                                }
                                break;
                            }
                            pred = e;
                            // 遍歷到連結串列尾部還沒找到元素,跳出迴圈
                            if ((e = e.next) == null)
                                break;
                        }
                    }
                    else if (f instanceof TreeBin) {
                        // 如果是樹節點
                        validated = true;
                        TreeBin<K,V> t = (TreeBin<K,V>)f;
                        TreeNode<K,V> r, p;
                        // 遍歷樹找到了目標節點
                        if ((r = t.root) != null &&
                                (p = r.findTreeNode(hash, key, null)) != null) {
                            V pv = p.val;
                            // 檢查目標節點舊value是否等於cv
                            if (cv == null || cv == pv ||
                                    (pv != null && cv.equals(pv))) {
                                oldVal = pv;
                                if (value != null)
                                    // 如果value不為空則替換舊值
                                    p.val = value;
                                else if (t.removeTreeNode(p))
                                    // 如果value為空則刪除元素
                                    // 如果刪除後樹的元素個數較少則退化成連結串列
                                    // t.removeTreeNode(p)這個方法返回true表示刪除節點後樹的元素個數較少
                                    setTabAt(tab, i, untreeify(t.first));
                            }
                        }
                    }
                }
            }
            // 如果處理過,不管有沒有找到元素都返回
            if (validated) {
                // 如果找到了元素,返回其舊值
                if (oldVal != null) {
                    // 如果要替換的值為空,元素個數減1
                    if (value == null)
                        addCount(-1L, -1);
                    return oldVal;
                }
                break;
            }
        }
    }
    // 沒找到元素返回空
    return null;
}
複製程式碼

(1)計算hash;

(2)如果所在的桶不存在,表示沒有找到目標元素,返回;

(3)如果正在擴容,則協助擴容完成後再進行刪除操作;

(4)如果是以連結串列形式儲存的,則遍歷整個連結串列查詢元素,找到之後再刪除;

(5)如果是以樹形式儲存的,則遍歷樹查詢元素,找到之後再刪除;

(6)如果是以樹形式儲存的,刪除元素之後樹較小,則退化成連結串列;

(7)如果確實刪除了元素,則整個map元素個數減1,並返回舊值;

(8)如果沒有刪除元素,則返回null;

獲取元素

獲取元素,根據目標key所在桶的第一個元素的不同採用不同的方式獲取元素,關鍵點在於find()方法的重寫。

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());
    // 如果元素所在的桶存在且裡面有元素
    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)
            // hash小於0,說明是樹或者正在擴容
            // 使用find尋找元素,find的尋找方式依據Node的不同子類有不同的實現方式
            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;
}
複製程式碼

(1)hash到元素所在的桶;

(2)如果桶中第一個元素就是該找的元素,直接返回;

(3)如果是樹或者正在遷移元素,則呼叫各自Node子類的find()方法尋找元素;

(4)如果是連結串列,遍歷整個連結串列尋找元素;

(5)獲取元素沒有加鎖;

獲取元素個數

元素個數的儲存也是採用分段的思想,獲取元素個數時需要把所有段加起來。

public int size() {
    // 呼叫sumCount()計算元素個數
    long n = sumCount();
    return ((n < 0L) ? 0 :
            (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
                    (int)n);
}

final long sumCount() {
    // 計算CounterCell所有段及baseCount的數量之和
    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;
}
複製程式碼

(1)元素的個數依據不同的執行緒存在在不同的段裡;(見addCounter()分析)

(2)計算CounterCell所有段及baseCount的數量之和;

(3)獲取元素個數沒有加鎖;

總結

(1)ConcurrentHashMap是HashMap的執行緒安全版本;

(2)ConcurrentHashMap採用(陣列 + 連結串列 + 紅黑樹)的結構儲存元素;

(3)ConcurrentHashMap相比於同樣執行緒安全的HashTable,效率要高很多;

(4)ConcurrentHashMap採用的鎖有 synchronized,CAS,自旋鎖,分段鎖,volatile等;

(5)ConcurrentHashMap中沒有threshold和loadFactor這兩個欄位,而是採用sizeCtl來控制;

(6)sizeCtl = -1,表示正在進行初始化;

(7)sizeCtl = 0,預設值,表示後續在真正初始化的時候使用預設容量;

(8)sizeCtl > 0,在初始化之前儲存的是傳入的容量,在初始化或擴容後儲存的是下一次的擴容門檻;

(9)sizeCtl = (resizeStamp << 16) + (1 + nThreads),表示正在進行擴容,高位儲存擴容郵戳,低位儲存擴容執行緒數加1;

(10)更新操作時如果正在進行擴容,當前執行緒協助擴容;

(11)更新操作會採用synchronized鎖住當前桶的第一個元素,這是分段鎖的思想;

(12)整個擴容過程都是通過CAS控制sizeCtl這個欄位來進行的,這很關鍵;

(13)遷移完元素的桶會放置一個ForwardingNode節點,以標識該桶遷移完畢;

(14)元素個數的儲存也是採用的分段思想,類似於LongAdder的實現;

(15)元素個數的更新會把不同的執行緒hash到不同的段上,減少資源爭用;

(16)元素個數的更新如果還是出現多個執行緒同時更新一個段,則會擴容段(CounterCell);

(17)獲取元素個數是把所有的段(包括baseCount和CounterCell)相加起來得到的;

(18)查詢操作是不會加鎖的,所以ConcurrentHashMap不是強一致性的;

(19)ConcurrentHashMap中不能儲存key或value為null的元素;

彩蛋——值得學習的技術

ConcurrentHashMap中有哪些值得學習的技術呢?

我認為有以下幾點:

(1)CAS + 自旋,樂觀鎖的思想,減少執行緒上下文切換的時間;

(2)分段鎖的思想,減少同一把鎖爭用帶來的低效問題;

(3)CounterCell,分段儲存元素個數,減少多執行緒同時更新一個欄位帶來的低效;

(4)@sun.misc.Contended(CounterCell上的註解),避免偽共享;(p.s.偽共享我們後面也會講的^^)

(5)多執行緒協同進行擴容;

(6)你又學到了哪些呢?

彩蛋——不能解決的問題

ConcurrentHashMap不能解決什麼問題呢?

請看下面的例子:

private static final Map<Integer, Integer> map = new ConcurrentHashMap<>();

public void unsafeUpdate(Integer key, Integer value) {
    Integer oldValue = map.get(key);
    if (oldValue == null) {
        map.put(key, value);
    }
}
複製程式碼

這裡如果有多個執行緒同時呼叫unsafeUpdate()這個方法,ConcurrentHashMap還能保證執行緒安全嗎?

答案是不能。因為get()之後if之前可能有其它執行緒已經put()了這個元素,這時候再put()就把那個執行緒put()的元素覆蓋了。

那怎麼修改呢?

答案也很簡單,使用putIfAbsent()方法,它會保證元素不存在時才插入元素,如下:

public void safeUpdate(Integer key, Integer value) {
    map.putIfAbsent(key, value);
}
複製程式碼

那麼,如果上面oldValue不是跟null比較,而是跟一個特定的值比如1進行比較怎麼辦?也就是下面這樣:

public void unsafeUpdate(Integer key, Integer value) {
    Integer oldValue = map.get(key);
    if (oldValue == 1) {
        map.put(key, value);
    }
}
複製程式碼

這樣的話就沒辦法使用putIfAbsent()方法了。

其實,ConcurrentHashMap還提供了另一個方法叫replace(K key, V oldValue, V newValue)可以解決這個問題。

replace(K key, V oldValue, V newValue)這個方法可不能亂用,如果傳入的newValue是null,則會刪除元素。

public void safeUpdate(Integer key, Integer value) {
    map.replace(key, 1, value);
}
複製程式碼

那麼,如果if之後不是簡單的put()操作,而是還有其它業務操作,之後才是put(),比如下面這樣,這該怎麼辦呢?

public void unsafeUpdate(Integer key, Integer value) {
    Integer oldValue = map.get(key);
    if (oldValue == 1) {
        System.out.println(System.currentTimeMillis());
        /**
         * 其它業務操作
         */
        System.out.println(System.currentTimeMillis());
      
        map.put(key, value);
    }
}
複製程式碼

這時候就沒辦法使用ConcurrentHashMap提供的方法了,只能業務自己來保證執行緒安全了,比如下面這樣:

public void safeUpdate(Integer key, Integer value) {
    synchronized (map) {
        Integer oldValue = map.get(key);
        if (oldValue == null) {
            System.out.println(System.currentTimeMillis());
            /**
             * 其它業務操作
             */
            System.out.println(System.currentTimeMillis());

            map.put(key, value);
        }
    }
}
複製程式碼

這樣雖然不太友好,但是最起碼能保證業務邏輯是正確的。

當然,這裡使用ConcurrentHashMap的意義也就不大了,可以換成普通的HashMap了。

上面只是舉一個簡單的例子,我們不能聽說ConcurrentHashMap是執行緒安全的,就認為它無論什麼情況下都是執行緒安全的,還是那句話盡信書不如無書。

這也正是我們讀原始碼的目的之一,瞭解其本質,才能在我們的實際工作中少挖坑,不論是挖給別人還是挖給自己^^。


好了,整個ConcurrentHashMap就講完了。

文章暫不支援留言功能,如果您有任何建議或意見請在公眾號後臺給我留言,留言必回覆。

喜歡這篇關於ConcurrentHashMap講解的,賞個雞腿唄~~


歡迎關注我的公眾號“彤哥讀原始碼”,檢視更多原始碼系列文章, 與彤哥一起暢遊原始碼的海洋。

qrcode

相關文章