Java進階:鎖定機制與條件變數
1. 引言
我們今天來談Java中的鎖定機制和條件變數,包括:
- 同步基礎的回顧
- Lock介面與ReentrantLock的使用
- 讀寫鎖ReentrantReadWriteLock的應用
- 條件變數(Condition)的概念和實踐
- Java 8引入的StampedLock
- 鎖的公平性與非公平性
- 常見的並行問題:死鎖、活鎖與飢餓
- 使用鎖和條件變數的最佳實踐
2. 同步基礎回顧
我們先回顧一下Java中的基本同步概念。
- synchronized 關鍵字:
- 用於方法或程式碼區塊
- 提供互斥訪問,確保同一時間只有一個執行緒可以執行被同步的程式碼
範例:
public synchronized void synchronizedMethod() {
// 同步方法內容
}
public void synchronizedBlock() {
synchronized(this) {
// 同步區塊內容
}
}
- volatile 關鍵字:
- 保證變數的可見性
- 防止指令重排序
範例:
- 等待與通知機制:
- Object類的wait()、notify()和notifyAll()方法
- 用於執行緒間的通信
範例:
synchronized(sharedObject) {
while(!condition) {
sharedObject.wait();
}
// 執行操作
sharedObject.notifyAll();
}
- 執行緒安全的概念:
- 多個執行緒同時訪問時,保持正確性
-
避免競態條件(Race Condition)
-
原子性、可見性和有序性:
- 原子性:操作不可中斷
- 可見性:一個執行緒的修改對其他執行緒立即可見
- 有序性:程式碼的執行順序
這些基本概念構成了Java同步機制的基礎。然而,在複雜的並行場景中,這些基本工具可能顯得不夠靈活或效率不高。
這就是為什麼Java引入了更進階的鎖定機制和條件變數。
3. Lock介面與ReentrantLock
Java 5引入了java.util.concurrent.locks包,提供了比synchronized更靈活的鎖定機制,其中,Lock介面是這個包的核心,而ReentrantLock是Lock介面最常用的實現。
Lock介面: Lock介面定義了鎖的基本操作,包括獲取鎖、釋放鎖、嘗試獲取鎖等。主要方法包括: - lock():獲取鎖,如果鎖不可用則阻塞 - unlock():釋放鎖 - tryLock():嘗試獲取鎖,立即返回結果 - tryLock(long time, TimeUnit unit):在指定時間內嘗試獲取鎖
ReentrantLock: ReentrantLock是Lock介面的可重入實現,它允許同一個執行緒多次獲取同一個鎖。
特點: 1. 可重入性:允許遞迴調用 2. 可中斷性:等待鎖的執行緒可以被中斷 3. 可設置公平性:可以選擇公平鎖或非公平鎖 4. 可設置超時:在指定時間內無法獲取鎖則返回
使用範例:
進階用法: 1. 嘗試獲取鎖:
-
可中斷的鎖獲取:
-
公平鎖:
ReentrantLock vs synchronized: 1. 靈活性:ReentrantLock提供更多的功能和更細粒度的控制 2. 效能:在高競爭情況下,ReentrantLock可能有更好的效能 3. 使用方式:ReentrantLock需要顯式的加鎖和解鎖,而synchronized是自動的 4. 可讀性:synchronized通常更簡潔,程式碼可讀性更高
使用ReentrantLock時的注意事項: 1. 確保在finally區塊中釋放鎖,避免死鎖 2. 避免在持有鎖時調用外部方法,可能導致嵌套鎖定 3. 考慮使用try-with-resources語法來自動管理鎖的釋放
ReentrantLock提供了比synchronized更高的靈活性和功能,但也需要更謹慎的使用。
在選擇使用ReentrantLock時,應該權衡其優勢和複雜性,確保正確使用以避免並發問題。
4. 讀寫鎖:ReentrantReadWriteLock
ReentrantReadWriteLock是Java並發包中提供的一種先進的鎖機制,允許多個讀操作同時進行,但寫操作則是互斥的。
這種機制在「讀多寫少」的場景中特別有用,可以顯著提高程式的並行性能。
ReentrantReadWriteLock的主要特性: 1. 讀寫分離:允許多個讀操作同時進行,但寫操作需要獨佔訪問。 2. 可重入性:支援重入,包括讀鎖和寫鎖。 3. 鎖降級:允許從寫鎖降級到讀鎖,但不支援從讀鎖升級到寫鎖。 4. 公平性選擇:可以設置為公平鎖或非公平鎖。
基本使用方式:
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
Lock readLock = rwLock.readLock();
Lock writeLock = rwLock.writeLock();
// 讀操作
readLock.lock();
try {
// 讀取共享資源
} finally {
readLock.unlock();
}
// 寫操作
writeLock.lock();
try {
// 修改共享資源
} finally {
writeLock.unlock();
}
進階用法: 1. 鎖降級:
writeLock.lock();
try {
// 寫操作
readLock.lock();
writeLock.unlock();
// 現在持有讀鎖,可以繼續讀操作
} finally {
readLock.unlock();
}
-
條件變數: ReentrantReadWriteLock也支援條件變數,但只能在寫鎖上使用。
-
讀寫鎖的升級(不直接支援): 如果需要從讀鎖升級到寫鎖,通常的做法是釋放讀鎖,然後獲取寫鎖。但這個過程不是原子的,可能會導致其他執行緒介入。
使用場景: 1. 緩存實現:讀取緩存使用讀鎖,更新緩存使用寫鎖。 2. 資料庫連接池:讀取連接狀態使用讀鎖,修改連接狀態使用寫鎖。 3. 檔案系統:讀取檔案使用讀鎖,修改檔案使用寫鎖。
效能考量: - 在讀多寫少的場景中,ReentrantReadWriteLock可以提供更好的並行性。 - 如果讀和寫的操作比例接近,或者寫操作較多,則可能不會比普通的互斥鎖有明顯優勢。
注意事項: 1. 避免長時間持有讀鎖,可能會導致寫操作長時間等待。 2. 讀寫鎖的實現比簡單的互斥鎖更複雜,在簡單的場景中可能不值得使用。 3. 讀寫鎖不支援鎖升級,需要特別注意避免死鎖。
5. 條件變數(Condition)
條件變數(Condition)是Java並發程式設計中的一個重要概念,讓執行緒可以在某個條件成立之前等待,並在條件滿足時被喚醒。Condition與Lock介面緊密相關,替代傳統的Object.wait()、Object.notify()和Object.notifyAll()方法,提供靈活的執行緒間協調機制。
主要特點: 1. 與特定的Lock實例關聯 2. 支援多個等待集(wait-set) 3. 提供比傳統的wait/notify機制更精確的控制
基本使用方式:
Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();
// 等待條件
lock.lock();
try {
while (!conditionMet) {
condition.await();
}
// 條件滿足,執行相應操作
} finally {
lock.unlock();
}
// 通知條件可能滿足
lock.lock();
try {
// 改變條件狀態
condition.signal(); // 或 condition.signalAll();
} finally {
lock.unlock();
}
主要方法: 1. await():使當前執行緒等待,直到被喚醒或中斷 2. awaitUninterruptibly():等待,但不響應中斷 3. await(long time, TimeUnit unit):帶超時的等待 4. signal():喚醒一個等待的執行緒 5. signalAll():喚醒所有等待的執行緒
進階用法: 1. 多條件變數:
Lock lock = new ReentrantLock();
Condition notFull = lock.newCondition();
Condition notEmpty = lock.newCondition();
- 帶超時的等待:
使用場景: 1. 生產者-消費者模式 2. 實現阻塞隊列 3. 資源池管理(如執行緒池、連接池)
與傳統wait/notify的比較: 1. 更精確的執行緒喚醒:可以有多個等待集 2. 與鎖的結合更緊密:必須與Lock一起使用 3. 可中斷性:支援可中斷的等待操作 4. 公平性:可以實現公平的喚醒策略
注意事項: 1. 始終在持有鎖的情況下調用Condition的方法 2. 檢查等待條件時使用while循環,而不是if語句 3. 優先使用signalAll()而不是signal(),除非您確定只需要喚醒一個執行緒 4. 注意避免信號丟失:確保在改變條件狀態後一定會發出信號
6. StampedLock:Java 8中的新型鎖
StampedLock是Java 8引入的一個新的鎖機制,在某些場景下可以替代ReentrantReadWriteLock,提供更好的性能。
StampedLock的主要特點是它支援樂觀讀(optimistic reading),這在讀多寫少的場景中可以大幅提升性能。
主要特性: 1. 三種模式:寫鎖、讀鎖和樂觀讀 2. 基於戳記(stamp)的鎖獲取和釋放 3. 不可重入 4. 不支援條件變數 5. 不支援公平性選擇
基本使用方式:
-
寫鎖(獨佔鎖):
-
讀鎖(共享鎖):
-
樂觀讀:
進階用法: 1. 讀鎖轉換為寫鎖:
long stamp = lock.readLock();
try {
// 讀操作
if (needWrite) {
long writeStamp = lock.tryConvertToWriteLock(stamp);
if (writeStamp != 0L) {
stamp = writeStamp;
// 寫操作
} else {
// 轉換失敗,手動釋放讀鎖並獲取寫鎖
lock.unlockRead(stamp);
stamp = lock.writeLock();
}
}
} finally {
lock.unlock(stamp);
}
性能優勢: - 在讀多寫少的場景中,樂觀讀可以大幅減少讀操作的開銷 - 相比ReentrantReadWriteLock,StampedLock在高競爭的情況下表現更好
使用注意事項: 1. StampedLock不可重入,需要特別注意避免死鎖 2. 樂觀讀不阻塞寫操作,在讀取數據時要注意數據一致性 3. 在使用樂觀讀時,要及時檢查並處理樂觀讀失敗的情況 4. StampedLock不支援條件變數,如果需要條件變數,仍需使用ReentrantReadWriteLock
適用場景: - 讀多寫少,且讀操作耗時較短的場景 - 需要高性能且能容忍偶爾重試的場景
7. 鎖的公平性與非公平性
在Java的鎖機制中,公平性是一個重要的概念,決定在競爭激烈的情況下,執行緒獲取鎖的順序。
公平鎖與非公平鎖的定義: 1. 公平鎖:嚴格按照執行緒請求鎖的順序來獲取鎖,等待時間最長的執行緒會優先獲得鎖。 2. 非公平鎖:允許「插隊」,當一個執行緒請求鎖時,如果在這個時間點鎖可用,那麼這個執行緒會直接獲取到鎖,而不管是否有其他執行緒已經等待更長時間。
ReentrantLock中的公平性設置:
// 默認為非公平鎖
ReentrantLock unfairLock = new ReentrantLock();
// 創建公平鎖
ReentrantLock fairLock = new ReentrantLock(true);
公平鎖的優點: 1. 減少執行緒飢餓:確保每個執行緒都有機會獲得鎖。 2. 適合對響應時間要求較為嚴格的場景。
非公平鎖的優點: 1. 整體吞吐量更高:減少了執行緒切換的開銷。 2. 在大多數情況下性能更好。
性能比較: - 非公平鎖通常比公平鎖有更好的性能,因為它減少了執行緒切換和唤醒的開銷。 - 公平鎖可能會導致較低的吞吐量,但可以提供更可預測的執行緒調度。
使用場景: 1. 公平鎖: - 對執行緒響應時間敏感的應用 - 需要確保執行緒按照請求順序獲得鎖的場景 - 長時間運行的任務,避免某些執行緒長時間無法獲取鎖
- 非公平鎖:
- 追求高吞吐量的場景
- 執行緒任務執行時間較短的場景
- 大多數常見的並發場景
注意事項: 1. 公平鎖不能完全消除飢餓問題,只是減少了發生的可能性。 2. 使用公平鎖可能會導致性能下降,特別是在高競爭的情況下。 3. 即使使用公平鎖,也要注意避免長時間持有鎖,以減少其他執行緒的等待時間。
選擇建議: - 除非有特殊需求,一般情況下優先使用非公平鎖,因為它能提供更好的性能。 - 如果應用對執行緒響應時間非常敏感,或者需要確保執行緒獲取鎖的順序,可以考慮使用公平鎖。 - 在選擇使用公平鎖或非公平鎖時,最好進行性能測試,以確定哪種方式更適合您的具體應用場景。
8. 死鎖、活鎖與飢餓
在並發程式設計中,死鎖、活鎖和飢餓是三種常見的問題,可能導致程式無法正常運行或效能嚴重下降。
- 死鎖(Deadlock): 定義:兩個或多個執行緒互相等待對方釋放資源,導致所有相關執行緒永久阻塞的情況。
死鎖的四個必要條件: - 互斥:資源不能同時被多個執行緒使用 - 持有並等待:執行緒持有資源的同時等待其他資源 - 不可剝奪:資源只能由持有它的執行緒主動釋放 - 循環等待:存在一個執行緒等待鏈
範例:
public void method1() {
synchronized(lockA) {
synchronized(lockB) {
// 操作
}
}
}
public void method2() {
synchronized(lockB) {
synchronized(lockA) {
// 操作
}
}
}
避免死鎖的方法: - 固定加鎖順序 - 使用顯式鎖(如ReentrantLock)的tryLock方法 - 使用帶超時的鎖獲取 - 檢測和恢復策略(如定期檢查執行緒狀態)
- 活鎖(Livelock): 定義:執行緒持續改變狀態,但無法繼續執行下去的情況。
範例:
while(true) {
if (tryLock(resource1)) {
if (tryLock(resource2)) {
// 使用資源
break;
} else {
unlock(resource1);
}
}
// 等待一段時間再重試
Thread.sleep(random.nextInt(1000));
}
避免活鎖的方法: - 引入隨機等待時間 - 優化重試邏輯 - 使用更高級的並發結構(如 java.util.concurrent 包中的工具)
- 飢餓(Starvation): 定義:一個執行緒長時間無法獲得所需資源,無法執行的情況。
造成飢餓的原因: - 優先級設置不當 - 長時間持有鎖 - 無限循環或遞迴
避免飢餓的方法: - 公平鎖:使用 ReentrantLock(true) 創建公平鎖 - 避免長時間持有鎖 - 合理設置執行緒優先級 - 使用 synchronized 關鍵字(內部實現考慮了公平性)
檢測並發問題的工具: 1. jconsole:Java 自帶的監控工具 2. VisualVM:可視化 JVM 監控工具 3. Thread Dump 分析:使用 jstack 命令生成線程轉儲 4. 靜態程式碼分析工具:如 FindBugs、SonarQube 等
操作建議: 1. 盡量使用高級並發工具:如 java.util.concurrent 包中的類 2. 遵循一致的加鎖順序 3. 避免在持有鎖時調用外部方法 4. 使用超時機制 5. 定期進行並發測試和性能分析
9. 實踐與效能考量
在使用Java的鎖定機制和條件變數時,遵循最佳實踐並考慮效能因素是非常重要的。
以下是一些關鍵的建議和考量:
1. 選擇適當的同步機制:
- 優先使用 java.util.concurrent 包中的高級工具
- 對於簡單場景,考慮使用 synchronized 關鍵字
- 需要更細粒度控制時,使用 ReentrantLock 或 StampedLock
2. 最小化鎖的範圍:
- 盡量縮小同步塊的範圍
- 避免在持有鎖時進行耗時操作或 I/O 操作
3. 避免鎖爭用:
- 使用分離鎖(Separate Locks)來增加並行性
- 考慮使用 ThreadLocal 來避免共享狀態
4. 正確使用條件變數:
- 總是在循環中檢查等待條件
- 優先使用 signalAll() 而不是 signal(),除非確定只需喚醒一個執行緒
5. 考慮使用讀寫鎖:
- 在讀多寫少的場景中使用 ReentrantReadWriteLock
- 對於 Java 8 及以上版本,考慮使用 StampedLock 來提高性能
6. 適當使用超時機制:
- 使用 tryLock() 方法的超時版本來避免無限等待
- 在條件變數的 await() 方法中使用超時參數
7. 注意鎖的順序:
- 始終以固定的順序獲取多個鎖,以避免死鎖
- 使用 try-finally 塊確保鎖的正確釋放
8. 效能優化:
- 使用 -XX:+PrintFlagsFinal JVM 參數來檢查和調整鎖的實現
- 考慮使用偏向鎖(Biased Locking)來優化單執行緒訪問的場景
- 適當設置執行緒池大小,避免過多的上下文切換
9. 使用並發集合:
- 優先使用 ConcurrentHashMap 而不是 Collections.synchronizedMap()
- 考慮使用 CopyOnWriteArrayList 用於讀多寫少的場景
10. 適當處理中斷:
- 正確響應 InterruptedException,不要忽視它
- 在長時間運行的操作中定期檢查中斷狀態
11. 避免過度同步:
- 不要同步不可變對象
- 使用 volatile 關鍵字來保證可見性,而不是使用同步塊
12. 性能測試和監控:
- 使用 JMH(Java Microbenchmark Harness)進行微基準測試
- 利用 JVisualVM 或 Java Mission Control 進行性能分析
13. 考慮無鎖算法:
- 在適當的場景中,考慮使用 AtomicInteger、AtomicReference 等原子類
- 熟悉並適當使用 CAS(Compare-and-Swap)操作
14. 文檔和程式碼審查:
- 詳細記錄同步策略和潛在的死鎖風險
- 進行定期的並發相關程式碼審查