轉自:https://www.jianshu.com/p/78989cd553b4
一、Segment
HashMap 在高並發下會出現鏈表環,從而導致程序出現死循環。高並發下避免 HashMap 出問題的方法有兩種,一是使用 HashTable,二是使用 Collections.syncronizedMap。但是這兩種方法的性能都能差。因為這兩個在執行讀寫操作時都是將整個集合加鎖,導致多個線程無法同時讀寫集合。高並發下的 HashMap 出現的問題就需要 ConcurrentHashMap 來解決了。
【介紹JDK 1.7】ConcurrentHashMap 中有一個 Segment 的概念。Segment 本身就相當於一個 HashMap 對象。同 HashMap 一樣,Segment 包含一個 HashEntry 數組,數組中的每一個 HashEntry 既是一個鍵值對,也是一個鏈表的頭節點。單一的 Segment 結構如下:


可以說,ConcurrentHashMap 是一個二級哈希表。在一個總的哈希表下面,有若干個子哈希表。這樣的二級結構,和數據庫的水平拆分有些相似。
1️⃣ConcurrentHashMap 的優勢
采取了鎖分段技術,每一個 Segment 就好比一個自治區,讀寫操作高度自治,Segment 之間互不影響。
Case1:不同 Segment 的並發寫入【可以並發執行】

Case2:同一 Segment 的一寫一讀【可以並發執行】

Case3:同一 Segment 的並發寫入【需要上鎖】

由此可見,ConcurrentHashMap 當中每個 Segment 各自持有一把鎖。在保證線程安全的同時降低了鎖的粒度,讓並發操作效率更高。
2️⃣Concurrent 的讀寫過程
Get方法:
- 為輸入的 Key 做 Hash 運算,得到 hash 值。(為了實現Segment均勻分布,進行了兩次Hash)
- 通過 hash 值,定位到對應的 Segment 對象
- 再次通過 hash 值,定位到 Segment 當中數組的具體位置。
Put方法:
- 為輸入的 Key 做 Hash 運算,得到 hash 值。
- 通過 hash 值,定位到對應的 Segment 對象
- 獲取可重入鎖
- 再次通過 hash 值,定位到 Segment 當中數組的具體位置。
- 插入或覆蓋 HashEntry 對象。
- 釋放鎖。
從步驟可以看出,ConcurrentHashMap 在讀寫時均需要二次定位。首先定位到 Segment,之后定位到 Segment 內的具體數組下標。
二、每一個 Segment 都各自加鎖,那么在調用 size() 的時候,怎么解決一致性的問題
1️⃣調用 Size() 是統計 ConcurrentHashMap 的總元素數量,需要把各個 Segment 內部的元素數量匯總起來。但是,如果在統計 Segment 元素數量的過程中,已統計過的 Segment 瞬間插入新的元素,這時候該怎么辦呢?



2️⃣ConcurrentHashMap 的 Size() 是一個嵌套循環,大體邏輯如下:
- 遍歷所有的 Segment。
- 把 Segment 的元素數量累加起來。
- 把 Segment 的修改次數累加起來。
- 判斷所有 Segment 的總修改次數是否大於上一次的總修改次數。如果大於,說明統計過程中有修改,重新統計,嘗試次數+1;如果不是。說明沒有修改,統計結束。
- 如果嘗試次數超過閾值,則對每一個 Segment 加鎖,再重新統計。
6.再次判斷所有 Segment 的總修改次數是否大於上一次的總修改次數。由於已經加鎖,次數一定和上次相等。 - 釋放鎖,統計結束。
官方源代碼如下:


為什么這樣設計呢?這種思想和樂觀鎖悲觀鎖的思想如出一轍。為了盡量不鎖住所有的 Segment,首先樂觀地假設 Size 過程中不會有修改。當嘗試一定次數,才無奈轉為悲觀鎖,鎖住所有 Segment 保證強一致性。
三、ConcurrentHashMap(線程安全)
- 底層采用分段的數組+鏈表實現
- 通過把整個 Map 分為N個 Segment,可以提供相同的線程安全,但是效率提升N倍,默認提升16倍。(讀操作不加鎖,由於 HashEntry 的 value 變量是 volatile 的,也能保證讀取到最新的值。)
- Hashtable 的 synchronized 是針對整張 Hash 表的,即每次鎖住整張表讓線程獨占,ConcurrentHashMap 允許多個修改操作並發進行,其關鍵在於使用了鎖分離技術。
- 有些方法需要跨段,比如 size() 和 containsValue(),它們可能需要鎖定整個表而不僅僅是某個段,這需要按順序鎖定所有段,操作完畢后,又按順序釋放所有段的鎖。
- 擴容:段內擴容(段內元素超過該段對應 Entry 數組長度的75%觸發擴容,不會對整個 Map 進行擴容),插入前檢測是否需要擴容,避免無效擴容。

從類圖可看出在存儲結構中 ConcurrentHashMap 比 HashMap 多出了一個類 Segment,而 Segment 是一個可重入鎖。ConcurrentHashMap 是使用了鎖分段技術來保證線程安全的。
鎖分段技術:
首先將數據分成一段一段的存儲,然后給每一段數據配一把鎖,當一個線程占用鎖訪問其中一個段數據的時候,其他段的數據仍能被其他線程訪問。
ConcurrentHashMap 提供了與 Hashtable 和 SynchronizedMap 不同的鎖機制。Hashtable 中采用的鎖機制是一次鎖住整個 hash 表,從而在同一時刻只能由一個線程對其進行操作;而 ConcurrentHashMap 中則是一次鎖住一個桶。
ConcurrentHashMap 默認將 hash 表分為16個桶,諸如 get、put、remove 等常用操作只鎖住當前需要用到的桶。這樣,原來只能一個線程進入,現在卻能同時有16個寫線程執行,並發性能的提升是顯而易見的。
四、ConcurrentHashMap 1.7和1.8的區別
1️⃣整體結構
1.7:Segment + HashEntry + Unsafe
1.8: 移除 Segment,使鎖的粒度更小,Synchronized + CAS + Node + Unsafe
2️⃣put()
1.7:先定位 Segment,再定位桶,put 全程加鎖,沒有獲取鎖的線程提前找桶的位置,並最多自旋 64 次獲取鎖,超過則掛起。
1.8:由於移除了 Segment,類似 HashMap,可以直接定位到桶,拿到 first 節點后進行判斷:①為空則 CAS 插入;②為 -1 則說明在擴容,則跟着一起擴容;③ else 則加鎖 put(類似1.7)
3️⃣get()
基本類似,由於 value 聲明為 volatile,保證了修改的可見性,因此不需要加鎖。
4️⃣resize()
1.7:跟 HashMap 步驟一樣,只不過是搬到單線程中執行,避免了 HashMap 在 1.7 中擴容時死循環的問題,保證線程安全。
1.8:支持並發擴容,HashMap 擴容在1.8中由頭插改為尾插(為了避免死循環問題),ConcurrentHashmap 也是,遷移也是從尾部開始,擴容前在桶的頭部放置一個 hash 值為 -1 的節點,這樣別的線程訪問時就能判斷是否該桶已經被其他線程處理過了。
5️⃣size()
1.7:很經典的思路:計算兩次,如果不變則返回計算結果,若不一致,則鎖住所有的 Segment 求和。
1.8:用 baseCount 來存儲當前的節點個數,這就設計到 baseCount 並發環境下修改的問題。
作者:日常更新
鏈接:https://www.jianshu.com/p/78989cd553b4
來源:簡書
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。