ReentrantLock你瞭解多少(結合Lock、AQS進行講解)

寫在前面

如果覺得有所收穫,記得的點個關注和點個贊,感謝支持。
本篇文章要講的是Lock 接口,重點強調 ReentrantLock 類,相關的接口在JUC 包裏面,自 JDK 5 起,Java 類庫中新提供了 java.util.concurrent 包(通常簡稱爲 JUC 包)。Java 中有兩種對併發資源加鎖的方式,除了 synchronized 之外(不清楚的可以查看我之前寫過的一篇關於synchronize文章),還有本篇文章要講的 Locksynchronized 是 JVM 通過底層實現的,而 Lock 是通過 JDK 純粹在軟件層面上實現的。

先來講講 Lock 接口

Lock 類本身是一個接口,對鎖進行了規範,Lock 接口的定義如下(我這裏刪除了源碼的註釋,這樣不佔用版面):

public interface Lock {
    void lock();
    void lockInterruptibly() throws InterruptedException;
    boolean tryLock();
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    void unlock();
    Condition newCondition();
}

上面可以看到,Lock 接口一共規範給定了 6 個方法。其中最爲常用的,是 lock() 方法和 unlock() 方法,這兩個方法必須成對出現,否則就有可能出現異常,使用邏輯如下:

// 假如已經創建了一個lock對象
lock.lock();
try {
    // ...
} finally {
    lock.unlock();
}

這裏使用 lock 上鎖,與使用 synchronized 上鎖的效果是相同的,但在使用上從大括號代碼塊變爲 try 代碼塊,並且一定要使用 finally 語句爲 lock 對象解鎖。可以查閱阿里巴巴的 Java 代碼規約,在裏面已經說的非常明白了,內容如下:

在這裏插入圖片描述
Lock 接口規定了四種上鎖,除了上文說到的最傳統的 lock() 方法之外,還有以下三種:

  • lockInterruptibly() 會處理線程中斷的上鎖
  • tryLock() 嘗試上鎖並立即返回,上鎖成功則 true,上鎖失敗則 false
  • tryLock(long time, TimeUnit unit) 嘗試一段時間上鎖後返回,上鎖成功則 true,上鎖失敗則 false

除以上上鎖方法之外,最後還有一個方法 newCondition(),該方法用於協調線程,這個後面再提。

講講線程相關的知識

在講解線程中斷之前呢,需要來了解一下線程相關的一些知識,我之前寫過一篇博文,是有關在Java中如何使用線程,不清楚的可以過去看看,這裏講解線程的使用邏輯,即線程的狀態,以及線程中斷的邏輯。
通常意義上線程有六種狀態,但依我來看線程實際上只有兩種狀態:可運行狀態、不可運行狀態。

  • 可運行狀態:線程可以運行,但是並不一定正在運行,細分的話可以分爲正在運行和等待運行兩種狀態。
  • 不可運行狀態:線程不能運行,可能是主動的(主動等待),也可能是被動的(要用的資源被鎖住了)。細分的話能分爲三種狀態:無限期等待狀態、限期等待狀態、阻塞狀態,前兩種是線程自己發起的,第三種是線程被迫的。

在這裏插入圖片描述

對各個狀態分別進行解釋:

  • New 新增:線程剛剛創建(例如 Thread t = new Thread()),還沒有執行代碼

  • Runnable 可運行:線程可以運行(例如 thread.start()),但並不代表一定在運行,是否正在運行要看虛擬機和 CPU 的線程調度情況。CPU 將時間劃分爲 10-20 ms 的一個個時間片,在每一個時間片中執行一條線程,到時間就切換(切換地太快導致似乎在並行執行多條線程),這被稱爲 CPU 在調度線程。在 Runnable 狀態下,每一條線程都有可能會被執行,但是執行和切換的速度都很快,非要分出來是在執行還是在等待並沒有太大的意義。

    • Ready 等待運行:等待 CPU 調度
    • Running 正在運行:CPU 正在執行
  • Waiting 無限期等待:線程主動等待,並且不設置等待結束的時間,直到被其他線程“喚醒”(例如 thread.join())。

  • Timed Waiting 限期等待:線程主動等待,但是設置一個等待的時長,到時間就自動喚醒(例如 thread.sleep(sleepTime)),在等待的這段時間也可以被其他線程“喚醒”。

  • Blocked 阻塞等待:線程被動等待,因爲搶鎖失敗了,被迫等着(例如使用 synchronized 同時讓多條線程獲取資源,總有線程會被迫等待)。

有關線程狀態還可以剖析地更深一些:

  • Java 的 Thread 類看似是一個尋常的 Java 對象,實際上可以視爲對底層系統操作線程的封裝,因此使用 Thread 類時不能完全按照面向對象的常規思維來思考,而是要以底層硬件的實現邏輯來思考。
  • 上文我將線程分爲了可運行狀態和不可運行狀態,細分析的話,這實際上是指 CPU 有沒有爲線程分配時間片。在另外的地方(線程和進程的區別)學習到,線程是操作系統能夠調度的最小單位,“能調度的最小單位“這種說法,就是指 CPU 劃分出一個個時間片,每一個時間片”調度“一個線程。可運行狀態指的是 CPU 能夠調度線程,而不可運行狀態指的是 CPU 不能調度線程,比如某一個線程中執行 Thread.sleep(sleepTime) 方法,那麼這個線程進入 Timed Waiting 狀態,在這種狀態下 CPU 不再調度該線程,直到該線程休眠時間結束,回到 Runnable 狀態,CPU 纔可以調度該線程,這個行爲被稱作線程的“掛起”。
  • 線程通過 sleep(time)wait(time) 方法都可以進入 Timed Waiting 狀態,CPU 都不再會調度該線程,但是 sleep 的一方不會釋放鎖,wait 的一方會釋放鎖。其他線程如果需要正在 sleep 的線程的資源,將一直阻塞到那個線程醒來再釋放資源。
  • 只有使用 synchronized 才能導致線程進入 Blocked 狀態,線程從 Waiting 狀態無法直接進入 Runnable 狀態,只能先進入 Blocked 狀態去獲取鎖。(順便一提,進入 Waiting 狀態的 wait()、notify()、notifyAll() 方法,只能在 synchronized 代碼塊中使用)

線程中斷,這裏的“中斷”是一個頗有迷惑性的詞語,它並不是指線程就此停止,而是指線程收到了一個“中斷信號”,線程應該根據這個信號來自行了斷一些事情(但是收到中斷信號也可以不處理)。比如,線程 1 向線程 2 發送了一條中斷信息,線程 2 的中斷狀態發生了改變,線程 2 根據中斷狀態來進行邏輯處理。所以我認爲,中斷是線程間通信的一種方式,通信的內容是“建議另一條線程停止行爲”,但是線程並不一定採取意見,即使採取意見也絕不是終止線程,而是停止某個一直重複運行的行爲,繼續執行後續的代碼。我目前所見,中斷有兩種使用場景:

  • 線程根據中斷狀態,停止某個循環(例如下面這段僞代碼)
while(還沒中斷){
    循環執行
}
中斷了,進行後續操作

  • 如果線程處於阻塞、限期等待或者無限期等待狀態,那麼就會拋出 InterruptedException,從而提前結束該線程,但是不能中斷 I/O 阻塞和 synchronized 鎖阻塞。這裏的用法是,當線程處於不可運行狀態時(暫停 CPU 調度),以異常的形式,強制讓線程處理中斷,以恢復回到可運行狀態(CPU 可調度)。雖然這是在處理異常,但實際上並不是指程序有什麼錯誤,而是代表一種強制手段:必須要對中斷進行處理。再換句話說,這是一種恢復線程狀態,停止發呆的一種機制。
try {
    // 當前線程休眠1秒
    Thread.sleep(1000);
} catch (InterruptedException e) {
    // 線程中斷,不讓繼續休眠了,處理後續的業務邏輯
}

線程中斷有三個相關方法:

API 介紹
public void interrupt() 中斷線程
public boolean isInterrupted() 查看線程是否中斷
public static boolean interrupted() 靜態方法,查看當前線程是否中斷的同時,清除中斷狀態,即如果線程中斷,執行之後將不再處於中斷狀態

中斷的源碼,以及阻塞狀態下的線程拋出中斷異常的原理,這裏暫不考究了。在此只掌握到兩點即可:

  • 線程中斷不代表線程活動終止
  • 線程中斷的基本原理,是給線程的中斷標誌位賦 true

聊一聊AQS

AQS 可以算是 JUC 包的核心,一大片併發類,包括要學習的 ReentrantLock 鎖,都是以 AQS 爲內核,不瞭解 AQS 則無法繼續學習。

AQS 的全稱是 AbstractQueuedSynchronizer(抽象隊列同步器,中文一般簡稱“隊列同步器”),它的作用正如其名,是一個隊列,需要同步的線程們在隊列裏排隊,每次讓一個線程佔用資源,剩下的線程在隊列同步器裏待命。這樣的設計實現了這種效果:當多個線程爭搶資源時,保證只會有一條線程在運行,其他線程都在等待隊列裏等候安排。打開 AQS 接口看源碼,會看到多如牛毛的方法,初識 AQS 如果從這些方法着手,就可以準備去世了,因此我們從 AQS 的成員變量着手,對 AQS 進行猜測性學習。以下代碼部分,基本全部參考自《一行一行源碼分析清楚 AbstractQueuedSynchronizer》,這篇博文寫的真的非常好

AQS 重要的成員變量有四個,分別是:

// 頭結點,你直接把它當做【當前持有鎖的線程】可能是最好理解的
private transient volatile Node head;

// 阻塞的尾節點,每個新的節點進來,都插入到最後,也就形成了一個鏈表
private transient volatile Node tail;

// 這個是最重要的,代表當前鎖的狀態,0代表沒有被佔用,大於 0 代表有線程持有當前鎖
// 這個值可以大於 1,是因爲鎖可以重入,每次重入都加上 1
private volatile int state;

// 代表當前持有獨佔鎖的線程(該變量繼承自父類),舉個最重要的使用例子
// 因爲鎖可以重入,reentrantLock.lock()可以嵌套調用多次,所以每次用這個來判斷當前線程是否已經擁有了鎖
// if (currentThread == getExclusiveOwnerThread()) {state++}
private transient Thread exclusiveOwnerThread;

AQS 接口中定義了一個內部類:Node,這個類是 AQS 隊列的基本構成元素,即併發線程們在 AQS 隊列裏等候時,都是裝在這個 Node 對象裏排序的。Node 類源碼如下:

static final class Node {
    // 標識節點當前在共享模式下
    static final Node SHARED = new Node();
    // 標識節點當前在獨佔模式下
    static final Node EXCLUSIVE = null;

    // ======== 下面的幾個int常量是給waitStatus用的 ===========
    // 代表此線程取消了爭搶這個鎖
    static final int CANCELLED = 1;
    // 官方的描述是,其表示當前node的後繼節點對應的線程需要被喚醒
    static final int SIGNAL = -1;
    // 本文不分析condition
    static final int CONDITION = -2;
    // 同樣的不分析,略過吧
    static final int PROPAGATE = -3;
    // =====================================================

    // 取值爲上面的1、-1、-2、-3,或者0(以後會講到)
    // 這麼理解,暫時只需要知道如果這個值 大於0 代表此線程取消了等待,
    //    ps: 半天搶不到鎖,不搶了,ReentrantLock是可以指定timeouot的。。。
    volatile int waitStatus;
    // 前驅節點的引用
    volatile Node prev;
    // 後繼節點的引用
    volatile Node next;
    // 這個就是線程本尊
    volatile Thread thread;
}

Node 類的代碼容易看得人一頭霧水,初學時應當將其視爲一個普通的鏈表節點,它必須需要

  • Node prev:指向前個節點
  • Node next:指向後個節點
  • Thread Thread:本節點需要存儲的內容

除此之外該節點還有一個狀態位:

  • int waitStatus:節點狀態,在之後的代碼中很重要

Node 類定義的其他內容不用太過糾結,看之後的代碼會懂。根據學習這個類,以及參考學習其他 AQS 相關的博文,可以大概知道 AQS 隊列的基本結構和設計邏輯是這樣的:
在這裏插入圖片描述
看圖應該就能明白 AQS 的數據結構,需要注意的是,head 並不在 AQS 的阻塞隊列當中。以下部分是 AQS 的源碼分析,這部分的內容很難,可以不看,不會影響到 Lock 接口的學習。之前的代碼中說過,使用 Lock 接口上鎖的基本步驟是:

lock.lock();		--> AQS#acquire()
try {
    // ...
} finally {
    lock.unlock();	--> AQS#release()
}

實際上,lock()unlock() 方法的原理,是使用 AQS 的 acquire()release() 方法實現的,因此我們來粗略地學習這兩個方法,並大致瞭解 AQS 的原理。(以下代碼說明均爲簡略版,查看詳細代碼說明請參見上述博文)

上鎖(新線程加入隊列)

在這裏插入圖片描述

解鎖(老線程執行完畢,傳喚下一個線程)

在這裏插入圖片描述
AQS 的具體實現代碼,我自認爲是又長又難的,因此不把全部代碼整理出來了,只在此記錄一些點吧:

  • AQS 中有大量的方法,是爲了處理併發的,例如隊列還是空的,同時有兩個線程進來申請鎖,如何來讓一個線程拿到鎖,另一個線程去隊列裏排隊等候。AQS 解決併發問題的原理是 CAS(CAS 的原理去看上篇介紹 synchronized 的博文),AQS 去調用 JDK5 剛剛出現的 sun.misc.Unsafe 類裏面的方法,這個類對 CPU 的 CAS 指令進行了封裝。
  • 進入阻塞隊列排隊的線程會被掛起,而喚醒的操作是由前驅節點完成的。當佔用鎖的線程結束,調用 unlock() 方法,此時 AQS 會去隊列裏喚醒排在最前面的節點線程。
  • AQS 接口確定了隊列同步的主要邏輯,也就是上鎖時線程先嚐試獲取鎖,失敗則加入隊列;解鎖時隊列先嚐試解除鎖,如果解鎖成功則喚醒後繼節點。但是嘗試獲取鎖和嘗試解除鎖這兩個操作,都是交由子類去實現的。這就使得 AQS 框架確立了基礎的併發隊列機制,但鎖的形式可以有各種不同。實際上每個鎖(每個 AQS 接口的實現類)就是在重寫 AQS 的 tryAcquire()tryRelease() 方法,其他的都依賴於 AQS 接口代碼。
  • AQS 有兩個很重要的變量,分別是隊列的狀態 state,以及隊列節點的狀態 waitStatus
    • state:0 代表鎖沒有被佔用,1 代表有線程正在佔用鎖,1 往上代表有線程正在重入佔用鎖
    • waitStatus:0 代表初始化,大於 0 代表該節點取消了等待,-1 代表後繼節點需要被喚醒

ReentrantLock

不容易呀,終於到了ReentrantLock,ReentrantLock 的字面意義是可重入鎖,代表線程可以多次執行 lock() 方法佔有鎖,不會導致死鎖問題。ReentrantLock 允許公平鎖,只要在構造方法中傳入 true(new ReentrantLock(true))即可。公平鎖的意思是,當多個線程獲取鎖時,按照先來後到的順序,先申請鎖的線程一定先得到鎖,後申請鎖的線程一定後得到鎖。如果是非公平鎖,那麼各個線程獲取到鎖的順序是“隨機”的。對於 ReentrantLock 的非公平鎖而言,後到的線程可以先試着獲取一次鎖,獲取到了就直接返回,獲取不到就跟公平鎖一樣在後面排隊。ReentrantLock 實現公平鎖和非公平鎖的方式,是在內部維護兩種 AQS 隊列。

// 非公平鎖(Sync是一個AQS隊列)
static final class NonfairSync extends Sync {...}
// 公平鎖
static final class FairSync extends Sync {...}

經過剛纔對 AQS 的學習,我們知道學習鎖實際上只需要看 tryAcquire() 和 tryRelease() 方法,其他都交由 AQS 接口就可以了。

上鎖 tryAcquire()

公平鎖

// 嘗試直接獲取鎖,返回值是boolean,代表是否獲取到鎖
// 返回true:1.沒有線程在等待鎖;2.重入鎖,線程本來就持有鎖,也就可以理所當然可以直接獲取
protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    // state == 0 此時此刻沒有線程持有鎖
    if (c == 0) {
        // 雖然此時此刻鎖是可以用的,但是這是公平鎖,既然是公平,就得講究先來後到,
        // 看看有沒有別人在隊列中等了半天了
        if (!hasQueuedPredecessors() &&
            // 如果沒有線程在等待,那就用CAS嘗試一下,成功了就獲取到鎖了,
            // 不成功的話,只能說明一個問題,就在剛剛幾乎同一時刻有個線程搶先了 =_=
            // 因爲剛剛還沒人的,我判斷過了
            compareAndSetState(0, acquires)) {

            // 到這裏就是獲取到鎖了,標記一下,告訴大家,現在是我佔用了鎖
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    // 會進入這個else if分支,說明是重入了,需要操作:state=state+1
    // 這裏不存在併發問題
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    // 如果到這裏,說明前面的if和else if都沒有返回true,說明沒有獲取到鎖
    // 回到上面一個外層調用方法(AQS的acquire()方法)繼續看:
    // if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) 
    //     selfInterrupt();
    return false;
}

非公平鎖

protected final boolean tryAcquire(int acquires) {
    // 調用了nonfairTryAcquire()方法,往下看
    return nonfairTryAcquire(acquires);
}

final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        // 與公平鎖相比,只有這裏有區別
        // 非公平鎖不會先判斷AQS隊列中是否有等候的節點,而是直接試着獲取一次鎖
        // 如果這次嘗試獲取不到,則和公平鎖一樣尾插隊列
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

公平鎖和非公平鎖只有兩點區別

  • 非公平鎖實際上會先 CAS 獲取一次鎖,如果失敗則調用 AQS 的 acquire() 方法(這段上面沒提)
// 非公平鎖的lock()方法(會先CAS獲取一次鎖,獲取不到再走AQS接口)
final void lock() {
    if (compareAndSetState(0, 1))
        setExclusiveOwnerThread(Thread.currentThread());
    else
        acquire(1);
}

// 公平鎖的lock()方法
final void lock() {
    acquire(1);
}
  • 在首次試着獲取鎖失敗的情況下,非公平鎖會在 tryAcquire() 方法中再試着獲取一次鎖,但是公平鎖會嚴格地按照先來後到的順序獲取

可以總結出來,非公平鎖比公平鎖多嘗試獲取了兩次鎖,如果成功就不用進入隊列了。這樣可以提高併發的線程吞吐量,但是有可能導致先等待的線程一直獲取不到鎖。

解鎖 tryRelease()

公平鎖和非公平鎖,共用一套解鎖方法,也就是 Lock#unlock() -> AQS#release() -> Lock#tryRelease() -> AQS#unparkSuccessor(),其中 tryRelease() 方法是交由實現類 ReentrantLock 去重寫的(不明白的話回到上面看一看 AQS 的解鎖邏輯)。ReentrantLock 重寫的 tryRelease() 方法的代碼如下:

protected final boolean tryRelease(int releases) {
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    // 是否完全釋放鎖
    boolean free = false;
    // 處理重入的問題,如果c==0,也就是說沒有嵌套鎖了,可以釋放了,否則還不能釋放掉
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    setState(c);
    return free;
}

ReentrantLock 作爲可重入鎖,每次上鎖就使 AQS 隊列的狀態(初始化是 0)增加 1,解鎖使狀態減少 1,如果 AQS 隊列的狀態變爲 0 了,就代表沒有線程持有鎖。

ReentrantLock使用

這裏模擬售票,通過ReentrantLock的方式實現線程的安全

public class LockMain {
    public static void main(String[] args) {
        Window window = new Window();
        Thread thread1 = new Thread(window);
        Thread thread2 = new Thread(window);
        Thread thread3 = new Thread(window);
        thread1.start();
        thread2.start();
        thread3.start();
    }
}


/**
 * 售票窗口
 */
class Window implements Runnable{

    private volatile int num = 100;
    ReentrantLock lock = new ReentrantLock();
    @Override
    public void run() {
        while (true){
            lock.lock();
            try {
                if (num > 0){
                    System.out.println(Thread.currentThread().getName()+"窗口在售票,票號爲"+ num);
                    num --;
                }else {
                    break;
                }
            }finally {
                lock.unlock();
            }

        }
    }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章