Java 類庫首個關聯集合類 Hashtable(JDK 1.0)提供易用、執行緒安全的 Map 功能,但所有方法皆同步,無競爭同步帶來可觀效能代價。繼任者 HashMap(JDK 1.2 集合框架)透過非同步基類搭配 Collections.synchronizedMap 包裝器,分離基本功能與執行緒安全,讓不需同步的用戶避免付出代價。
Hashtable 和 synchronizedMap 採用的同步方式(同步每個方法)有兩大不足:
get()、put() 等簡單操作無需額外同步即安全,但常見操作序列如迭代或「不存在則放入」需外部同步。synchronizedMap 和 synchronizedList 被稱為「有條件執行緒安全」——單一操作安全,但多操作序列因控制流依賴前序操作結果,可能導致資料競爭。
Map m = Collections.synchronizedMap(new HashMap());
List l = Collections.synchronizedList(new ArrayList());
// put-if-absent 慣用語 — 含競爭條件
if (!map.containsKey(key))
map.put(key, value);
// 臨機操作迭代 — 含競爭條件
for (int i = 0; i < list.size(); i++) {
doSomething(list.get(i));
}
// 正常迭代 — 可能拋出 ConcurrentModificationException
for (Iterator i = list.iterator(); i.hasNext(); ) {
doSomething(i.next());
}
第一段:containsKey() 返回到 put() 呼叫間隙,其他執行緒可能插入相同鍵值。需用同步區塊包裝。第二段:List.size() 結果在迴圈執行期間可能失效,其他執行緒刪除元素導致 List.get() 返回 null 拋出 NullPointerException。第三段:java.util 迭代器極易失效——迭代期間其他執行緒修改集合,hasNext() 或 next() 拋出 ConcurrentModificationException。避免此問題需在迭代期間鎖住整個 List,但嚴重降低並發性。
同步包裝器的條件式安全帶來隱患:開發者假設「集合已同步即執行緒安全」,疏忽正確同步複合操作。結果輕負載下正常,重負載時拋出 NullPointerException 或 ConcurrentModificationException。
擴展性指應用程式隨工作負載與處理資源增加而提升吞吐量的能力。鎖定共享資源獲取獨佔存取形成擴展性瓶頸——阻塞其他執行緒,即使有閒置處理器也無濟於事。為獲擴展性,必須消除或減少對獨佔資源鎖的依賴。
同步包裝器、早期 Hashtable、Vector 同步在單一鎖上:一次只能一執行緒存取集合。讀取 Map 時,所有其他讀/寫執行緒必須等待。常見 get()、put() 操作遍歷雜湊桶調用 equals() 可能耗時,若 hashCode() 分布不均或大量雜湊衝突,部分鏈極長,遍歷長鏈並調用 equals() 很慢。此期間所有其他執行緒被鎖在外面無法存取 Map。
複合操作需長時間持有鎖(如整個迭代期間),進一步惡化爭用。
Map 在伺服器應用最常見用途是實現快取(檔案內容、生成頁面、資料庫查詢結果、DOM 樹等)。快取工作負載特徵:讀取遠多於寫入,理想情況下 get() 效能極佳。但用 synchronizedMap 實現快取引入潛在擴展性瓶頸——一次只能一執行緒存取,包含讀取值與插入新鍵值對的執行緒。
提高 HashMap 並發性同時保持執行緒安全的方法:廢除全表單一鎖,改用每個桶一鎖(或鎖池,每鎖保護幾個桶)。多執行緒可同時存取 Map 不同部分,無需爭用全域鎖,直接提升插入、檢索、移除擴展性。代價:size()、isEmpty() 等全集合操作實現困難(需一次獲取多鎖,可能返回不正確結果)。但對快取等場景(讀寫頻繁、全集合操作稀少)是良好折衷。
util.concurrent 中的 ConcurrentHashMap(將納入 JDK 1.5 java.util.concurrent)提供遠優於 synchronizedMap 的並發性:多讀操作幾乎總能並發,讀寫並發通常可行,寫寫並發也能間歇進行。設計優化檢索操作——成功 get() 通常完全無鎖。實現需深入理解 Java 記憶體模型,經並發專家驗證正確性。
ConcurrentHashMap 透過放寬承諾獲高並發:檢索可返回最近完成插入的值、或並發插入添加的值(絕不返回無意義結果)。迭代器每次最多返回一元素、不拋 ConcurrentModificationException、可能反映迭代器建立後的插入/移除。迭代無需全表鎖即提供執行緒安全。任何不依賴鎖全表防止更新的應用,可用 ConcurrentHashMap 替代 synchronizedMap 或 Hashtable。
雙處理器 Xeon Linux 系統測試:n 執行緒並發從 Hashtable 或 ConcurrentHashMap 檢索隨機鍵值,80% 檢索失敗(模擬 put())、1% 成功。表 1 顯示 10,000,000 次迭代執行時間(毫秒),以單執行緒 ConcurrentHashMap 為基準歸一化。隨執行緒增加,ConcurrentHashMap 效能持續上升,Hashtable 因鎖爭用立即下降。
線程數 ConcurrentHashMap Hashtable 1 1.00 1.03 2 2.59 32.40 4 5.58 78.23 8 13.21 163.48 16 27.58 341.21 32 57.27 778.41
測試中執行緒數看似少於實際伺服器應用,但每執行緒持續操作,爭用情況與更多執行緒間歇使用基本等價。
遍歷操作遠多於插入/移除的並發應用(如事件監聽器列表——AWT/Swing、JavaBean 常見),可用 CopyOnWriteArrayList 替代 ArrayList(對應 CopyOnWriteArraySet 實現 Set 介面)。
用一般 ArrayList 存監聽器列表,列表可變且可能多執行緒存取,必須在迭代期間或迭代前克隆期間鎖定整個列表,開銷大。CopyOnWriteArrayList 修改列表時不建立全新副本,迭代器保證返回迭代器建立時的列表狀態,不拋 ConcurrentModificationException。迭代前無需克隆或鎖定,因迭代器看到的副本不可變。本質上持有對不可變陣列的可變引用,保留引用即獲不可變執行緒安全優勢,無需鎖定。
同步集合類(Hashtable、Vector、Collections.synchronizedMap、Collections.synchronizedList)提供基本條件式執行緒安全實現,但全域單鎖特性阻礙擴展性,且常需長時間鎖定集合防 ConcurrentModificationException。ConcurrentHashMap 和 CopyOnWriteArrayList 提供更高並發性並保持執行緒安全,代價是對調用者承諾打折。它們非適用於所有 HashMap/ArrayList 場景,而是針對特定常見方案優化設計,許多並發應用將受益於其使用。