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

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

全文摘要:

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

HashMap

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

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

新添加的元素通過取模的方式,定位 Table 數組位置,然後將元素加入鏈表頭部,這樣下次提取時就可以快速被訪問到。

訪問數據時,也是通過取模的方式,定位數組中的位置,然後再遍歷鏈表,依次比較,獲取相應的元素。

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

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

<code>// size:HashMap 中實際元素數量 
//capacity:HashMap 容量,即 Table 數組長度,默認為:16
//loadFactor:負載因子,默認為:0.75
size>=capacity*loadFactor/<code>

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

<code>void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry e : table) {
while(null != e) {
Entry
next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
\t// 以下代碼導致死鏈的產生
\te.next = newTable[i];
// 插入到鏈表頭結點,
newTable[i] = e;
e = next;
}
}
}
/<code>

舊數組元素遷移到新數組時,依舊採用『頭插入法』,這樣將會導致新鏈表元素的逆序排序。

多線程併發擴容的情況下,鏈表可能形成死鏈(環形鏈表)。一旦有任何查找元素的動作,線程將會陷入死循環,從而引發 CPU 使用率飆升。

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

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

文章地址:https://coolshell.cn/articles/9606.html

JDK1.8 改進方案

JDK1.8 HashMap 底層結構進行徹底重構,使用數組加鏈表/紅黑樹方式這種組合結構。

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

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

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

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

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

其實並沒有,多線程併發的情況,HashMap 可能導致丟失數據。

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

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

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

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

從源碼出發,併發過程數據丟失的原因有以下幾點:

併發賦值時被覆蓋

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

併發的情況下,一個線程的賦值可能被另一個線程覆蓋,這就導致對象的丟失。

size 計算問題

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

img

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

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

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

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

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

SynchronizedMap

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

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

<code>Map m = Collections.synchronizedMap(new HashMap(...));/<code>

SynchronizedMap 源碼如下:

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

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

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

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

所以多線程併發場景我們很少使用 SynchronizedMap 。

ConcurrentHashMap

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

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

JDK1.7

JDK1.7 ConcurrentHashMap 數據結構如下所示:

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

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 數組元素變多,鎖的數量也會變多,併發度也會提高。

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

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

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方式,再次減少衝突。

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

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

分段鎖實戰應用

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

熱點數據更新問題。

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

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

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

這就是一個典型的熱點數據更新問題。

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

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

然後每次更新餘額,隨機選擇某個

影子賬戶進行相應的更新。

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

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

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

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

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

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

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

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

總結

HashMap 在多線程併發的過程中存在死鏈與丟失數據的可能,不適合用於多線程併發使用的場景的,我們可以在方法的局部變量中使用。

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

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

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

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

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

  1. 碼出高效 Java 開發手冊
  2. http://www.jasongj.com/java/concurrenthashmap/

最後說一句(求關注)

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

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

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


分享到:


相關文章: