首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> Java 程式設計 >> Java 相關知識

HashMap 多線程死循環根因深度剖析:從 Rehash 機制到 ConcurrentHashMap 選型指南

     編輯:Java 相關知識

淘寶工程師曾曝光:將單線程 HashMap 改為多線程環境後,CPU 飆升 100%,排查發現是 HashMap 並發 put 導致鏈表形成環狀結構,get 陷入死循環。本文從 JDK 源碼層面深度剖析 Rehash 擴容機制、多線程競態條件、死循環形成全過程,並給出生產環境選型建議。

一、 事件還原:單線程改多線程的災難

// 線程不安全的 HashMap
Map<String, Object> map = new HashMap<>();

// 多線程並發 put
new Thread(() -> map.put("key", "value")).start();
new Thread(() -> map.put("key2", "value2")).start();

// 後果:CPU 100%,get() 永不返回

許多開發者誤以為「懂 synchronized、volatile、JUC 就能寫對併發代碼」,實則線程安全極其反直覺,微小疏忽即釀成大禍。

二、 兩個經典併發陷阱側寫

陷阱 1:線程安全容器的複合操作非原子

// 看似安全的 CopyOnWriteArrayList / Collections.synchronizedList
if (list.size() > 0) {        // 線程 A 檢查 size=1
    return list.get(0);       // 線程 B 此時 remove(0) → IndexOutOfBoundsException
}

教訓:單個操作線程安全 ≠ 復合操作線程安全。需額外加鎖或使用原子變量。

陷阱 2:多線程發郵件的競態條件

「發送郵件」看似獨立,實則涉及 Session、Transport 共享狀態,並發發送導致郵件內容錯亂、發送失敗。場景建模錯誤 是併發 Bug 溫床。

三、 HashMap 死循環根因:Rehash 並發競態

1. Rehash (擴容) 是什麼?

  • 觸發條件:size > threshold (capacity * loadFactor)
  • 過程:new Table[newCap] → 遍歷舊桶 → 重新計算 hash → 遷移節點到新桶
  • 代價:雙重巨大開銷 — 內存分配(新數組)+ CPU 計算(重新哈希 + 鏈表遷移)

2. JDK 7/8 源碼關鍵片段

// JDK 7 transfer() 核心遷移邏輯 (簡化)
void transfer(Entry[] newTable) {
    Entry[] src = table;
    int newCapacity = newTable.length;
    for (int j = 0; j < src.length; j++) {
        Entry<K,V> e = src[j];
        while (e != null) {
            Entry<K,V> next = e.next; // 保存下一節點
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];     // 關鍵:頭插法
            newTable[i] = e;
            e = next;
        }
    }
}

3. 死循環形成全過程圖解

場景:初始容量 2,負載因子 0.75,插入 3 個鍵觸發擴容。鏈表:A → B(同桶)。線程 1、2 並發 transfer。

  1. 線程 1 執行到 Entry<K,V> next = e.next;(e=A, next=B),時間片用盡。
  2. 線程 2 完整執行 transfer,完成遷移:新桶中 B → A(頭插法導致順序反轉)。
  3. 線程 1 恢復,繼續執行:e.next = newTable[i](此時 newTable[i] 已是 B),newTable[i] = e(A)。
  4. 線程 1 處理 e = next(即 B),再次頭插:B.next = A,newTable[i] = B。
  5. 結果:A.next = B 且 B.next = A → 環形鏈表誕生!
最終結構:A ↔ B (循環)
get(key) → while (e != null) e = e.next → 無限循環 → CPU 100%

4. 為什麼是頭插法?

JDK 7 為了性能,遷移時採用 頭插法,導致鏈表順序反轉。多線程並發遷移同一鏈表時,反轉邏輯交織產生環。JDK 8 改為 尾插法(保持原序),緩解但未根治:並發遷移仍可能導致節點丟失、數據覆蓋,本質上 HashMap 非線程安全。

四、 官方回應與生產環境鐵律

Sun/Oracle 官方立場:HashMap 本來就不支持並發。要並發請用 ConcurrentHashMap。

鐵律:

  1. ❌ 絕不在多線程環境直接使用 HashMap、ArrayList 等非線程安全集合。
  2. ✅ 高併發讀寫:ConcurrentHashMap(JDK 8 CAS + synchronized + 紅黑樹,分段鎖升級為 Node 級鎖)。
  3. ✅ 讀多寫少、需快照一致性:CopyOnWriteMap / CopyOnWriteArrayList。
  4. ✅ 簡單計數/標記:LongAdder、AtomicReference 等原子類。
  5. ⚠️ Collections.synchronizedMap(new HashMap()) 僅保證單方法原子,復合操作仍需外部加鎖,性能差於 ConcurrentHashMap。

五、 ConcurrentHashMap 核心進化(JDK 7 → 8 → 17+)

版本核心機制關鍵點
JDK 7分段鎖16 個 Segment 繼承 ReentrantLock,並發度 16
JDK 8CAS + synchronized (Node) + 紅黑樹鎖粒度細化到桶首節點,擴容支持多線程協助
JDK 17+優化擴容、CounterCell 改進減少自旋、改進 size() 精度、增強穩定性

六、 總結

HashMap 死循環是非線程安全數據結構在並發擴容時頭插法導致鏈表成環的經典案例。它警示我們:

  • 併發編程不僅是 API 調用,更是對內存模型、數據結構變異過程的深度理解。
  • 「會用 ConcurrentHashMap」只是入門,理解其擴容協助機制、sizeCtl 狀態機、紅黑樹轉換閾值,才是進階。
  • 代碼上線前,併發壓測、線程安全審計 不可或缺。
  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved