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