HashMap很美好,但執行緒不安全怎麼辦?ConcurrentHashMap告訴你答案!

JavaBuild發表於2024-03-02

寫在開頭

《耗時2天,寫完HashMap》這篇文章中,我們提到關於HashMap執行緒不安全的問題,主要存在如下3點風險:

風險1: put的時候導致元素丟失;如兩個執行緒同時put,且key值相同的情況下,後一個執行緒put操作覆蓋了前一個執行緒的操作,導致前一個執行緒的元素丟失。
風險2: put 和 get 併發時會導致 get 到 null;若一個執行緒的put操作觸發了陣列的擴容,這時另外一個執行緒去get,因為擴容的操作很耗時,這時有可能會卡死或者get到null。
風險3: 多執行緒下擴容會死迴圈;多執行緒下觸發擴容時,因為前一個執行緒已經破壞了原有連結串列結構,後一個執行緒再去讀取節點,進行連結的時候,很可能發生順序錯亂,從而形成一個環形連結串列,進而導致死迴圈。

Hashtable解決執行緒安全靠譜嗎?

那我們怎麼辦呢?很多小夥伴可能第一時間想到了HashTable,因為它和HashMap擁有者相似的功能,底層也是基於雜湊表實現,陣列+連結串列構建,陣列容量到達閾值後,同樣會自動擴容,Hashtable 預設的初始大小為 11,之後每次擴充,容量變為原來的 2n+1。並且,Hashtable內部的方法幾乎都是synchronized關鍵字修飾,保證了執行緒的安全

哇!這樣一看,Hashtable簡直是解決HashMap執行緒不安全的天選之子啊!但事實上,因為效能的問題,Hashtable已經在被廢棄的邊緣了,非常不建議在程式碼中使用它,原因如下接著往下看。
我們先寫一個小小的測試類,來感受一下Hashtable的使用。

【程式碼示例1】

public class Test {
    public static void main(String[] args) {
        HashMap<Integer, String> map = new HashMap<>();
        map.put(1, "I");
        map.put(2, "love");
        map.put(3, "Java");

        Hashtable<Integer, String> hashtable = new Hashtable<>();
        hashtable.put(1, "JavaBuild");
        for (Map.Entry<Integer, String> entry : hashtable.entrySet()) {
            System.out.println(entry.getKey()+":"+entry.getValue());
        }
    }
}

輸出:

1:JavaBuild

然後,我們跟入到put中的原來,去看看它的底層實現

【原始碼解析1】

 public synchronized V put(K key, V value) {
        // Make sure the value is not null
        if (value == null) {
            throw new NullPointerException();
        }

        // Makes sure the key is not already in the hashtable.
        Entry<?,?> tab[] = table;
        int hash = key.hashCode();
        int index = (hash & 0x7FFFFFFF) % tab.length;
        @SuppressWarnings("unchecked")
        Entry<K,V> entry = (Entry<K,V>)tab[index];
        for(; entry != null ; entry = entry.next) {
            if ((entry.hash == hash) && entry.key.equals(key)) {
                V old = entry.value;
                entry.value = value;
                return old;
            }
        }

        addEntry(hash, key, value, index);
        return null;
    }

透過這段原始碼我們能夠發現
1、Hashtable雜湊值的計算,並沒有像HashMap那樣重新計算,而是直接取key的hashCode()方法,這樣一來它的擾動次數明顯降低,hash的重合度更高;
2,index的位置計算中,Hashtable採用了%取餘運算,而HashMap採用的是&運算,我們知道位運算直接對記憶體資料進行操作,不需要轉成十進位制,處理速度非常快,相比之下Hashtable的效率低下。
3,底層大部分的方法都是synchronized修飾,我們知道用synchronized 來保證執行緒安全的效率非常低下。當一個執行緒訪問同步方法時,其他執行緒也訪問同步方法,可能會進入阻塞或輪詢狀態,如使用 put 新增元素,另一個執行緒不能使用 put 新增元素,也不能使用 get,競爭會越來越激烈效率越低。

以上3點足以讓我們頭也不回的捨棄Hashtable,那麼問題來了,除了這個集合類外,我們還有什麼選項呢?這時,ConcurrentHashMap 高高的舉起了它的小手!

ConcurrentHashMap

文章寫到這些,終於引出了我們今天的主角,ConcurrentHashMap!作為一個效率又高,又能保證執行緒安全的集合類,它的使用頻率非常之高,話不多說,我們先來畫一個底層邏輯實現圖感受一下它的魅力!
JDK1.8下的ConcurrentHashMap底層實現

哦,對了,雖然我們現在主流的Java版本都是1.8+了,但很多公司在面試的時候,提及ConcurrentHashMap時,有時候還是會問到1.7的底層實現,因此,學有餘力的小夥伴,私下裡把JDK1.7的底層原始碼也拿過來讀讀哈(build哥本地沒有安裝JDK1.7,就不貼原始碼解析了)。

JDK1.8中ConcurrentHashMap拋棄了原有的 Segment 分段鎖,採用了 CAS + synchronized 來保證併發安全性,底層結構採用Node陣列+連結串列/紅黑樹,當連結串列長度達到一定長度後,會轉為紅黑樹,這和HashMap一樣。

【PUT原始碼解析】

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) {
    // key 和 value 不能為空
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        // f = 目標位置元素
        Node<K,V> f; int n, i, fh;// fh 後面存放目標位置的元素 hash 值
        if (tab == null || (n = tab.length) == 0)
            // 陣列桶為空,初始化陣列桶(自旋+CAS)
            tab = initTable();
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            // 桶內為空,CAS 放入,不加鎖,成功了就直接 break 跳出
            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 加鎖加入節點
            synchronized (f) {
                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;
                            }
                        }
                    }
                    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;
                        }
                    }
                }
            }
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    addCount(1L, binCount);
    return null;
}

原始碼有點長,大致做了如下幾點:

  • 先根據 key 計算出 hashcode;
  • 判斷陣列桶是否為空,若為空則透過tab = initTable(),初始化陣列桶(自旋+CAS);
  • 計算出key的陣列桶位置後,如果為空表示當前位置可以寫入資料,利用 CAS 嘗試寫入,失敗則自旋保證成功;
  • 如果當前位置的 “hashcode == MOVED == -1”,則需要進行擴容;
  • 如果都不滿足,則利用 synchronized 鎖寫入資料;
  • 如果數量大於 TREEIFY_THRESHOLD 則要執行樹化方法,在 treeifyBin 中會首先判斷當前陣列長度 ≥64 時才會將連結串列轉換為紅黑樹。

【原始碼擴充套件1】
上面put的時候,若Node陣列桶為空時,需要進行初始化,那麼我們跟入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) {
        // 如果 sizeCtl < 0 ,說明另外的執行緒執行CAS 成功,正在進行初始化。
        if ((sc = sizeCtl) < 0)
            // 讓出 CPU 使用權
            Thread.yield(); // lost initialization race; just spin
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
            try {
                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;
}

從原始碼中我們可以看到,它的初始化是透過CAS和自旋完成的,注意其中的sizeCtl私有成員變數,當它的值小於0(準確來說等於-1)時,說明另外的執行緒執行CAS 成功,正在進行初始化。透過Thread.yield()做執行緒讓步動作,讓出CPU的使用權,自旋等待,隨著獲得資源,進入CAS。

知識點補充

CAS(compare and swap) 譯為:比較與交換

// 如果在這個位置(address) 的值等於 這個值(expectedValue),那麼交換(newValue)。
boolean CAS(address,expectedValue,newValue) {
	if(address 的 value == expectedValue) {
		address 的 value = newValue;
		return true;
	}
}

自旋: 所謂的自旋,旨線上程搶鎖失敗後進入阻塞狀態,放棄 CPU,需要過很久才能再次被排程。但經過測算,大部分情況下,雖然當前搶鎖失敗,但過不了很久,鎖就會被釋放。因此,當某個執行緒搶佔 CPU 失敗後,保持就緒狀態,一旦鎖釋放,就會繼續搶佔。
以上這2點內容,在後面的併發多執行緒中會著重學習,在這裡淺淺點名,讓大家明白他們的意思和作用即可。

【原始碼擴充套件2】
當連結串列的長度大於8時,會轉為紅黑樹,而紅黑樹的實現,是透過底層的TreeBin,我們跟進去看一下。

static final class TreeBin<K,V> extends Node<K,V> {
        TreeNode<K,V> root;
        volatile TreeNode<K,V> first;
        volatile Thread waiter;
        volatile int lockState;
        // values for lockState
        static final int WRITER = 1; // set while holding write lock
        static final int WAITER = 2; // set when waiting for write lock
        static final int READER = 4; // increment value for setting read lock
...
}

TreeBin透過root屬性維護紅黑樹的根結點,因為紅黑樹在旋轉的時候,根結點可能會被它原來的子節點替換掉,在這個時間點,如果有其他執行緒要寫這棵紅黑樹就會發生執行緒不安全問題,所以在 ConcurrentHashMap 中TreeBin透過waiter屬性維護當前使用這棵紅黑樹的執行緒,來防止其他執行緒的進入。

【Get原始碼解析】
與put相比,get的原始碼就簡單太多了,大概進行了如下幾步操作:
1,根據計算出來的 hash 值定址,如果在桶上直接返回值;
2,如果是紅黑樹,按照樹的方式獲取值;
3,如果是連結串列,按連結串列的方式遍歷獲取值;

public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    // key 所在的 hash 位置
    int h = spread(key.hashCode());
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
        // 如果指定位置元素存在,頭結點hash值相同
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                // key hash 值相等,key值相同,直接返回元素 value
                return e.val;
        }
        else if (eh < 0)
            // 頭結點hash值小於0,說明正在擴容或者是紅黑樹,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;
}

總結

文章寫到這裡,ConcurrentHashMap的介紹基本講完了,我們現在來自我總結一下為啥它的效率又高,又能保證執行緒安全。
以JDK1.8版本闡述:

  1. Node + CAS + synchronized 保證併發安全,每次上鎖的顆粒度細到連結串列或紅黑樹的根節點,不會影響其他Node的讀寫,此外CAS是輕量級的,synchronized 也經過了鎖升級;
  2. JDK1.7的版本里採用的Segment 分段鎖,顆粒度粗不說,Segment 的個數一旦初始化就不能改變。 Segment 陣列的大小預設是 16,也就是說預設可以同時支援 16 個執行緒併發寫。而1.8的版本中,Node是一個陣列,初始預設為16,後續仍然可以以2的冪次方級別進行擴容,因此,它所支援的併發量要看它陣列的真實容量;
  3. 效率高是因為它底層採用了和JDK1.8中HashMap相同的陣列+連結串列/紅黑樹結構。

結尾彩蛋

如果本篇部落格對您有一定的幫助,大家記得留言+點贊+收藏呀。原創不易,轉載請聯絡Build哥!

如果您想與Build哥的關係更近一步,還可以關注俺滴公眾號“JavaBuild888”,在這裡除了看到《Java成長計劃》系列博文,還有提升工作效率的小筆記、讀書心得、大廠面經、人生感悟等等,歡迎您的加入!

相關文章