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

HashMap 多線程並發致死循環、數據丟失、Null 值穿透:源碼級根因分析與三大解決方案

     編輯:Java 綜合教學

前言:一個經典的生產事故

多線程並發 put/get 非線程安全的 HashMap,會導致:

  • CPU 100%:get() 進入死循環 (環形鏈表)。
  • 數據丟失:兩線程同時擴容導致節點覆蓋。
  • 穿透 Null:遷移中舊桶置空,舊表讀取返回 null。

本文基於 JDK 7/8 源碼,從 transfer() 遷移邏輯入手,圖解並發擴容形成環形鏈表全過程,對比 Hashtable、Collections.synchronizedMap、ConcurrentHashMap 三大解決方案優劣。

一、並發症狀複現代碼

HashMap map = new HashMap<>();
// 5 線程 put,5 線程 get,各 5 萬次
for (int i = 0; i < 5; i++) new Thread(() -> {
    for (int j = 0; j < 50000; j++) map.put(j, j);
}).start();
for (int i = 0; i < 5; i++) new Thread(() -> {
    for (int j = 0; j < 50000; j++) map.get(j);
}).start();
// 現象:部分線程 Hang 住,jstack 顯示卡在 HashMap.get:303 / put:374

二、HashMap 核心源碼 (JDK 7/8 關鍵邏輯一致)

1. put 流程

public V put(K key, V value) {
    int hash = hash(key.hashCode());
    int i = indexFor(hash, table.length);
    // 1. 遍歷鏈表覆蓋舊值
    for (Entry e = table[i]; e != null; e = e.next) {
        if (e.hash == hash && key.equals(e.key)) {
            V old = e.value; e.value = value; return old;
        }
    }
    // 2. 新增節點 (頭插法)
    addEntry(hash, key, value, i);
    return null;
}

void addEntry(int hash, K key, V value, int bucketIndex) {
    Entry e = table[bucketIndex];
    table[bucketIndex] = new Entry<>(hash, key, value, e); // 新節點指向舊頭
    if (size++ >= threshold) resize(2 * table.length);     // 擴容觸發點
}

2. resize → transfer 遷移邏輯 (環形鏈表根源)

void resize(int newCapacity) {
    Entry[] oldTable = table;
    Entry[] newTable = new Entry[newCapacity];
    transfer(newTable);        // 核心遷移
    table = newTable;
    threshold = (int)(newCapacity * loadFactor);
}

void transfer(Entry[] newTable) {
    Entry[] src = table;               // 引用舊表
    for (int j = 0; j < src.length; j++) {
        Entry e = src[j];
        if (e != null) {
            src[j] = null;             // ★ 舊桶置空!
            do {
                Entry next = e.next; // ① 線程1 讀到 next 指向節點B
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];     // ② 線程2 已遷移完,newTable[i] 指向節點B'
                newTable[i] = e;          // ③ 線程1 恢復執行,e.next 指向 B',形成環!
                e = next;
            } while (e != null);
        }
    }
}

三、並發擴容形成環形鏈表:圖解 4 步驟

正常 Rehash

場景:兩線程同時擴容,頭插法導致順序反轉

  1. 初始鏈表:桶 [0] → A → B → null (A 先插入,B 後插入,頭插法下 B 在頭)。
  2. 線程1 讀到 e=A, next=B,被掛起 (未執行 e.next = newTable[i])。
    線程1掛起
  3. 線程2 完整遷移:新表桶 [0] → B → A → null (順序反轉)。
  4. 線程1 恢復:e.next = newTable[i] 使 A.next = B',但 B'.next 已指向 A → 環形鏈表 A ↔ B'。
    環形鏈表形成
    環形鏈表確認
    死循環

後續 get(key) 遍歷該桶 → 無限循環 → CPU 100%。

四、其他兩大並發 Bug

Bug根因後果
數據丟失 addEntry 兩線程同桶同時 new Entry(..., e),e 相同,後寫入者覆蓋先寫入者 元素莫名消失,size 不準
穿透 Null transfer 中 src[j] = null 置空舊桶,舊表引用讀取返回 null 明明 put 了非 null,get 卻是 null

五、三大解決方案深度對比

方案原理優點缺點適用場景
Hashtable 所有方法 synchronized (鎖整個表) 簡單、JDK 1.0 就有 鎖粗粒度、性能極差、迭代器快速失效 極低併發、歷史代碼維護
Collections.synchronizedMap 包裝層鎖 map 對象,迭代需手動同步 非侵入式、兼容現有 Map 仍是全表鎖、迭代易忘同步、擴容仍單線程 中低併發、遷移舊代碼
ConcurrentHashMap ★ 推薦 JDK7: Segment 分段鎖 (16把) / JDK8: CAS + synchronized 節點鎖 + @Contended 高併發讀無鎖、寫鎖細粒度、擴容多線程協助、迭代器弱一致不拋異常 size() 非精確、無法加鎖整個表 高併發生產環境首選

ConcurrentHashMap 核心優勢 (JDK 8)

  • 讀無鎖:get() 直接讀 Node 數組 + 鏈表/紅黑樹,volatile 保證可見性。
  • 寫細粒度:synchronized 鎖桶頭節點 (而非整表),CAS 失敗自旋重試。
  • 擴容協作:多線程並行遷移桶,ForwardingNode 標記遷移中桶。
  • 弱一致迭代器:遍歷快照風格,不拋 ConcurrentModificationException。

六、避坑指南

  1. 絕不在多線程環境裸用 HashMap:哪怕「大概率沒事」,一旦發生無法復現、極難排查。
  2. 首選 ConcurrentHashMap:JDK 8+ 性能碾壓 Hashtable/synchronizedMap。
  3. 複合操作需額外鎖:if (!map.containsKey(k)) map.put(k, v) 非原子,需鎖住 map 或用 putIfAbsent。
  4. 避免在迭代中修改:即使 ConcurrentHashMap 也不保證強一致性。
  5. 容量預估:new ConcurrentHashMap<>(expectedSize) 減少擴容開銷。

七、總結

HashMap 並發三大禍根:頭插法擴容形成環形鏈表 (死循環)、addEntry 覆蓋丟失、transfer 置空舊桶穿透 Null。本質是非線程安全的數組+鏈表結構在並發寫入下不一致。ConcurrentHashMap (JDK 8+) 以 CAS+節點鎖+協作擴容徹底解決,是高併發場景唯一正解。記住:線程安全不是可選項,是基礎設施。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved