本篇文章站在多執行緒併發安全形度,帶你瞭解多執行緒併發使用 HashMap
將會引發的問題,深入學習 ConcurrentHashMap
,帶你徹底掌握這些核心技術。
全文摘要:
HashMap
核心技術ConcurrentHashMap
核心技術- 分段鎖實戰應用
博文地址:sourl.cn/r3RVY8
HashMap
HashMap
是我們經常會用到的集合類,JDK 1.7 之前底層使用了陣列加連結串列的組合結構,如下圖所示:
新新增的元素通過取模的方式,定位 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 使用率飆升。
網上詳細分析死鍊形成的過程比較多,這裡就不再詳細解釋,大家感興趣可以閱讀以下**@陳皓**的文章。
文章地址:coolshell.cn/articles/96…
JDK1.8 改進方案
JDK1.8 HashMap
底層結構進行徹底重構,使用陣列加連結串列/紅黑樹方式這種組合結構。
新元素依舊通過取模方式獲取 Table
陣列位置,然後再將元素加入連結串列尾部。一旦連結串列元素數量超過 8 之後,自動轉為紅黑樹,進一步提高了查詢效率。
面試題:為什麼這裡使用紅黑樹?而不是其他二叉樹呢?
由於 JDK1.8 連結串列採用『尾插入』法,從而避免併發擴容情況下連結串列形成死鏈的可能。
那麼 HashMap
在 JDK1.8 版本就是併發安全的嗎?
其實並沒有,多執行緒併發的情況,HashMap
可能導致丟失資料。
下面是一段 JDK1.8 測試程式碼:
在我的電腦上輸出如下,資料發生了丟失:
從原始碼出發,併發過程資料丟失的原因有以下幾點:
併發賦值時被覆蓋
併發的情況下,一個執行緒的賦值可能被另一個執行緒覆蓋,這就導致物件的丟失。
size 計算問題
每次元素增加完成之後,size
將會加 1。這裡採用 ++i
方法,天然的併發不安全。
物件丟失的問題原因可能還有很多,這裡只是列舉兩個比較的明顯的問題。
當然 JDK1.7 中也是存在資料丟失的問題,問題原因也比較相似。
一旦發生死鏈的問題,機器 CPU 飆升,通過系統監控,我們可以很容易發現。
但是資料丟失的問題就不容易被發現。因為資料丟失環節往往非常長,往往需要系統執行一段時間才可能出現,而且這種情況下又不會形成髒資料。只有出現一些詭異的情況,我們才可能去排查,而且這種問題排查起來也比較困難。
SynchronizedMap
對於併發的情況,我們可以使用 JDK 提供 SynchronizedMap
保證安全。
SynchronizedMap
是一個內部類,只能通過以下方式建立例項。
Map m = Collections.synchronizedMap(new HashMap(...));
複製程式碼
SynchronizedMap
原始碼如下:
每個方法內將會使用 synchronized
關鍵字加鎖,從而保證併發安全。
由於多執行緒共享同一把鎖,導致同一時間只允許一個執行緒讀寫操作,其他執行緒必須等待,極大降低的效能。
並且大多數業務場景都是讀多寫少,多執行緒讀操作本身並不衝突,SynchronizedMap
極大的限制讀的效能。
所以多執行緒併發場景我們很少使用 SynchronizedMap
。
ConcurrentHashMap
既然多執行緒共享一把鎖,導致效能下降。那麼設想一下我們是不是多搞幾把鎖,分流執行緒,減少鎖衝突,提高併發度。
ConcurrentHashMap
正是使用這種方法,不但保證併發過程資料安全,又保證一定的效率。
JDK1.7
JDK1.7 ConcurrentHashMap
資料結構如下所示:
Segament
是一個ConcurrentHashMap
內部類,底層結構與 HashMap
一致。另外Segament
繼承自 ReentrantLock
,類圖如下:
當新元素加入 ConcurrentHashMap
時,首先根據 key hash 值找到相應的 Segament
。接著直接對 Segament
上鎖,若獲取成功,後續操作步驟如同 HashMap
。
由於鎖的存在,Segament
內部操作都是併發安全,同時由於其他 Segament
未被佔用,因此可以支援 concurrencyLevel 個執行緒安全的併發讀寫。
size 統計問題
雖然 ConcurrentHashMap
引入分段鎖解決多執行緒併發的問題,但是同時引入新的複雜度,導致計算 ConcurrentHashMap
元素數量將會變得複雜。
由於 ConcurrentHashMap
元素實際分佈在 Segament
中,為了統計實際數量,只能遍歷 Segament
陣列求和。
為了資料的準確性,這個過程過我們需要鎖住所有的 Segament
,計算結束之後,再依次解鎖。不過這樣做,將會導致寫操作被阻塞,一定程度降低 ConcurrentHashMap
效能。
所以這裡對 ConcurrentHashMap#size
統計方法進行一定的優化。
Segment
每次被修改(寫入,刪除),都會對 modCount
(更新次數)加 1。只要相鄰兩次計算獲取所有的 Segment
modCount
總和一致,則代表兩次計算過程並無寫入或刪除,可以直接返回統計數量。
如果三次計算結果都不一致,那沒辦法只能對所有 Segment
加鎖,重新計算結果。
這裡需要注意的是,這裡求得 size 數量不能做到 100% 準確。這是因為最後依次對 Segment
解鎖後,可能會有其他執行緒進入寫入操作。這樣就導致返回時的數量與實際數不一致。
不過這也能被接受,總不能因為為了統計元素停止所有元素的寫入操作。
效能問題
想象一種極端情況的,所有寫入都落在同一個 Segment
中,這就導致ConcurrentHashMap
退化成 SynchronizedMap
,共同搶一把鎖。
JDK1.8 改進方案
JDK1.8 之後,ConcurrentHashMap
取消了分段鎖的設計,進一步減鎖衝突的發生。另外也引入紅黑樹的結構,進一步提高查詢效率。
資料結構如下所示:
Table
陣列的中每一個 Node
我們都可以看做一把鎖,這就避免了 Segament
退化問題。
另外一旦 ConcurrentHashMap
擴容, Table
陣列元素變多,鎖的數量也會變多,併發度也會提高。
寫入元素原始碼比較複雜,這裡可以參考下面流程圖。
總的來說,JDK1.8 使用 CAS 方法加 synchronized
方式,保證併發安全。
size 方法優化
JDK1.8 ConcurrentHashMap#size
統計方法還是比較簡單的:
這個方法我們需要知道兩個重要變數:
baseCount
CounterCell[] counterCells
baseCount
記錄元素數量的,每次元素元素變更之後,將會使用 CAS
方式更新該值。
如果多個執行緒併發增加新元素,baseCount
更新衝突,將會啟用 CounterCell
,通過使用 CAS
方式將總數更新到 counterCells
陣列對應的位置,減少競爭。
如果 CAS
更新 counterCells
陣列某個位置出現多次失敗,這表明多個執行緒在使用這個位置。此時將會通過擴容 counterCells
方式,再次減少衝突。
通過上面的努力,統計元素總數就變得非常簡單,只要計算 baseCount
與 counterCells
總和,整個過程都不需要加鎖。
仔細回味一下,counterCells
也是通過類似分段鎖思想,減少多執行緒競爭。
分段鎖實戰應用
ConcurrentHashMap
通過使用分段鎖的設計方式,降低鎖的粒度,提高併發度。我們可以借鑑這種設計,解決某些熱點資料更新問題。
舉個例子,假如現在我們有一個支付系統,使用者每次支付成功,商家的賬戶餘額就會相應的增加。
當大促的時候,非常多使用者同時支付,同一個商家賬戶餘額會被併發更新。
資料庫層面為了保證資料安全,每次更新時將會使用行鎖。同時併發更新的情況,只有一個執行緒才能獲取鎖,更新資料,其他執行緒只能等待鎖釋放。這就很有可能導致其他執行緒餘額更新操作耗時過長,甚至事務超時,餘額更新失敗的。
這就是一個典型的熱點資料更新問題。
這個問題實際原因是因為多執行緒併發搶奪行鎖導致,那如果有多把行鎖,是不是就可以降低鎖衝突了那?
沒錯,這裡我們借鑑 ConcurrentHashMap
分段鎖的設計,在商家的賬戶的下建立多個影子賬戶。
然後每次更新餘額,隨機選擇某個影子賬戶進行相應的更新。
理論上影子賬戶可以建立無數個,這就代表我們可以無限提高併發的能力。
這裡感謝**@why** 神提出影子賬戶的概念,大家感興趣可以搜尋關注,公眾號: why技術
架構設計中引入新的方案,就代表會引入新的複雜度,我們一定要這些問題考慮清楚,綜合權衡設計。
引入影子賬戶雖然解決熱點資料的問題,但是商戶總餘額統計就變得很麻煩,我們必須統計所有子賬戶的餘額。
另外實際的業務場景,商家餘額不只是會增加,還有可能的進行相應的扣減。這就有可能產生商戶總餘額是足夠的,但是選中的影子賬戶的餘額卻不足。
這怎麼辦?這留給大家思考了。不知道各位讀者有沒有碰到這種類似的問題,歡迎留言討論。
大家感興趣的話,後面的文章我們可以詳細聊聊熱點賬戶的解決方案。
總結
HashMap
在多執行緒併發的過程中存在死鏈與丟失資料的可能,不適合用於多執行緒併發使用的場景的,我們可以在方法的區域性變數中使用。
SynchronizedMap
雖然執行緒安全,但是由於鎖粒度太大,導致效能太低,所以也不太適合在多執行緒使用。
ConcurrentHashMap
由於使用多把鎖,充分降低多執行緒併發競爭的概率,提高了併發度,非常適合在多執行緒中使用。
最後小黑哥再提一點,不要一提到多執行緒環境,就直接使用 ConcurrentHashMap
。如果僅僅使用 Map
當做全域性變數,而這個變數初始載入之後,從此資料不再變動的場景下。建議使用不變集合類 Collections#unmodifiableMap
,或者使用 Guava 的 ImmutableMap
。不變集合的好處在於,可以有效防止其他執行緒偷偷修改,從而引發一些業務問題。
ConcurrentHashMap
分段鎖的經典思想,我們可以應用在熱點更新的場景,提高更新效率。
不過一定要記得,當我們引入新方案解決問題時,必定會引入新的複雜度,導致其他問題。這個過程一定要先將這些問題想清楚,然後這中間做一定權衡。
參考資料
- 碼出高效 Java 開發手冊
- www.jasongj.com/java/concur…
最後說一句(求關注)
看到這裡,點個關注呀,點個讚唄。別下次一定啊,大哥。寫文章很辛苦的,需要來點正反饋。
才疏學淺,難免會有紕漏,如果你發現了錯誤的地方,還請你留言給我指出來,我對其加以修改。
感謝您的閱讀,我堅持原創,十分歡迎並感謝您的關注
歡迎關注我的公眾號:程式通事,獲得日常乾貨推送。如果您對我的專題內容感興趣,也可以關注我的部落格:studyidea.cn