文章目錄
- 一、HashMap 的“不安全”:問題的根源
- 1. 數據結構回顧 (JDK 1.8)
- 2. 并發下的致命缺陷:`put` 操作
- 二、ConcurrentHashMap 的安全之道 (JDK 1.8+)
- 1. 核心數據結構
- 2. 安全的 `put` 操作:分場景精細化加鎖
- 3. 安全的 `size()` 計算:并發計數
- 三、表格總結
在 Java 面試和日常開發中,
HashMap
和
ConcurrentHashMap
(以下簡稱 CHM) 是我們繞不開的兩個核心類。我們都知道它們的關鍵區別是“線程安全”:
HashMap
是非線程安全的,而 CHM 是線程安全的。
那么,這種“安全”是如何實現的?CHM 到底比 HashMap
高明在哪里?為什么 JDK 1.8 的 CHM 性能遠超早期版本?
本文將帶你深入 JDK 1.8+ 的源碼,徹底搞懂 CHM 并發安全的底層奧秘。
一、HashMap 的“不安全”:問題的根源
在分析 CHM 的精妙設計之前,我們必須先理解 HashMap
在并發環境下為什么會“翻車”。
1. 數據結構回顧 (JDK 1.8)
HashMap
的底層是 “數組 + 鏈表 / 紅黑樹”。
// HashMap.java
transient Node<K,V>[] table;
table
是一個 Node
數組。當多個元素的 hash
值沖突時,它們會以鏈表的形式存放在同一個數組索引(桶)下;當鏈表長度超過 TREEIFY_THRESHOLD
(默認為8) 且數組長度大于 64 時,鏈表會轉化為紅黑樹以提高查詢效率。
2. 并發下的致命缺陷:put
操作
HashMap
的所有操作都沒有任何同步措施。我們以 putVal
方法為例,看看在并發下會發生什么。
// HashMap.java -> putVal() 核心邏輯簡化
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {Node<K,V>[] tab; Node<K,V> p; int n, i;// 1. 檢查 table 是否為空,如果為空則初始化 (resize)if ((tab = table) == null || (n = tab.length) == 0)n = (tab = resize()).length;// 2. 計算索引 i,檢查該位置是否為空if ((p = tab[i = (n - 1) & hash]) == null)// 3. 如果為空,直接創建一個新節點放在這里tab[i] = newNode(hash, key, value, null);else {// ... 省略桶內已有元素時的處理邏輯}// ...return null;
}
經典的“數據覆蓋”競爭條件 (Race Condition):
假設兩個線程 T1 和 T2 同時向一個空桶 tab[i]
中 put
數據。
- T1 執行到第 2 步,
p = tab[i]
,發現p
是null
。 - 此時發生線程切換,T2 開始執行。
- T2 也執行到第 2 步,
p = tab[i]
,發現p
同樣是null
。 - T2 繼續執行第 3 步,成功將自己的新節點放入
tab[i]
。 - 線程切換回 T1。由于 T1 之前已經判斷過
p
是null
,它不會再檢查一次,而是直接執行第 3 步,將自己的新節點放入tab[i]
。 - 結果:T1 的數據覆蓋了 T2 的數據,導致 T2 的寫入操作丟失。
除了數據覆蓋,并發下的 resize()
操作在 JDK 1.7 中甚至會因頭插法導致鏈表形成環形結構,造成 get
操作時死循環。雖然 JDK 1.8 改為尾插法修復了此問題,但數據丟失等并發問題依然存在。
結論:HashMap
的不安全,源于其所有操作都是“非原子性”的,且缺乏內存可見性保障。
二、ConcurrentHashMap 的安全之道 (JDK 1.8+)
JDK 1.8 對 CHM 進行了革命性的重構,摒棄了 JDK 1.7 的分段鎖(Segment
)機制,采用了更為精細的 CAS
+ synchronized
+ volatile
方案,實現了更高的并發性能。
1. 核心數據結構
// ConcurrentHashMap.java
transient volatile Node<K,V>[] table;// Node 定義 (部分)
static class Node<K,V> implements Map.Entry<K,V> {final int hash;final K key;volatile V val; // value 是 volatile 的volatile Node<K,V> next; // next 指針是 volatile 的
}
關鍵點:
table
數組被volatile
修飾:保證其可見性,當一個線程對table
進行擴容或修改后,其他線程能立刻看到。Node
的val
和next
指針被volatile
修飾:保證了節點值或鏈表結構的修改對其他線程的可見性。這是無鎖讀(get
操作)的關鍵基礎。
2. 安全的 put
操作:分場景精細化加鎖
CHM 的 put
方法是其并發設計的精髓所在。它通過 CAS
和 synchronized
的配合,將鎖的粒度降到了最低。
我們來分析其核心方法 putVal
的源碼:
// ConcurrentHashMap.java -> putVal() 核心邏輯簡化
final V putVal(K key, V value, boolean onlyIfAbsent) {// ... key/value 不允許為 nullint hash = spread(key.hashCode());int binCount = 0;// (1) 無限循環,直到成功插入或找到已有keyfor (Node<K,V>[] tab = table;;) {Node<K,V> f; int n, i, fh;// (2) 場景一:table 未初始化,CAS 初始化if (tab == null || (n = tab.length) == 0)tab = initTable(); // CAS操作,只有一個線程能成功// (3) 場景二:目標桶為空,CAS 插入節點 (無鎖)else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))break; // CAS 成功,直接跳出循環}// (4) 場景三:檢測到正在擴容else if ((fh = f.hash) == MOVED)tab = helpTransfer(tab, f); // 幫助其他線程一起擴容// (5) 場景四:目標桶有值,鎖住頭節點else {V oldVal = null;synchronized (f) { // f 是桶的頭節點if (tabAt(tab, i) == f) { // 再次確認頭節點沒變// 在同步塊內,遍歷鏈表或紅黑樹if (fh >= 0) {// ... 鏈表遍歷邏輯 ...} else if (f instanceof TreeBin) {// ... 紅黑樹處理邏輯 ...}}}// ...break;}}addCount(1L, binCount); // 更新 sizereturn null;
}
剖析其安全機制:
-
無鎖讀(
get
操作):得益于table
、Node.val
、Node.next
都是volatile
的,get
操作不需要加任何鎖。volatile
保證了讀線程總能看到其他寫線程對數據的最新修改,從而實現了高效的無鎖讀取。 -
put
操作的安全性分析:- 初始化安全 (場景二):
initTable()
方法內部使用CAS
操作來設置sizeCtl
狀態位,保證了在多線程同時初始化時,只有一個線程能成功執行,其他線程則會yield
等待。 - 空桶插入安全 (場景三):當目標桶為空時,CHM 并未直接加鎖,而是樂觀地嘗試使用
CAS
(casTabAt
) 操作將新節點放入。casTabAt
是一個原子操作,它會比較tab[i]
的內存值是否為null
,如果是,就將其更新為新節點。- 如果
CAS
成功,說明沒有競爭,操作完成。 - 如果失敗,說明在它操作的瞬間,有另一個線程捷足先登了。此時
for
循環會繼續,重新讀取tab[i]
的新值,進入下一個場景(通常是場景五)。
- 非空桶插入安全 (場景五):這是最關鍵的部分。當桶中已有節點時,不能再用
CAS
了(因為需要遍歷鏈表)。此時,CHM 采用synchronized
關鍵字,但它鎖住的不是整個table
,而是這個桶的頭節點f
。- 鎖粒度極小:只鎖住當前要操作的桶,其他桶的操作完全不受影響,并發度極高。
- 為什么不鎖
String
或Integer
:synchronized
鎖的是對象。如果鎖key
,當key
是常量池中的字符串時,可能會鎖住一個全局共享的對象,造成意想不到的死鎖。鎖住桶的頭節點Node
對象是最安全和高效的選擇。
- 擴容安全 (場景四):當一個線程在
put
時發現桶的頭節點hash
值為MOVED
,表示table
正在擴容。此時它不會等待,而是調用helpTransfer
加入到擴容大軍中,幫助移動數據,充分利用了 CPU 資源,實現并發擴容。
- 初始化安全 (場景二):
3. 安全的 size()
計算:并發計數
HashMap
的 size
只是一個簡單的成員變量 int size
。多線程下 ++size
是非原子操作,會導致計數不準。
ConcurrentHashMap
的 size()
實現非常巧妙,它避免了全局鎖,以分散計數的方式實現。
baseCount
:一個基礎計數值,在沒有并發競爭時,優先通過CAS
更新這個值。CounterCell[]
:一個計數器單元數組。當CAS
更新baseCount
失敗(說明存在競爭)時,線程會找到CounterCell
數組中的一個槽位,對這個槽位里的計數值進行更新。size()
計算:最終的大小是baseCount
加上所有CounterCell
數組中計數的總和。
這種條帶化計數(Striped Counter) 的思想,將對單一 size
變量的競爭壓力分散到多個 CounterCell
上,極大地提高了高并發下的計數性能。
三、表格總結
特性 | HashMap (JDK 1.8+) | ConcurrentHashMap (JDK 1.8+) |
---|---|---|
線程安全 | 否 | 是 |
核心數據結構 | Node[] table | volatile Node<K,V>[] table |
加鎖機制 | 無鎖 | CAS + synchronized (鎖桶頭節點) |
put 操作 | 非原子性,并發下有數據覆蓋風險 | 1. 空桶:CAS 無鎖插入 2. 非空桶: synchronized 鎖住桶頭節點 |
get 操作 | 非線程安全 | 無鎖,通過 volatile 保證可見性 |
size() 計算 | int size 成員變量,非線程安全 | baseCount + CounterCell[] ,并發安全高效 |
擴容機制 | 單線程擴容,并發下危險 | 并發擴容,多線程可協同完成 |
Key/Value | 可為 null | 均不可為 null (避免二義性) |
性能 | 單線程下極高 | 高并發下性能優異,單線程下因 volatile 和 CAS 略遜于HashMap |
結論:
- 用
volatile
作為基石,保證了多線程間的內存可見性,是無鎖讀和后續操作的基礎。 - 用
CAS
作為先鋒,處理無競爭或低競爭場景(如初始化、空桶插入),避免了不必要的加鎖開銷。 - 用
synchronized
鎖住桶頭節點作為主力,在必須同步的場景下,將鎖的粒度降到最低,實現了極高的并發度。 - 用并發擴容和并發計數等輔助策略,將并發思想貫徹到每一個細節,榨干硬件性能。