跳轉到

Java進階:鎖定機制與條件變數

1. 引言

我們今天來談Java中的鎖定機制和條件變數,包括:

  1. 同步基礎的回顧
  2. Lock介面與ReentrantLock的使用
  3. 讀寫鎖ReentrantReadWriteLock的應用
  4. 條件變數(Condition)的概念和實踐
  5. Java 8引入的StampedLock
  6. 鎖的公平性與非公平性
  7. 常見的並行問題:死鎖、活鎖與飢餓
  8. 使用鎖和條件變數的最佳實踐

2. 同步基礎回顧

我們先回顧一下Java中的基本同步概念。

  1. synchronized 關鍵字:
  2. 用於方法或程式碼區塊
  3. 提供互斥訪問,確保同一時間只有一個執行緒可以執行被同步的程式碼

範例:

public synchronized void synchronizedMethod() {
    // 同步方法內容
}

public void synchronizedBlock() {
    synchronized(this) {
        // 同步區塊內容
    }
}

  1. volatile 關鍵字:
  2. 保證變數的可見性
  3. 防止指令重排序

範例:

private volatile boolean flag = false;

  1. 等待與通知機制:
  2. Object類的wait()、notify()和notifyAll()方法
  3. 用於執行緒間的通信

範例:

synchronized(sharedObject) {
    while(!condition) {
        sharedObject.wait();
    }
    // 執行操作
    sharedObject.notifyAll();
}

  1. 執行緒安全的概念:
  2. 多個執行緒同時訪問時,保持正確性
  3. 避免競態條件(Race Condition)

  4. 原子性、可見性和有序性:

  5. 原子性:操作不可中斷
  6. 可見性:一個執行緒的修改對其他執行緒立即可見
  7. 有序性:程式碼的執行順序

這些基本概念構成了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. 可設置超時:在指定時間內無法獲取鎖則返回

使用範例:

Lock lock = new ReentrantLock();
try {
    lock.lock();
    // 臨界區程式碼
} finally {
    lock.unlock();
}

進階用法: 1. 嘗試獲取鎖:

if (lock.tryLock()) {
    try {
        // 獲取到鎖後的操作
    } finally {
        lock.unlock();
    }
} else {
    // 未獲取到鎖的處理
}

  1. 可中斷的鎖獲取:

    try {
        lock.lockInterruptibly();
        // 臨界區程式碼
    } catch (InterruptedException e) {
        // 處理中斷
    } finally {
        lock.unlock();
    }
    

  2. 公平鎖:

    Lock fairLock = new ReentrantLock(true);
    

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();
}

  1. 條件變數: ReentrantReadWriteLock也支援條件變數,但只能在寫鎖上使用。

    Condition condition = writeLock.newCondition();
    

  2. 讀寫鎖的升級(不直接支援): 如果需要從讀鎖升級到寫鎖,通常的做法是釋放讀鎖,然後獲取寫鎖。但這個過程不是原子的,可能會導致其他執行緒介入。

使用場景: 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. 帶超時的等待:
    if (condition.await(1, TimeUnit.SECONDS)) {
        // 在超時前條件滿足
    } else {
        // 超時
    }
    

使用場景: 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. 寫鎖(獨佔鎖):

    StampedLock lock = new StampedLock();
    long stamp = lock.writeLock();
    try {
        // 寫操作
    } finally {
        lock.unlockWrite(stamp);
    }
    

  2. 讀鎖(共享鎖):

    long stamp = lock.readLock();
    try {
        // 讀操作
    } finally {
        lock.unlockRead(stamp);
    }
    

  3. 樂觀讀:

    long stamp = lock.tryOptimisticRead();
    // 讀取共享變數
    if (!lock.validate(stamp)) {
        // 樂觀讀失敗,升級為讀鎖
        stamp = lock.readLock();
        try {
            // 重新讀取共享變數
        } finally {
            lock.unlockRead(stamp);
        }
    }
    

進階用法: 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. 執行緒任務執行時間較短的場景
  4. 大多數常見的並發場景

注意事項: 1. 公平鎖不能完全消除飢餓問題,只是減少了發生的可能性。 2. 使用公平鎖可能會導致性能下降,特別是在高競爭的情況下。 3. 即使使用公平鎖,也要注意避免長時間持有鎖,以減少其他執行緒的等待時間。

選擇建議: - 除非有特殊需求,一般情況下優先使用非公平鎖,因為它能提供更好的性能。 - 如果應用對執行緒響應時間非常敏感,或者需要確保執行緒獲取鎖的順序,可以考慮使用公平鎖。 - 在選擇使用公平鎖或非公平鎖時,最好進行性能測試,以確定哪種方式更適合您的具體應用場景。

8. 死鎖、活鎖與飢餓

在並發程式設計中,死鎖、活鎖和飢餓是三種常見的問題,可能導致程式無法正常運行或效能嚴重下降。

  1. 死鎖(Deadlock): 定義:兩個或多個執行緒互相等待對方釋放資源,導致所有相關執行緒永久阻塞的情況。

死鎖的四個必要條件: - 互斥:資源不能同時被多個執行緒使用 - 持有並等待:執行緒持有資源的同時等待其他資源 - 不可剝奪:資源只能由持有它的執行緒主動釋放 - 循環等待:存在一個執行緒等待鏈

範例:

public void method1() {
    synchronized(lockA) {
        synchronized(lockB) {
            // 操作
        }
    }
}

public void method2() {
    synchronized(lockB) {
        synchronized(lockA) {
            // 操作
        }
    }
}

避免死鎖的方法: - 固定加鎖順序 - 使用顯式鎖(如ReentrantLock)的tryLock方法 - 使用帶超時的鎖獲取 - 檢測和恢復策略(如定期檢查執行緒狀態)

  1. 活鎖(Livelock): 定義:執行緒持續改變狀態,但無法繼續執行下去的情況。

範例:

while(true) {
    if (tryLock(resource1)) {
        if (tryLock(resource2)) {
            // 使用資源
            break;
        } else {
            unlock(resource1);
        }
    }
    // 等待一段時間再重試
    Thread.sleep(random.nextInt(1000));
}

避免活鎖的方法: - 引入隨機等待時間 - 優化重試邏輯 - 使用更高級的並發結構(如 java.util.concurrent 包中的工具)

  1. 飢餓(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. 文檔和程式碼審查:

- 詳細記錄同步策略和潛在的死鎖風險
- 進行定期的並發相關程式碼審查

本篇文章同步刊載iThome: iThome
筆者個人的網站: JUNYI