Java併發-顯式鎖篇【可重入鎖+讀寫鎖】

湯圓學Java發表於2021-05-23

作者:湯圓

個人部落格:javalover.cc

前言

在前面併發的開篇,我們介紹過內建鎖synchronized

這節我們再介紹下顯式鎖Lock

顯式鎖包括:可重入鎖ReentrantLock、讀寫鎖ReadWriteLock

關係如下所示:

image-20210523174802931

簡介

顯式鎖和內建鎖最大的區別就是:顯式鎖需手動獲取鎖和釋放鎖,而內建鎖不需要

關於顯式鎖,本節會分別介紹可它的實現類 - 可重入鎖,以及它的相關類 - 讀寫鎖

  • 可重入鎖,實現了顯式鎖,意思就是可重入的顯式鎖(內建鎖也是可重入的)

  • 讀寫鎖,將顯式鎖分為讀寫分離,即讀讀可並行,多個執行緒同時讀不會阻塞(讀寫,寫寫還是序列)

下面讓我們開始吧

文章如果有問題,歡迎大家批評指正,在此謝過啦

目錄

  1. 可重入鎖 ReentrantLock
  2. 讀寫鎖 ReadWriteLock
  3. 區別

正文

1.可重入鎖 ReentrantLock

我們先來看下它的幾個方法:

  • public ReentrantLock();建構函式,預設構造非公平的鎖(可插隊,如果某個執行緒獲取鎖時,剛好鎖被釋放,那麼這個執行緒就會立馬獲得鎖,而不管佇列裡的執行緒是否在等待)

  • public void lock()獲取鎖,以阻塞的方式(如果其他執行緒持有鎖,則阻塞當前執行緒,直到鎖被釋放);

  • public void lockInterruptibly() throws InterruptedException獲取鎖,以可被中斷的方式(如果當前執行緒被中斷,則丟擲中斷異常);

  • public boolean tryLock(): 嘗試獲取鎖,如果鎖被其他執行緒持有,則立馬返回false

  • public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException:嘗試獲取鎖,並設定一個超時時間(如果超過這個時間,還沒獲取到鎖,則返回false)

  • public void unlock(): 釋放鎖

首先我們先看下它的構造方法,內部實現如下:

public ReentrantLock() {
  sync = new NonfairSync();
}

可以看到,這裡建立了一個非公平鎖

公平鎖:如果獲取鎖時,被其他執行緒持有,則將當前執行緒放入等待佇列

非公平鎖:如果獲取鎖時,剛好鎖被釋放,那麼這個執行緒就會立馬獲得鎖,而不管佇列裡的執行緒是否在等待

非公平鎖的好處就是,可以減少執行緒的掛起和喚醒開銷

如果某個執行緒的執行任務所需時間很短,甚至比喚醒佇列中的執行緒所消耗的時間還短,那麼非公平鎖的優勢就很明顯

我們可以假設這樣一個情景:

  • 執行緒A的任務執行耗時為10ms
  • 而喚醒佇列中的執行緒B到執行真正去執行執行緒B的任務耗時為20ms
  • 那麼當執行緒A去獲取鎖時,剛好鎖又被釋放,此時執行緒A搶先獲得鎖,並執行任務,然後釋放鎖
  • 當執行緒A釋放鎖之後,佇列中當執行緒B才被喚醒正要去獲取鎖,那麼執行緒B被喚醒的這段時間CPU就沒有被浪費,從而提高了程式的效能

這也是為啥預設是非公平鎖的原因(一般情況下,非公平鎖的效能高於公平鎖)

那什麼時候應該用公平鎖呢?

  • 持有鎖的時間較長,即執行緒的任務執行耗時較長
  • 請求鎖的時間間隔較長

因為這種情況下,如果執行緒插隊獲取到鎖,結果任務還半天執行不完,那麼佇列中被喚醒的執行緒醒來發現鎖還是被佔有的,就會被再次放到佇列中(此時並不會提高效能,還有可能降低)

接下來我們看下關鍵的部分:獲取鎖

獲取鎖有多個方法,我們用程式碼來看下他們之間的區別

  1. 先來看下lock()方法,示例程式碼如下:
public class ReentrantLockDemo {

    private Lock lock = new ReentrantLock();

    private int i = 0;

    public void add(){
        lock.lock();
        try {
            i++;
        }finally {
            System.out.println(i);
            lock.unlock();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        ReentrantLockDemo demo = new ReentrantLockDemo();
        ExecutorService service = Executors.newFixedThreadPool(5);
        for (int i = 0; i < 100; i++) {
            service.submit(()->{
                demo.add();
            });
        }
    }
}

依次輸出1~100,這是因為lock()獲取鎖時,會以阻塞的方式來獲取

  1. 接下來看下 tryLock()方法,程式碼如下:
public class ReentrantLockDemo {

    private Lock lock = new ReentrantLock();

    private int i = 0;

    public void tryAdd(){
        if(lock.tryLock()){
            try {
                i++;
            }finally {
                System.out.println(i);
                lock.unlock();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        ReentrantLockDemo demo = new ReentrantLockDemo();
        ExecutorService service = Executors.newFixedThreadPool(5);
        for (int i = 0; i < 100; i++) {
            service.submit(()->{
                demo.tryAdd();
            });
        }
    }
}

執行發現,輸出永遠都少於100,是因為tryLock()如果獲取鎖失敗,會立馬返回false,而不是阻塞等待

  1. 最後我們來看下lockInterruptibly()方法,它也是阻塞獲取鎖,只是比lock()多了箇中斷異常,即獲取鎖時,如果執行緒被中斷,則丟擲中斷異常
public class ReentrantLockDemo {

    private Lock lock = new ReentrantLock();

    private int i = 0;

    public void interruptAdd(){
        try {
            lock.lockInterruptibly();
            i++;
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println(i);
            lock.unlock();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        ReentrantLockDemo demo = new ReentrantLockDemo();
        ExecutorService service = Executors.newFixedThreadPool(5);
        for (int i = 0; i < 100; i++) {
						// 第10次,立馬關閉執行緒池,停止所有的執行緒(包括正在執行的和正在等待的)
            if (10 == i){
                service.shutdownNow();
            }
            service.submit(()->{
                demo.interruptAdd();
            });
        }

    }
}

多執行幾次,有可能輸出如下:

1
2
3
4
5
6
6
6
6
6
java.lang.InterruptedException
	at 
......

這就是因為前面幾個都是正常獲取到鎖並執行了i++,但是後面的幾個執行緒因為被突然停止,所以丟擲中斷異常

  1. 最後就是釋放鎖, unlock()

這個就很簡單了,上面的程式碼都有涉及到這個釋放鎖

不過細心的朋友可能發現了,上面的unlock()都是在finally塊中編寫的

這是因為在獲取鎖並執行任務時,有可能丟擲異常,此時如果不把unlock()放到finally塊中,那麼鎖不被釋放,這在後期是一個很大的隱患(其他執行緒無法再次獲取到這個鎖,如果是lock()形式的獲取鎖,則執行緒會一直阻塞)

這也是顯式鎖無法完全替代內建鎖的一個原因,有危險

2. 讀寫鎖 ReadWriteLock

讀寫鎖內部就兩個方法,分別返回讀鎖和寫鎖

讀鎖屬於共享鎖,而寫鎖屬於獨佔鎖(前面介紹的可重入鎖和內建鎖也是獨佔鎖)

讀鎖允許多個執行緒同時獲取一個鎖,因為讀不會修改資料,它很適合讀多寫少的場合

下面我們用程式碼來看下

先看下讀鎖,程式碼如下:

public class ReadWriteLockDemo {

    private int i = 0;
    private Lock readLock;
    private Lock writeLock;


    public ReadWriteLockDemo() {
        ReadWriteLock lock = new ReentrantReadWriteLock();
        this.readLock = lock.readLock();
        this.writeLock = lock.writeLock();
    }

    public void readFun(){
        readLock.lock();
        System.out.println("=== 獲取到 讀鎖 ===");
        try {
            System.out.println(i);
        }finally {
            readLock.unlock();
            System.out.println("=== 釋放了 讀鎖 ===");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        ReadWriteLockDemo demo = new ReadWriteLockDemo();
        ExecutorService executors = Executors.newFixedThreadPool(2);
        for (int i = 0; i < 10; i++) {
            executors.submit(()->{
                demo.readFun();
            });
        }
    }
}

多次執行,有可能輸出下面的結果:

=== 獲取到 讀鎖 ===
0
=== 獲取到 讀鎖 ===

可以看到,兩個執行緒都獲取到了讀鎖,這就是讀鎖的優勢,多個執行緒同時讀

下面看下寫鎖,程式碼如下:(這裡用到了ReentrantReadWriteLock類,表示可重入的讀寫鎖)

public class ReadWriteLockDemo {

    private int i = 0;
    private Lock readLock;
    private Lock writeLock;

    public ReadWriteLockDemo() {
        ReadWriteLock lock = new ReentrantReadWriteLock();
        this.readLock = lock.readLock();
        this.writeLock = lock.writeLock();
    }

    public void writeFun(){
        writeLock.lock();
        System.out.println("=== 獲取到 寫鎖 ===");
        try {
            i++;
            System.out.println(i);
        }finally {
            writeLock.unlock();
            System.out.println("=== 釋放了 寫鎖 ===");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        ReadWriteLockDemo demo = new ReadWriteLockDemo();
        ExecutorService executors = Executors.newFixedThreadPool(2);
        for (int i = 0; i < 10; i++) {
            executors.submit(()->{
                demo.writeFun();
            });
        }
    }

}

輸出如下:可以看到,寫鎖類似上面的重入鎖的lock()方法,阻塞獲取寫鎖

=== 獲取到 寫鎖 ===1=== 釋放了 寫鎖 ====== 獲取到 寫鎖 ===2=== 釋放了 寫鎖 ====== 獲取到 寫鎖 ===3=== 釋放了 寫鎖 ====== 獲取到 寫鎖 ===4=== 釋放了 寫鎖 ====== 獲取到 寫鎖 ===5=== 釋放了 寫鎖 ====== 獲取到 寫鎖 ===6=== 釋放了 寫鎖 ====== 獲取到 寫鎖 ===7=== 釋放了 寫鎖 ====== 獲取到 寫鎖 ===8=== 釋放了 寫鎖 ====== 獲取到 寫鎖 ===9=== 釋放了 寫鎖 ====== 獲取到 寫鎖 ===10=== 釋放了 寫鎖 ===

關於讀寫鎖,需要注意的一點是,讀鎖和寫鎖必須基於同一個ReadWriteLock類才有意義

如果讀鎖和寫鎖分別是從兩個ReadWrite Lock類中獲取的,那麼讀鎖和寫鎖就是完全無關的兩個鎖,也就不會起到鎖的作用(阻止其他執行緒訪問)

這就類似synchronized(a)和synchronized(b),分別鎖了兩個物件,此時單個執行緒是可以同時訪問這兩個鎖的

3. 區別

我們用表格來展示吧,細節如下:

鎖的特點 內建鎖 可重入鎖 讀寫鎖
靈活性
公平性 不確定 非公平(預設)+公平 非公平(預設)+公平
定時性 可定時 可定時
中斷性 可中斷 可中斷
互斥性 互斥 互斥 讀讀共享,其他都互斥

建議優先選擇內建鎖,只有在內建鎖滿足不了需求時,再採用顯式鎖(比如可定時、可中斷、公平性)

如果是讀多寫少的場景(比如配置資料),推薦用讀寫鎖

總結

  1. 可重入鎖 ReentrantLock:需顯式獲取鎖和釋放鎖,切記要在finally塊中釋放鎖
  2. 讀寫鎖 ReadWriteLock:基於顯式鎖(顯式鎖有的它都有),多了讀寫分離,實現了讀讀共享(多個執行緒同時讀),其他都不共享(讀寫,寫寫)
  3. 區別:內建鎖不支援手動獲取/釋放鎖、公平性選擇、定時、中斷,顯式鎖支援

建議使用鎖時,優先考慮內建鎖

因為現在內建鎖的效能跟顯式鎖差別不大

而且顯式鎖因為需要手動釋放鎖(需在finally塊中釋放),所以會有忘記釋放的風險

如果是讀多寫少的場合,則推薦用讀寫鎖(成對的讀鎖和寫鎖需從同一個讀寫鎖類獲取)

參考內容:

  • 《Java併發程式設計實戰》
  • 《實戰Java高併發》

後記

最後,祝願所有人都心想事成,闔家歡樂

相關文章