Java 集合框架中,Vector 和 Hashtable 是早期 (JDK 1.0) 就存在的「線程安全」集合,其所有公開方法均以 synchronized 關鍵字修飾。然而,**單個方法的原子性不等於複合操作的線程安全**。本文透過一個經典的 putAll 併發修改異常案例,深度剖析其根因,並提供現代解決方案。
場景:一個執行緒持續向共享的 Hashtable 添加資料,另一個執行緒定期清空並複製該 Hashtable 的內容到本地 Map。
import java.util.*;
public class HashtableConcurrentIssue {
// 共享資料源
static class Constants {
public static final Map USER_MAP = new Hashtable<>();
public static final List USER_LIST = new Vector<>();
}
static class User {
int id; String name;
public User(int id, String name) { this.id = id; this.name = name; }
}
// 寫入執行緒:持續新增資料
static class WriterThread extends Thread {
@Override public void run() {
for (int i = 0; i < 100_000; i++) {
User u = new User(i, "name" + i);
// Hashtable.put 是 synchronized,單一操作安全
if (!Constants.USER_MAP.containsKey(i)) {
Constants.USER_MAP.put(i, u);
Constants.USER_LIST.add(u);
}
}
System.out.println("寫入執行緒結束");
}
}
public static void main(String[] args) {
new WriterThread().start();
Map localMap = new Hashtable<>();
List localList = new Vector<>();
for (int i = 0; i < 1000; i++) {
localMap.clear();
localList.clear();
localList.addAll(Constants.USER_LIST); // Vector.addAll 安全
// 關鍵問題點:Hashtable.putAll 非原子性複製
localMap.putAll(Constants.USER_MAP); // 拋出 ConcurrentModificationException
System.out.printf("迴圈 %d: Map大小=%d, List大小=%d%n", i, localMap.size(), localList.size());
}
System.out.println("主執行緒結束");
}
}
Exception in thread "main" java.util.ConcurrentModificationException
at java.util.Hashtable$Enumerator.next(Hashtable.java:1378)
at java.util.Hashtable.copyTo(Hashtable.java:1462)
at java.util.Hashtable.putAll(Hashtable.java:1490)
// ...
查看 JDK 原始碼 (java.util.Vector):
public synchronized boolean addAll(Collection extends E> c) {
// 關鍵:先將參數集合轉為陣列,再操作陣列
Object[] a = c.toArray(); // 複製一份快照
// ... 後續操作基於快照陣列 a
}
結論: Vector.addAll 內部首先調用 c.toArray() 產生一份**快照**,後續遍歷的是這份快照,不受原集合修改影響,因此不會拋出 CME。
查看 JDK 原始碼 (java.util.Hashtable):
public synchronized void putAll(Map extends K, ? extends V> t) {
// 直接獲取參數 Map 的 EntrySet 迭代器
for (Map.Entry extends K, ? extends V> e : t.entrySet()) {
put(e.getKey(), e.getValue()); // 直接遍歷原集合視圖
}
}
結論: Hashtable.putAll **沒有複製快照**,直接遍歷傳入 Map 的 entrySet() 視圖。雖然 putAll 本身是 synchronized,鎖住的是 this (目標 Map),但**無法鎖住參數 Map (源 Map)**。當寫入執行緒修改源 Map 時,迭代器檢測到 modCount 變化,拋出 ConcurrentModificationException。
synchronized 方法鎖定的是 **當前物件 (this)**。target.putAll(source) 時,鎖住的是 target。source 並未被鎖定,寫入執行緒可自由修改它。| 方案 | 代碼示例 | 優點 | 缺點 |
|---|---|---|---|
| 外部同步鎖源 Map | synchronized (Constants.USER_MAP) {
localMap.putAll(Constants.USER_MAP);
} | 最小改動,邏輯清晰 | 擴展性差,易死鎖,鎖住源 Map 影響寫入效能 |
| 複製快照後再 putAll | Map | 無鎖,效能好,源 Map 可繼續寫入 | 快照瞬間佔用雙倍記憶體 |
| 使用 ConcurrentHashMap (推薦) | static final Map | 高併發效能極佳,API 豐富 (computeIfAbsent 等) | 弱一致性迭代器,遍歷時可能不反映最新狀態 |
| Collections.synchronizedMap | Map | 包裝現有 Map | 本質同方案一,複合操作仍需外部同步 |
new HashMap<>(concurrentMap) 複製一份,再對副本操作。if (!map.containsKey(k)) map.put(k, v) 這類「檢查-執行」模式仍需額外加鎖或使用原子方法 (putIfAbsent)。Vector 和 Hashtable 的「線程安全」僅限於**單個方法調用**。putAll 因直接遍歷源集合視圖而非複製快照,導致在併發修改源集合時拋出 ConcurrentModificationException。現代 Java 開發應全面遷移至 ConcurrentHashMap、CopyOnWriteArrayList 等 `java.util.concurrent` 包下的高併發集合,並理解**一致性快照**與**弱一致性迭代器**的權衡。