1. 程式人生 > >每天都在用 Map,這些核心技術你知道嗎?

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

本篇文章站在多執行緒併發安全形度,帶你瞭解多執行緒併發使用 `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