數據結構算法 - ConcurrentHashMap 源碼解析

  • 五個線程同時往 HashMap 中 put 數據會發生什麼?
  • ConcurrentHashMap 是怎麼保證線程安全的?

在分析 HashMap 源碼時還遺留這兩個問題,這次我們站在 Java 多線程內存模型和 synchronized 的實現原理,這兩個角度來徹底分析一下。至於 JDK 1.8 的紅色樹不是本文探討的內容,如果感興趣可以看看之前的文章。

1. Java 多線程內存模型

五個線程同時往 HashMap 中 put 數據會出現兩種現象,大概率會出現數據丟失,小概率會出現死循環,我們不妨寫個測試代碼自己驗證一下。那爲什麼會出現這兩種現象,我們先來回顧一下之前的Java 多線程內存模型。請看圖:
圖片來源於網絡

Java內存模型中規定了所有的變量都存儲在主內存中,每條線程還有自己的工作內存,線程的工作內存中保存了該線程使用到的變量到主內存副本拷貝,線程對變量的所有操作(讀取、賦值)都必須在工作內存中進行,而不能直接讀寫主內存中的變量。不同線程之間無法直接訪問對方工作內存中的變量,線程間變量值的傳遞均需要在主內存來完成,線程、主內存和工作內存的交互關係如上圖所示。

現在我們來想象一下,假設線程 1 把數據讀到了自己的工作內存中,在 tab 角標爲 1 的鏈表頭插入了一條新的數據,倘若這時還沒來得及將新增的數據刷新到主內中。接着線程 2 就把數據讀到了自己的工作內存中,在 tab 角標爲 1 的鏈表頭插入了一條新的數據。接着線程 1 把新增數據刷新到主內存中,線程 2 也把數據新增數據刷新到主內存中,那麼線程 2 就會覆蓋線程 1 的新增數據,從而導致數據丟失的情況。這裏需要注意的是,只有兩個線程都是操作 tab 的同一個 index 鏈表纔會導致數據丟失的情況,如果不是同一個 index 鏈表就不會有覆蓋和丟失這一說。

2. synchronized 的底層實現原理

關於 HashMap 的線程不安全問題,Java 給我們提供了三種方案,第一種是 HashTable ,第二種是 Collections.synchronizedMap() ,第三種是 ConcurrentHashMap 。而第一種和第二種都是通過用 synchronized 同步方法來保證線程安全,性能上有所欠缺不推薦大家使用。ConcurrentHashMap 在 JDK 1.8 之前採用的是 Segment 分段鎖來實現的,而 JDK 1.8 之後則採用 synchronized 和 CAS 來實現。

HashTable 通過鎖住整個 put 和 get 方法來實現線程安全並不是很合理,因爲一個線程在 put 的時候,另外一個線程不能再 put 和 get 必須進入等待狀態。同理一個線程在 get 的時候,另外一個線程也不能再 get 和 put 。上面通過分析只有兩個線程都是操作 tab 的同一個 index 鏈表纔會導致數據丟失的情況,如果不是同一個 index 鏈表就不會有覆蓋和丟失這一說。因此也沒必要鎖住整個方法,只需要鎖住每個 tab 的 index 鏈即可。

ConcurrentHashMap 在 JDK 1.8 之前採用的是 Segment 繼承自 ReentrantLock 來鎖住 tab 的 index 鏈,而 JDK 1.8 之後則採用 synchronized 來實現,這兩者又有什麼區別?我們首先看下 synchronized 的底層是怎麼實現線程安全的。Java中的每一個對象都可以作爲鎖。具體表現有以下3種形式。

// 1.對於普通同步方法,鎖是當前實例對象。this
public synchronized void method(){
   
}

// 2.對於靜態同步方法,鎖是當前類的Class對象。this.class
public static synchronized void method(){
   
}

// 3.對於同步方法塊,鎖是Synchonized括號裏配置的對象。object
public static synchronized void method(){
  synchronized(object){
    
  }
}

我們可能會想鎖到底存在哪裏呢?鎖裏面會存儲什麼信息呢?其實 synchronized 同步的代碼塊,虛擬機在同步代碼塊開始前會插入一條 monitorenter 指令,在代碼塊的末尾會插入一條 monitorexit 指令。而每個對象的 Mark Word 頭信息裏都會存儲 Monitor 信息,也就是當前對象的鎖信息,當然 Mark Word 頭信息還包含對象的 hashCode 和 GC 的分代年齡,具體請看下錶:

鎖狀態 25 bit 4 bit 1 bit 是否是偏向鎖 x bit 鎖標緻位
0 對象的 hashCode GC 分代年齡 0 0

當線程 1 進入同步代碼塊遇到 monitorenter 指令,首先判斷鎖的狀態發現是 0 ,採用 CAS 將鎖的狀態設置爲 1,偏向鎖設置爲 1,鎖的標緻位設置爲 1 ,繼續執行同步代碼塊裏面的指令。這是若線程 2 也來到了同步代碼塊,也會遇到 monitorenter 指令,首先判斷鎖的狀態發現是 1 進入等待中,等線程 1 執行完同步代碼塊遇到 monitorenter 指令,首先會清空鎖的狀態然後喚醒線程 2 。如此反覆即可保證線程安全。

Lock 的實現原理和 synchronized 有些類似,都是通過線程的原子性來保證線程同步,具體的實現的方式大家可以去看下 ReentrantLock 的源碼實現。那爲什麼在 JDK 1.8 之後要採用 synchronized 和 CAS 來實現?在 JDK 1.6 爲了減少獲得鎖和釋放鎖帶來的性能消耗,引入了“偏向鎖”和“輕量級鎖”,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態,這幾個狀態會隨着競爭情況逐漸升級。鎖可以升級但不能降級,意味着偏向鎖升級成輕量級鎖後不能降級成偏向鎖。這種鎖升級卻不能降級的策略,目的是爲了提高獲得鎖和釋放鎖的效率。

偏向鎖

大多數情況下,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,爲了讓線程獲得鎖的代價更低而引入了偏向鎖。當一個線程訪問同步塊並獲取鎖時,會在對象頭和棧幀中的鎖記錄裏存儲鎖偏向的線程 ID,以後該線程在進入和退出同步塊時不需要進行 CAS 操作來加鎖和解鎖,只需簡單地測試一下對象頭的 Mark Word 裏是否存儲着指向當前線程的偏向鎖。如果測試成功,表示線程已經獲得了鎖。如果測試失敗,則需要再測試一下 Mark Word 中偏向鎖的標識是否設置成1(表示當前是偏向鎖):如果沒有設置,則使用 CAS 競爭鎖;如果設置了,則嘗試使用CAS將對象頭的偏向鎖指向當前線程。

輕量級鎖

線程在執行同步塊之前,JVM 會先在當前線程的棧楨中創建用於存儲鎖記錄的空間,並將對象頭中的 Mark Word 複製到鎖記錄中。然後線程嘗試使用 CAS 將對象頭中的 Mark Word 替換爲指向鎖記錄的針。如果成功,當前線程獲得鎖,如果失敗,表示其他線程競爭鎖,當前線程便嘗試使用自旋來獲取鎖。

重量級鎖

輕量級鎖採用自旋的方式不斷的嘗試獲取鎖,如果長時間獲取不到鎖勢必會不斷消耗 CPU 的資源。所以當線程競爭比較激烈或者線程遲遲獲取不到鎖,就會升級爲重量級的鎖狀態,此時線程是阻塞的,且響應時間緩慢。

3. ConcurrentHashMap 源碼分析

// volatile 保證可見性
transient volatile Node<K,V>[] table;

// 新增元素的方法
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;
            // 如果 tab 爲空,初始化 tab
            if (tab == null || (n = tab.length) == 0){
                tab = initTable();
            }
            // 當前 tab 的 index 鏈表爲 null
            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;
                // 鎖住當前 tab 的 index 鏈表(分段鎖)
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) {
                            binCount = 1;
                            // ......

public V get(Object key) {
        Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
        int h = spread(key.hashCode());
        if ((tab = table) != null && (n = tab.length) > 0 &&
            // CAS 操作
            (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)
                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;
    }

最後值得一提的是 table 和 Node 對象中的 next 和 val 都是採用的 volatile 來修飾,關於 volatile 之前已有分析,這裏就不再反覆囉嗦。

視頻地址:https://pan.baidu.com/s/1Rl8z6XJ_gD8vkG_0ODwzvA
視頻密碼:g493

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