早年淘寶工程師曝光一個經典案例:單線程應用為提升性能改造為多線程後,上線發現 CPU 頻繁飆升 100%。排查發現罪魁禍首是 HashMap 在併發擴容時導致的死循環。這不僅是線程安全問題,更揭示了 HashMap 內部 rehash() 機制在併發下的致命缺陷。
許多開發者誤以為使用 Collections.synchronizedList() 或 ConcurrentHashMap 就高枕無憂。實則不然,複合操作的原子性往往被忽視:
// 典型隱患代碼:檢查-然後獲取(Check-Then-Act)
if (list.size() > 0) { // 線程 A 執行此處
return list.get(0); // 線程 B 可能在此刻刪除了唯一元素 → IndexOutOfBoundsException
}
即使底層容器線程安全,業務邏輯的複合操作若無額外同步,依然會出現競態條件。
作者曾因郵件收發性能問題大膽引入多線程,卻因任務劃分邏輯缺陷導致重複發送、狀態不一致。核心教訓:多線程不僅是技術疊加,更是業務建模的重構。必須重新審視:共享狀態、臨界區、任務邊界、失敗重試語義。
當 size > threshold(capacity × loadFactor) 時,HashMap 會調用 resize() 進行擴容:
newCap = oldCap << 1),JDK 8 後為 2 的冪次。(n - 1) & hash)並掛接到新數組。| 維度 | 代價分析 |
|---|---|
| 時間 | 需遍歷所有節點(O(n)),重新計算 hash、分配新節點/樹節點、修正指針。大 Map 擴容可達數十毫秒甚至更久,阻塞主線程。 |
| 空間 | 舊數組與新數組並存期間,內存佔用翻倍。若負載因子 0.75,擴容瞬間內存峰值約為穩態 2.67 倍。 |
| GC 壓力 | 大量臨時對象(舊節點、舊數組)觸發 Young GC,甚至 Full GC。 |
JDK 7 使用頭插法遷移鏈表:
// 簡化版遷移邏輯
Entry next = e.next;
e.next = newTable[index]; // 將 e 插入新桶頭部
newTable[index] = e;
e = next;
併發場景:線程 A、B 同時擴容,對同一鏈表操作。假設鏈表 1 → 2 → null:
e=1, next=2,時間片耗盡。2 → 1 → null。1.next = 2(但 2 已指向 1),形成 環狀鏈表:1 ↔ 2。get() 遍歷陷入無限循環 → CPU 100%。JDK 8 改為尾插法保持原序,避免了環狀鏈表,但併發下仍會導致數據丟失、鏈表斷裂,本質上HashMap 非線程安全。
CAS + synchronized (節點首部) + 紅黑樹,細粒度鎖,性能大幅提升,size() 使用 LongAdder。| 類別 | 特點 | 適用場景 |
|---|---|---|
Collections.synchronizedMap(new HashMap()) | 全鎖,性能差,複合操作需額外同步 | 低併發、臨時兼容 |
Hashtable | 全鎖,過時,不支援 null key/value | 避免使用 |
ConcurrentSkipListMap | 跳表實現,有序,併發性能優 | 需要有序遍歷的併發場景 |
computeIfAbsent、putIfAbsent 等原子方法優於手動同步。new ConcurrentHashMap<>(expectedSize) 減少擴容頻率。forEach、reduce 等聚合操作,或加鎖遍歷。HashMap 死循環問題本質上是非線程安全數據結構在併發下的行為未定義。Sun/Oracle 明確表示:HashMap 不支援併發,請用 ConcurrentHashMap。這提醒我們:
「線程安全的容器並不真正線程安全,會用 ConcurrentHashMap 只是進入初級階段。」—— 這句話依然振聾發聵。