每天都在用 Map,這些核心技術你知道嗎?

樓下小黑哥發表於2020-03-17

本篇文章站在多執行緒併發安全形度,帶你瞭解多執行緒併發使用 HashMap 將會引發的問題,深入學習 ConcurrentHashMap ,帶你徹底掌握這些核心技術。

全文摘要:

  • HashMap 核心技術
  • ConcurrentHashMap 核心技術
  • 分段鎖實戰應用

博文地址:sourl.cn/r3RVY8

HashMap

HashMap 是我們經常會用到的集合類,JDK 1.7 之前底層使用了陣列加連結串列的組合結構,如下圖所示:

每天都在用 Map,這些核心技術你知道嗎?

新新增的元素通過取模的方式,定位 Table 陣列位置,然後將元素加入連結串列頭部,這樣下次提取時就可以快速被訪問到。

訪問資料時,也是通過取模的方式,定位陣列中的位置,然後再遍歷連結串列,依次比較,獲取相應的元素。

如果 HasMap 中元素過多時,可能導致某個位置上鍊表很長。原本 O(1) 查詢效能,可能就退化成 O(N),嚴重降低查詢效率。

為了避免這種情況,當 HasMap 元素數量滿足以下條件時,將會自動擴容,重新分配元素。

// size:HashMap 中實際元素數量 
//capacity:HashMap 容量,即 Table 陣列長度,預設為:16 
//loadFactor:負載因子,預設為:0.75
size>=capacity*loadFactor
複製程式碼

HasMap 將會把容量擴充為原來的兩倍,然後將原陣列元素遷移至新陣列。

void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
        while(null != e) {
            Entry<K,V> next = e.next;
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
           	// 以下程式碼導致死鏈的產生
          	e.next = newTable[i];
            // 插入到連結串列頭結點,
            newTable[i] = e;
            e = next;
        }
    }
}
複製程式碼

舊陣列元素遷移到新陣列時,依舊採用『頭插入法』,這樣將會導致新連結串列元素的逆序排序。

多執行緒併發擴容的情況下,連結串列可能形成死鏈(環形連結串列)。一旦有任何查詢元素的動作,執行緒將會陷入死迴圈,從而引發 CPU 使用率飆升。

每天都在用 Map,這些核心技術你知道嗎?

網上詳細分析死鍊形成的過程比較多,這裡就不再詳細解釋,大家感興趣可以閱讀以下**@陳皓**的文章。

文章地址:coolshell.cn/articles/96…

JDK1.8 改進方案

JDK1.8 HashMap 底層結構進行徹底重構,使用陣列加連結串列/紅黑樹方式這種組合結構。

每天都在用 Map,這些核心技術你知道嗎?

新元素依舊通過取模方式獲取 Table 陣列位置,然後再將元素加入連結串列尾部。一旦連結串列元素數量超過 8 之後,自動轉為紅黑樹,進一步提高了查詢效率。

面試題:為什麼這裡使用紅黑樹?而不是其他二叉樹呢?

由於 JDK1.8 連結串列採用『尾插入』法,從而避免併發擴容情況下連結串列形成死鏈的可能。

那麼 HashMap 在 JDK1.8 版本就是併發安全的嗎?

其實並沒有,多執行緒併發的情況,HashMap 可能導致丟失資料。

下面是一段 JDK1.8 測試程式碼:

每天都在用 Map,這些核心技術你知道嗎?

在我的電腦上輸出如下,資料發生了丟失:

每天都在用 Map,這些核心技術你知道嗎?

從原始碼出發,併發過程資料丟失的原因有以下幾點:

併發賦值時被覆蓋

每天都在用 Map,這些核心技術你知道嗎?

併發的情況下,一個執行緒的賦值可能被另一個執行緒覆蓋,這就導致物件的丟失。

size 計算問題

img

每次元素增加完成之後,size 將會加 1。這裡採用 ++i方法,天然的併發不安全。

物件丟失的問題原因可能還有很多,這裡只是列舉兩個比較的明顯的問題。

當然 JDK1.7 中也是存在資料丟失的問題,問題原因也比較相似。

一旦發生死鏈的問題,機器 CPU 飆升,通過系統監控,我們可以很容易發現。

但是資料丟失的問題就不容易被發現。因為資料丟失環節往往非常長,往往需要系統執行一段時間才可能出現,而且這種情況下又不會形成髒資料。只有出現一些詭異的情況,我們才可能去排查,而且這種問題排查起來也比較困難。

SynchronizedMap

對於併發的情況,我們可以使用 JDK 提供 SynchronizedMap 保證安全。

SynchronizedMap 是一個內部類,只能通過以下方式建立例項。

Map m = Collections.synchronizedMap(new HashMap(...));
複製程式碼

SynchronizedMap 原始碼如下:

每天都在用 Map,這些核心技術你知道嗎?

每個方法內將會使用 synchronized 關鍵字加鎖,從而保證併發安全。

由於多執行緒共享同一把鎖,導致同一時間只允許一個執行緒讀寫操作,其他執行緒必須等待,極大降低的效能。

並且大多數業務場景都是讀多寫少,多執行緒讀操作本身並不衝突,SynchronizedMap 極大的限制讀的效能。

所以多執行緒併發場景我們很少使用 SynchronizedMap

ConcurrentHashMap

既然多執行緒共享一把鎖,導致效能下降。那麼設想一下我們是不是多搞幾把鎖,分流執行緒,減少鎖衝突,提高併發度。

ConcurrentHashMap 正是使用這種方法,不但保證併發過程資料安全,又保證一定的效率。

JDK1.7

JDK1.7 ConcurrentHashMap 資料結構如下所示:

ConcurrentHashMap-1.7

Segament 是一個ConcurrentHashMap內部類,底層結構與 HashMap 一致。另外Segament 繼承自 ReentrantLock,類圖如下:

每天都在用 Map,這些核心技術你知道嗎?

當新元素加入 ConcurrentHashMap 時,首先根據 key hash 值找到相應的 Segament。接著直接對 Segament 上鎖,若獲取成功,後續操作步驟如同 HashMap

由於鎖的存在,Segament 內部操作都是併發安全,同時由於其他 Segament 未被佔用,因此可以支援 concurrencyLevel 個執行緒安全的併發讀寫。

size 統計問題

雖然 ConcurrentHashMap 引入分段鎖解決多執行緒併發的問題,但是同時引入新的複雜度,導致計算 ConcurrentHashMap 元素數量將會變得複雜。

由於 ConcurrentHashMap 元素實際分佈在 Segament 中,為了統計實際數量,只能遍歷 Segament陣列求和。

為了資料的準確性,這個過程過我們需要鎖住所有的 Segament,計算結束之後,再依次解鎖。不過這樣做,將會導致寫操作被阻塞,一定程度降低 ConcurrentHashMap效能。

所以這裡對 ConcurrentHashMap#size 統計方法進行一定的優化。

每天都在用 Map,這些核心技術你知道嗎?

Segment 每次被修改(寫入,刪除),都會對 modCount(更新次數)加 1。只要相鄰兩次計算獲取所有的 Segment modCount 總和一致,則代表兩次計算過程並無寫入或刪除,可以直接返回統計數量。

如果三次計算結果都不一致,那沒辦法只能對所有 Segment 加鎖,重新計算結果。

這裡需要注意的是,這裡求得 size 數量不能做到 100% 準確。這是因為最後依次對 Segment 解鎖後,可能會有其他執行緒進入寫入操作。這樣就導致返回時的數量與實際數不一致。

不過這也能被接受,總不能因為為了統計元素停止所有元素的寫入操作。

效能問題

想象一種極端情況的,所有寫入都落在同一個 Segment中,這就導致ConcurrentHashMap 退化成 SynchronizedMap,共同搶一把鎖。

每天都在用 Map,這些核心技術你知道嗎?

JDK1.8 改進方案

JDK1.8 之後,ConcurrentHashMap 取消了分段鎖的設計,進一步減鎖衝突的發生。另外也引入紅黑樹的結構,進一步提高查詢效率。

資料結構如下所示:

每天都在用 Map,這些核心技術你知道嗎?

Table 陣列的中每一個 Node 我們都可以看做一把鎖,這就避免了 Segament 退化問題。

另外一旦 ConcurrentHashMap 擴容, Table 陣列元素變多,鎖的數量也會變多,併發度也會提高。

寫入元素原始碼比較複雜,這裡可以參考下面流程圖。

concurrentHashMap-1.8 加入元素

總的來說,JDK1.8 使用 CAS 方法加 synchronized 方式,保證併發安全。

size 方法優化

JDK1.8 ConcurrentHashMap#size 統計方法還是比較簡單的:

每天都在用 Map,這些核心技術你知道嗎?
每天都在用 Map,這些核心技術你知道嗎?

這個方法我們需要知道兩個重要變數:

  • baseCount
  • CounterCell[] counterCells

baseCount 記錄元素數量的,每次元素元素變更之後,將會使用 CAS方式更新該值。

如果多個執行緒併發增加新元素,baseCount 更新衝突,將會啟用 CounterCell,通過使用 CAS 方式將總數更新到 counterCells 陣列對應的位置,減少競爭。

如果 CAS 更新 counterCells 陣列某個位置出現多次失敗,這表明多個執行緒在使用這個位置。此時將會通過擴容 counterCells方式,再次減少衝突。

通過上面的努力,統計元素總數就變得非常簡單,只要計算 baseCountcounterCells總和,整個過程都不需要加鎖。

仔細回味一下,counterCells 也是通過類似分段鎖思想,減少多執行緒競爭。

分段鎖實戰應用

ConcurrentHashMap 通過使用分段鎖的設計方式,降低鎖的粒度,提高併發度。我們可以借鑑這種設計,解決某些熱點資料更新問題。

舉個例子,假如現在我們有一個支付系統,使用者每次支付成功,商家的賬戶餘額就會相應的增加。

當大促的時候,非常多使用者同時支付,同一個商家賬戶餘額會被併發更新。

資料庫層面為了保證資料安全,每次更新時將會使用行鎖。同時併發更新的情況,只有一個執行緒才能獲取鎖,更新資料,其他執行緒只能等待鎖釋放。這就很有可能導致其他執行緒餘額更新操作耗時過長,甚至事務超時,餘額更新失敗的。

這就是一個典型的熱點資料更新問題。

這個問題實際原因是因為多執行緒併發搶奪行鎖導致,那如果有多把行鎖,是不是就可以降低鎖衝突了那?

沒錯,這裡我們借鑑 ConcurrentHashMap 分段鎖的設計,在商家的賬戶的下建立多個影子賬戶

然後每次更新餘額,隨機選擇某個影子賬戶進行相應的更新。

理論上影子賬戶可以建立無數個,這就代表我們可以無限提高併發的能力。

這裡感謝**@why** 神提出影子賬戶的概念,大家感興趣可以搜尋關注,公眾號: why技術

每天都在用 Map,這些核心技術你知道嗎?

架構設計中引入新的方案,就代表會引入新的複雜度,我們一定要這些問題考慮清楚,綜合權衡設計。

引入影子賬戶雖然解決熱點資料的問題,但是商戶總餘額統計就變得很麻煩,我們必須統計所有子賬戶的餘額。

另外實際的業務場景,商家餘額不只是會增加,還有可能的進行相應的扣減。這就有可能產生商戶總餘額是足夠的,但是選中的影子賬戶的餘額卻不足。

這怎麼辦?這留給大家思考了。不知道各位讀者有沒有碰到這種類似的問題,歡迎留言討論。

大家感興趣的話,後面的文章我們可以詳細聊聊熱點賬戶的解決方案。

總結

HashMap 在多執行緒併發的過程中存在死鏈與丟失資料的可能,不適合用於多執行緒併發使用的場景的,我們可以在方法的區域性變數中使用。

SynchronizedMap 雖然執行緒安全,但是由於鎖粒度太大,導致效能太低,所以也不太適合在多執行緒使用。

ConcurrentHashMap 由於使用多把鎖,充分降低多執行緒併發競爭的概率,提高了併發度,非常適合在多執行緒中使用。

最後小黑哥再提一點,不要一提到多執行緒環境,就直接使用 ConcurrentHashMap。如果僅僅使用 Map 當做全域性變數,而這個變數初始載入之後,從此資料不再變動的場景下。建議使用不變集合類 Collections#unmodifiableMap,或者使用 Guava 的 ImmutableMap。不變集合的好處在於,可以有效防止其他執行緒偷偷修改,從而引發一些業務問題。

ConcurrentHashMap 分段鎖的經典思想,我們可以應用在熱點更新的場景,提高更新效率。

不過一定要記得,當我們引入新方案解決問題時,必定會引入新的複雜度,導致其他問題。這個過程一定要先將這些問題想清楚,然後這中間做一定權衡。

參考資料

  1. 碼出高效 Java 開發手冊
  2. www.jasongj.com/java/concur…

最後說一句(求關注)

看到這裡,點個關注呀,點個讚唄。別下次一定啊,大哥。寫文章很辛苦的,需要來點正反饋。

才疏學淺,難免會有紕漏,如果你發現了錯誤的地方,還請你留言給我指出來,我對其加以修改。

感謝您的閱讀,我堅持原創,十分歡迎並感謝您的關注

每天都在用 Map,這些核心技術你知道嗎?

歡迎關注我的公眾號:程式通事,獲得日常乾貨推送。如果您對我的專題內容感興趣,也可以關注我的部落格:studyidea.cn

每天都在用 Map,這些核心技術你知道嗎?

相關文章