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


作者:湯圓

個人博客: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高並發》

后記

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM