天天看點

Java鎖機制淺析:到底什麼情況下該用ReentrantLock?

作者:你的老師父

在多線程程式設計中,鎖(Lock)是一種重要的同步機制,它可以保證同一時間隻有一個線程可以通路共享資源。Java 中提供了兩種類型的鎖:隐式鎖和顯式鎖。

隐式鎖通過 synchronized 關鍵字實作,在使用時比較友善,但其粒度較大,無法滿足複雜的同步需求。而顯式鎖則通過 Lock 接口實作,可以更靈活地控制鎖的粒度和行為。本文将介紹 Java 顯式鎖中的顯示鎖(ReentrantLock)和顯示條件隊列(Condition),并讨論它們的使用方法、進階用法以及可能遇到的問題和解決方案。

一、顯示鎖

1. 簡介

顯示鎖(ReentrantLock)是 Java 顯式鎖中最常用的一種,它實作了 Lock 接口的所有特性,并提供了可重入和公平性等額外功能。其中,可重入指同一線程可以多次擷取該鎖而不會造成死鎖,公平性指多個線程按照申請鎖的順序獲得鎖。

與隐式鎖不同的是,顯示鎖需要手動加鎖和釋放鎖,通常使用 try-finally 語句塊保證鎖的正确釋放,避免異常導緻鎖未能被及時釋放而造成死鎖。

2. 基本使用

顯示鎖(ReentrantLock)的基本用法如下:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class MyRunnable implements Runnable {
    private final Lock lock = new ReentrantLock();
    private int count = 0;

    public void run() {
        lock.lock(); // 加鎖
        try {
            count++; // 通路共享資源
        } finally {
            lock.unlock(); // 解鎖
        }
    }
}
           

在上述示例中,我們首先建立了一個 ReentrantLock 對象,并将其作為同步對象(Monitor)來通路共享資源。然後,在通路共享資源時使用 lock.lock() 方法加鎖,使用 lock.unlock() 方法解鎖。由于 lock 和 unlock 方法都可能抛出異常,是以通常需要使用 try-finally 語句塊來確定鎖的正确釋放。

3. 可重入性

在 Java 中,可重入性指同一線程可以多次獲得該鎖而不會産生死鎖或排斥自己的情況。這是由于每個線程在加鎖時會記錄加鎖的次數,隻有在解鎖和加鎖次數相等時才真正釋放鎖。例如:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class MyRunnable implements Runnable {
    private final Lock lock = new ReentrantLock();
    private int count = 0;

    public void run() {
        lock.lock(); // 第一次加鎖
        try {
            count++; // 通路共享資源
            lock.lock(); // 第二次加鎖
            try {
                count++; // 通路共享資源
            } finally {
                lock.unlock(); // 第二次解鎖
            }
        } finally {
            lock.unlock(); // 第一次解鎖
        }
    }
}
           

在上述示例中,我們先後兩次擷取了同一個鎖,并在其中通路了共享資源。由于鎖是可重入的,是以即使在第二次加鎖時仍然持有鎖,也不會産生死鎖或排斥自己的情況。

4. 公平性

在 Java 中,公平性指多個線程按照申請鎖的順序獲得鎖的特性。公平性可以避免某些線程長期持有鎖,導緻其他線程無法獲得鎖而等待過長時間的情況。

在顯示鎖中,預設情況下是非公平的,即目前線程可以随時獲得鎖,而不考慮其他線程的申請順序。這樣可能會導緻某些線程一直無法獲得鎖,進而産生線程饑餓(Thread Starvation)的問題。

為了解決這個問題,Java 中提供了公平鎖(FairLock),它會按照線程申請鎖的順序進行排隊,并且保證先來先得的原則。示例代碼如下:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class MyRunnable implements Runnable {
    private final Lock lock = new ReentrantLock(true); // 公平鎖
    private int count = 0;

    public void run() {
        lock.lock(); // 加鎖
        try {
            count++; // 通路共享資源
        } finally {
            lock.unlock(); // 解鎖
        }
    }
}
           

在上述示例中,我們建立了一個公平鎖(FairLock),并将其傳遞給 ReentrantLock 的構造函數中。然後,在通路共享資源時使用 lock.lock() 方法加鎖,使用 lock.unlock() 方法解鎖。由于公平鎖會按照線程申請鎖的順序進行排隊,是以可以避免線程饑餓的問題。

二、顯示條件隊列

1. 簡介

條件隊列(Condition)是 Java 顯式鎖中實作線程等待/通知機制的一種方式。它允許多個線程在某些條件不滿足時暫停執行,并在特定條件滿足時恢複執行。與 synchronized 關鍵字相比,條件隊列提供了更靈活和細粒度的同步控制,可以更好地支援複雜的同步需求。

條件隊列通常與顯示鎖一起使用,通過 ReentrantLock.newCondition() 方法建立一個 Condition 對象,并使用 await()、signal() 和 signalAll() 等方法來進行線程等待和喚醒操作。其中,await() 方法用于使目前線程等待某個條件發生變化,signal() 方法用于喚醒一個等待該條件的線程,signalAll() 方法用于喚醒所有等待該條件的線程。

2. 基本使用

顯示條件隊列(Condition)的基本用法如下:

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class MyRunnable implements Runnable {
    private final Lock lock = new ReentrantLock();
    private final Condition condition = lock.newCondition();
    private boolean flag = false;

    public void run() {
        lock.lock(); // 加鎖
        try {
            while (!flag) {
                condition.await(); // 等待條件變化
            }
            // 通路共享資源
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            lock.unlock(); // 解鎖
        }
    }

    public void changeFlag() {
        lock.lock(); // 加鎖
        try {
            flag = true; // 修改條件
            condition.signalAll(); // 喚醒等待的線程
        } finally {
            lock.unlock(); // 解鎖
        }
    }
}
           

在上述示例中,我們首先建立了一個 Condition 對象,并将其關聯到一個顯示鎖(ReentrantLock)上。然後,在通路共享資源時使用 while 循環判斷條件是否滿足,如果不滿足則調用 condition.await() 方法使目前線程進入等待狀态。在修改條件時調用 changeFlag() 方法,并使用 condition.signalAll() 喚醒所有等待該條件的線程。需要注意的是,await() 方法和 signal()/signalAll() 方法都必須在鎖保護下進行調用,否則會抛出 IllegalMonitorStateException 異常。

3. 進階使用

條件隊列(Condition)還提供了許多進階操作,用于支援更複雜的同步需求。以下是一些常用的進階使用方式:

(1)等待逾時

有時候我們希望線程在等待一段時間後自動喚醒,而不是一直等待到被喚醒為止。這時候可以使用 condition.await(long time, TimeUnit unit) 方法,它允許我們指定等待的最長時間,如果超過指定時間仍未被喚醒,則自動退出等待狀态。示例代碼如下:

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.TimeUnit;

public class MyRunnable implements Runnable {
    private final Lock lock = new ReentrantLock();
    private final Condition condition = lock.newCondition();
    private boolean flag = false;

    public void run() {
        lock.lock(); // 加鎖
        try {
            long timeout = 10L; // 等待 10 秒
            while (!flag) {
                if (!condition.await(timeout, TimeUnit.SECONDS)) {
                    // 在等待一定時間後還未被喚醒,做相應處理
                    break;
                }
            }
            // 通路共享資源
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            lock.unlock(); // 解鎖
        }
    }

    public void changeFlag() {
        lock.lock(); // 加鎖
        try {
            flag = true; // 修改條件
            condition.signalAll(); // 喚醒等待的線程
        } finally {
            lock.unlock(); // 解鎖
        }
    }
}
           

在上述示例中,我們使用 condition.await(timeout, TimeUnit.SECONDS) 方法等待了 10 秒,如果超過該時間還未被喚醒,則退出等待狀态并做相應處理。

(2)等待多個條件

有時候我們需要等待多個條件同時滿足後才能繼續執行,這時候可以使用多個條件隊列(Condition)來實作。示例代碼如下:

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class MyRunnable implements Runnable {
    private final Lock lock = new ReentrantLock();
    private final Condition condition1 = lock.newCondition();
    private final Condition condition2 = lock.newCondition();
    private boolean flag1 = false;
    private boolean flag2 = false;

    public void run() {
        lock.lock(); // 加鎖
        try {
            while (!flag1 || !flag2) {
                if (!flag1) {
                    condition1.await(); // 等待條件 1
                }
                if (!flag2) {
                    condition2.await(); // 等待條件 2
                }
            }
            // 通路共享資源
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            lock.unlock(); // 解鎖
        }
    }

    public void changeFlag1() {
        lock.lock(); // 加鎖
        try {
            flag1 = true; // 修改條件 1
            condition1.signalAll(); // 喚醒等待條件 1 的線程
        } finally {
            lock.unlock(); // 解鎖
        }
    }

    public void changeFlag2() {
        lock.lock(); // 加鎖
        try {
            flag2 = true; // 修改條件 2
            condition2.signalAll(); // 喚醒等待條件 2 的線程
        } finally {
            lock.unlock(); // 解鎖
        }
    }
}
           

在上述示例中,我們建立了兩個條件隊列(Condition),分别用于等待兩個不同的條件。然後,在通路共享資源時使用 while 循環判斷兩個條件是否都滿足,如果不滿足則分别調用 condition1.await() 和 condition2.await() 方法使目前線程進入等待狀态。在修改條件時分别調用 changeFlag1() 和 changeFlag2() 方法,并使用 condition1.signalAll() 和 condition2.signalAll() 喚醒等待相應條件的線程。

(3)實作生産者消費者模型

條件隊列(Condition)還可以用于實作生産者消費者模型,其中生産者和消費者共享一個緩沖區,當緩沖區為空時,消費者需要等待生産者生産資料;當緩沖區滿時,生産者需要等待消費者消費資料。示例代碼如下:

import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class MyRunnable implements Runnable {
    private final Lock lock = new ReentrantLock();
    private final Condition notFull = lock.newCondition();
    private final Condition notEmpty = lock.newCondition();
    private final Queue<Integer> queue = new LinkedList<>();
    private final int maxSize = 10;

    public void run() {
        while (true) {
            lock.lock(); // 加鎖
            try {
                while (queue.isEmpty()) {
                    notEmpty.await(); // 等待不為空
                }
                int data = queue.poll(); // 取出資料
                notFull.signalAll(); // 喚醒生産者
                // 處理資料
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                lock.unlock(); // 解鎖
            }
        }
    }

    public void produce(int data) {
        lock.lock(); // 加鎖
        try {
            while (queue.size() == maxSize) {
                notFull.await(); // 等待不滿
            }
            queue.offer(data); // 添加資料
            notEmpty.signalAll(); // 喚醒消費者
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            lock.unlock(); // 解鎖
        }
    }
}
           

在上述示例中,我們建立了一個緩沖區(Queue),并使用兩個條件隊列(Condition)分别表示緩沖區不為空和不滿。在消費者線程中,使用 while 循環判斷緩沖區是否為空,如果為空則調用 notEmpty.await() 方法使目前線程進入等待狀态。當從緩沖區取出資料後,調用 notFull.signalAll() 方法喚醒所有等待不滿的生産者線程。在生産者線程中,使用 while 循環判斷緩沖區是否已滿,如果已滿則調用 notFull.await() 方法使目前線程進入等待狀态。當往緩沖區添加資料後,調用 notEmpty.signalAll() 方法喚醒所有等待不為空的消費者線程。

三、讀寫鎖

1. 簡介

讀寫鎖是一種特殊的鎖,它允許多個線程同時讀取共享資源,但隻允許一個線程對共享資源進行寫操作。讀寫鎖可以有效地提高并發性能,特别是在讀取操作遠多于寫操作的場景下。

Java 中提供了 ReentrantReadWriteLock 類來實作讀寫鎖。它包含一個讀鎖和一個寫鎖,讀鎖可同時被多個線程持有,但寫鎖一次隻能被一個線程持有。示例代碼如下:

import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class MyRunnable implements Runnable {
    private final ReadWriteLock lock = new ReentrantReadWriteLock();
    private int count = 0;

    public void run() {
        lock.readLock().lock(); // 擷取讀鎖
        try {
            // 通路共享資源(讀取)
        } finally {
            lock.readLock().unlock(); // 釋放讀鎖
        }
    }

    public void write() {
        lock.writeLock().lock(); // 擷取寫鎖
        try {
        		// 通路共享資源(寫入)
        } finally {
       		 lock.writeLock().unlock(); // 釋放寫鎖
        }
    }
}           

在上述示例中,我們建立了一個讀寫鎖(ReentrantReadWriteLock),并使用 readLock() 方法擷取讀鎖,writeLock() 方法擷取寫鎖。在通路共享資源時,讀取操作可以同時被多個線程持有讀鎖,而寫入操作必須先擷取寫鎖,然後其他所有操作都被阻塞,直到寫入完成并釋放寫鎖。

2. 使用場景

讀寫鎖适用于以下場景:

  • 讀取操作遠多于寫入操作。
  • 共享資源的狀态不會發生太大變化,即讀取操作和寫入操作之間的時間間隔較長。
  • 寫入操作對資源的一緻性要求高,需要獨占式通路。

使用讀寫鎖可以有效地提高程式的并發性能,特别是在讀取操作遠多于寫入操作的情況下。但需要注意的是,讀寫鎖的實作需要消耗更多的系統資源,是以隻有在讀取操作遠多于寫入操作、且讀寫操作之間的時間間隔較長時才應該使用讀寫鎖。

四、StampedLock

1. 簡介

StampedLock 是 Java 8 新增的一種鎖機制,它是對讀寫鎖的一種改進,具有更高的并發性能。StampedLock 支援三種模式:讀(共享)、寫(獨占)和樂觀讀(非獨占)。與 ReadWriteLock 不同的是,StampedLock 的讀取操作不會被阻塞,但可能會失敗,如果讀取的資料在讀取過程中發生了改變,則讀取操作會失敗并傳回一個标記(stamp),此時可以根據需要重試讀取操作或者轉換為獨占寫入操作。

StampedLock 使用一個長整型的 stamp 來表示鎖的版本号,每次修改資料後都會更新版本号。讀取操作需要傳入目前版本号以確定讀取的資料沒有被修改,寫入操作則需要傳入上一次讀取操作傳回的版本号以確定資料的一緻性。示例代碼如下:

import java.util.concurrent.locks.StampedLock;
public class MyRunnable implements Runnable {
    private final StampedLock lock = new StampedLock();
    private int x = 0;
    private int y = 0;

    public void run() {
        long stamp = lock.tryOptimisticRead(); // 嘗試樂觀讀取
        int currentX = x;
        int currentY = y;
        if (!lock.validate(stamp)) { // 校驗版本号
            stamp = lock.readLock(); // 擷取讀鎖
            try {
                currentX = x; // 重新讀取資料
                currentY = y;
            } finally {
                lock.unlockRead(stamp); // 釋放讀鎖
            }
        }
        // 通路共享資源(讀取)
    }

    public void write(int newX, int newY) {
        long stamp = lock.writeLock(); // 擷取寫鎖
        try {
            x = newX; // 修改資料
            y = newY;
        } finally {
            lock.unlockWrite(stamp); // 釋放寫鎖
        }
    }
}           

在上述示例中,我們建立了一個 StampedLock,并使用 tryOptimisticRead() 方法嘗試進行樂觀讀取操作。如果校驗版本号失敗,則說明資料被修改過,此時需要再次擷取讀鎖并重新讀取資料。在修改資料時,使用 writeLock() 方法擷取寫鎖,修改完成後釋放寫鎖。

2. 使用場景

StampedLock 适用于以下場景:

  • 讀取操作頻繁,而寫入操作較少。
  • 資料的一緻性要求不高,即資料會發生周期

性的變化,但讀取操作與寫入操作之間的時間間隔較短,不需要使用分布式鎖或者資料庫事務來保證資料一緻性。

使用 StampedLock 可以提高程式的并發性能,特别是在讀取操作頻繁、寫入操作較少的情況下。但需要注意的是,StampedLock 的實作依賴于硬體的 CAS(Compare and Swap)指令,是以在某些 CPU 架構上可能會存在性能問題。此外,在使用樂觀讀取模式時需要進行版本号校驗,如果校驗失敗則需要重新擷取讀鎖并重新讀取資料,這可能會帶來額外的開銷和複雜度。

五、總結

Java 提供了多種鎖機制來協調多個線程對共享資源的通路。ReentrantLock 是最基本的一種鎖,它采用獨占式通路方式,可以精确控制多個線程對共享資源的通路順序。Condition 可以用于在鎖的基礎上實作更靈活的同步操作,例如線程的等待和喚醒。ReadWriteLock 是一種特殊的鎖,它允許多個線程同時讀取共享資源,但隻允許一個線程對共享資源進行寫操作。StampedLock 是對讀寫鎖的一種改進,具有更高的并發性能,但需要注意的是它的實作依賴于硬體的 CAS 指令。

在使用鎖時需要注意避免死鎖、避免過度競争和防止資源饑餓等問題。應該根據具體的場景選擇不同的鎖機制,并合理地設定鎖的粒度和範圍。同時也可以考慮使用一些進階的并發工具來簡化鎖的管理,例如 Executor 架構、原子變量、信号量、倒計時門闩等。

繼續閱讀