多線程並發 put/get 非線程安全的 HashMap,會導致:
get() 進入死循環 (環形鏈表)。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
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); // 擴容觸發點
}
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);
}
}
}

e=A, next=B,被掛起 (未執行 e.next = newTable[i])。
e.next = newTable[i] 使 A.next = B',但 B'.next 已指向 A → 環形鏈表 A ↔ B'。


後續 get(key) 遍歷該桶 → 無限循環 → CPU 100%。
| 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() 非精確、無法加鎖整個表 | 高併發生產環境首選 |
get() 直接讀 Node 數組 + 鏈表/紅黑樹,volatile 保證可見性。synchronized 鎖桶頭節點 (而非整表),CAS 失敗自旋重試。ForwardingNode 標記遷移中桶。ConcurrentModificationException。if (!map.containsKey(k)) map.put(k, v) 非原子,需鎖住 map 或用 putIfAbsent。new ConcurrentHashMap<>(expectedSize) 減少擴容開銷。HashMap 並發三大禍根:頭插法擴容形成環形鏈表 (死循環)、addEntry 覆蓋丟失、transfer 置空舊桶穿透 Null。本質是非線程安全的數組+鏈表結構在並發寫入下不一致。ConcurrentHashMap (JDK 8+) 以 CAS+節點鎖+協作擴容徹底解決,是高併發場景唯一正解。記住:線程安全不是可選項,是基礎設施。