1.5 w字、16 張圖,輕鬆入門 RLock+AQS 併發程式設計原理

熬夜不加班發表於2021-05-19

前言

AbstractQueuedSynchronizer(AQS)是 Java 併發程式設計中繞不過去的一道坎,JUC 併發包下的 Lock、Semaphore、ReentrantLock 等都是基於 AQS 實現的。AQS 是一個抽象的同步框架,提供了原子性管理同步狀態,基於阻塞佇列模型實現阻塞和喚醒等待執行緒的功能

文章從 ReentrantLock 加鎖、解鎖應用 API 入手,逐步講解 AQS 對應原始碼以及相關隱含流程

列出本篇文章大綱以及相關知識點,方便大家更好的理解

image
image

什麼是 ReentrantLock

ReentrantLock 翻譯為  可重入鎖,指的是一個執行緒能夠對  臨界區共享資源進行重複加鎖

確保執行緒安全最常見的做法是利用鎖機制(Lock、sychronized)來對  共享資料做互斥同步,這樣在同一個時刻,只有  一個執行緒可以執行某個方法或者某個程式碼塊,那麼操作必然是  原子性的,執行緒安全的

這裡就有個疑問,因為 JDK 中關鍵字  synchronized 也能同時支援原子性以及執行緒安全

有了 synchronized 關鍵字後為什麼還需要 ReentrantLock?

為了大家更好的掌握 ReentrantLock 原始碼,這裡列出兩種鎖之間的區別

image.png
image.png

透過以上六個維度比對,可以看出 ReentrantLock 是要比 synchronized  靈活以及支援功能更豐富

什麼是 AQS

AQS( AbstractQueuedSynchronizer )是一個用來構建鎖和同步器的抽象框架,只需要繼承 AQS 就可以很方便的實現我們自定義的多執行緒同步器、鎖


image.png
image.png

如圖所示,在  java.util.concurrent 包下相關鎖、同步器(常用的有 ReentrantLock、 ReadWriteLock、CountDownLatch...)都是基於 AQS 來實現

AQS 是典型的模板方法設計模式,父類(AQS)定義好骨架和內部操作細節,具體規則由子類去實現

AQS 核心原理

如果被請求的共享資源未被佔用,將當前請求資源的執行緒設定為獨佔執行緒,並將共享資源設定為鎖定狀態

AQS 使用一個 Volatile 修飾的 int 型別的成員變數 State 來表示同步狀態,修改同步狀態成功即為獲得鎖

Volatile 保證了變數在多執行緒之間的可見性,修改 State 值時透過 CAS 機制來保證修改的原子性

如果共享資源被佔用,需要一定的阻塞等待喚醒機制來保證鎖的分配,AQS 中會將競爭共享資源失敗的執行緒新增到一個變體的 CLH 佇列中

image.png
image.png

關於支撐 AQS 特性的重要方法及屬性如下:

public abstract class AbstractQueuedSynchronizer 
  extends AbstractOwnableSynchronizer implements java.io.Serializable {   // CLH 變體佇列頭、尾節點
    private transient volatile Node head;   private transient volatile Node tail;   // AQS 同步狀態
    private volatile int state;   // CAS 方式更新 state
   protected final boolean compareAndSetState(int expect, int update) {        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
    }
}

CLH 佇列

既然是 AQS 中使用的是 CLH 變體佇列,我們先來了解下 CLH 佇列是什麼

CLH:Craig、Landin and Hagersten 佇列,是  單向連結串列實現的佇列。申請執行緒只在本地變數上自旋, 它不斷輪詢前驅的狀態,如果發現  前驅節點釋放了鎖就結束自旋

image
image

透過對 CLH 佇列的說明,可以得出以下結論

  1. CLH 佇列是一個單向連結串列,保持 FIFO 先進先出的佇列特性
  2. 透過 tail 尾節點(原子引用)來構建佇列,總是指向最後一個節點
  3. 未獲得鎖節點會進行自旋,而不是切換執行緒狀態
  4. 併發高時效能較差,因為未獲得鎖節點不斷輪訓前驅節點的狀態來檢視是否獲得鎖

AQS 中的佇列是 CLH 變體的虛擬雙向佇列,透過將每條請求共享資源的執行緒封裝成一個節點來實現鎖的分配

image.png
image.png

相比於 CLH 佇列而言,AQS 中的 CLH 變體等待佇列擁有以下特性

  1. AQS 中佇列是個雙向連結串列,也是 FIFO 先進先出的特性
  2. 透過 Head、Tail 頭尾兩個節點來組成佇列結構,透過 volatile 修飾保證可見性
  3. Head 指向節點為已獲得鎖的節點,是一個虛擬節點,節點本身不持有具體執行緒
  4. 獲取不到同步狀態,會將節點進行自旋獲取鎖,自旋一定次數失敗後會將執行緒阻塞,相對於 CLH 佇列效能較好

認識 AOS

抽象類 AQS 同樣繼承自抽象類 AOS(AbstractOwnableSynchronizer)

AOS 內部只有一個 Thread 型別的變數,提供了獲取和設定當前獨佔鎖執行緒的方法

主要作用是  記錄當前佔用獨佔鎖(互斥鎖)的執行緒例項

public abstract class AbstractOwnableSynchronizer implements java.io.Serializable {    // 獨佔執行緒(不參與序列化)
    private transient Thread exclusiveOwnerThread;    // 設定當前獨佔的執行緒
    protected final void setExclusiveOwnerThread(Thread thread) {
        exclusiveOwnerThread = thread;
    }    // 返回當前獨佔的執行緒
    protected final Thread getExclusiveOwnerThread() {        return exclusiveOwnerThread;
    }
}

為什麼要掌握 AQS

如何能夠體現程式設計師的水平,那就是掌握大多數人所不掌握的技術,這也是為什麼面試時 AQS 高頻出現的原因,因為它不簡單

最初接觸 ReentrantLock 以及 AQS 的時候,看到原始碼就是一頭霧水,Debug 跟著跟著就  迷失了自己,相信這也是大多數人的反應

正是因為經歷過,所以才能從小白的心理上出發,把其中的知識點能夠盡數梳理

獨佔加鎖原始碼解析

什麼是獨佔鎖

獨佔鎖也叫排它鎖,是指該鎖一次只能被一個執行緒所持有,如果別的執行緒想要獲取鎖,只有等到持有鎖執行緒釋放

獲得排它鎖的執行緒即能讀資料又能修改資料,與之對立的就是共享鎖

共享鎖是指該鎖可被多個執行緒所持有。如果執行緒T對資料A加上共享鎖後,則其他執行緒只能對A再加共享鎖,不能加排它鎖

獲得共享鎖的執行緒只能讀資料,不能修改資料

獨佔鎖加鎖

ReentrantLock 就是獨佔鎖的一種實現方式,接下來看程式碼中如何使用 ReentrantLock 完成獨佔式加鎖業務邏輯

public static void main(String[] args) {    // 建立非公平鎖
    ReentrantLock lock = new ReentrantLock();    // 獲取鎖操作
    lock.lock();    try {        // 執行程式碼邏輯
    } catch (Exception ex) {        // ...
    } finally {        // 解鎖操作
        lock.unlock();
    }
}

new ReentrantLock() 建構函式預設建立的是非公平鎖 NonfairSync

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

同時也可以在建立鎖建構函式中傳入具體引數建立公平鎖 FairSync

ReentrantLock lock = new ReentrantLock(true);
--- ReentrantLock// true 代表公平鎖,false 代表非公平鎖public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

FairSync、NonfairSync 代表公平鎖和非公平鎖,兩者都是 ReentrantLock 靜態內部類,只不過實現不同鎖語義

公平鎖 FairSync

  1. 公平鎖是指多個執行緒按照申請鎖的順序來獲取鎖,執行緒直接進入佇列中排隊,佇列中的第一個執行緒才能獲得鎖
  2. 公平鎖的優點是等待鎖的執行緒不會餓死。缺點是整體吞吐效率相對非公平鎖要低,等待佇列中除第一個執行緒以外的所有執行緒都會阻塞,CPU 喚醒阻塞執行緒的開銷比非公平鎖大

非公平鎖 NonfairSync

  1. 非公平鎖是多個執行緒加鎖時直接嘗試獲取鎖,獲取不到才會到等待佇列的隊尾等待。但如果此時鎖剛好可用,那麼這個執行緒可以無需阻塞直接獲取到鎖
  2. 非公平鎖的優點是可以減少喚起執行緒的開銷,整體的吞吐效率高,因為執行緒有機率不阻塞直接獲得鎖,CPU 不必喚醒所有執行緒。缺點是處於等待佇列中的執行緒可能會餓死,或者等很久才會獲得鎖

兩者的都繼承自 ReentrantLock 靜態抽象內部類 Sync, Sync 類繼承自 AQS,這裡就有個疑問

這些鎖都沒有直接繼承 AQS,而是定義了一個  Sync 類去繼承 AQS,為什麼要這樣呢?

因為  鎖面向的是使用使用者同步器面向的則是執行緒控制,那麼在鎖的實現中聚合同步器而不是直接繼承 AQS 就可以很好的  隔離二者所關注的事情

透過對不同鎖種類的講解以及 ReentrantLock 內部結構的解析,根據上下級關係繼承圖,加深其理解

image
image

這裡以非公平鎖舉例,檢視加鎖的具體過程,詳細資訊下文會詳細說明

image
image

看一下非公平鎖加鎖方法 lock 內部怎麼做的

ReentrantLock lock = new ReentrantLock();
lock.lock();
--- ReentrantLockpublic void lock() {
    sync.lock();
}
--- Syncabstract void lock();

Sync#lock 為抽象方法,最終會呼叫其子類非公平鎖的方法 lock

final void lock() {    if (compareAndSetState(0, 1))
        setExclusiveOwnerThread(Thread.currentThread());    else
        acquire(1);
}

非公平加鎖方法有兩個邏輯

  1. 透過比較並替換 State(同步狀態)成功與否決定是否獲得鎖,設定 State 為 1表示成功獲取鎖,並將當前執行緒設定為獨佔執行緒
  2. 修改 State 值失敗則進入嘗試獲取鎖流程,acquire 方法為 AQS 提供的方法

compareAndSetState 以 CAS 比較並替換的方式將 State 值設定為 1,表示同步狀態被佔用

protected final boolean compareAndSetState(int expect, int update) {    // See below for intrinsics setup to support this
    return unsafe.compareAndSwapInt(this, StateOffset, expect, update);
}

setExclusiveOwnerThread 設定當前執行緒為獨佔鎖擁有執行緒

protected final void setExclusiveOwnerThread(Thread thread) {
    exclusiveOwnerThread = thread;
}

acquire 對整個 AQS 做到了承上啟下的作用,透過 tryAcquire 模版方法進行嘗試獲取鎖,獲取鎖失敗包裝當前執行緒為 Node 節點加入等待佇列排隊

public final void acquire(int arg) {    if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

tryAcquire 是 AQS 中抽象模版方法,但是內部會有預設實現,雖然預設的方法內部丟擲異常, 為什麼不直接定義為抽象方法呢?

因為 AQS 不只是對獨佔鎖實現了抽象,同時還包括共享鎖;不同鎖定義了不同類別的方法,共享鎖就不需要 tryAcquire,如果定義為抽象方法,繼承 AQS 子類都需要實現該方法

protected boolean tryAcquire(int arg) {    throw new UnsupportedOperationException();
}

NonfairSync 類中有 tryAcquire 重寫方法,繼續檢視具體如何進行非公平方式獲取鎖

protected final boolean tryAcquire(int acquires) {    return nonfairTryAcquire(acquires);
}final boolean nonfairTryAcquire(int acquires) {    final Thread current = Thread.currentThread();    int c = getState();   // State 等於0表示此時無鎖
    if (c == 0) {       // 再次使用CAS嘗試獲取鎖, 表現為非公平鎖特性
        if (compareAndSetState(0, acquires)) {           // 設定執行緒為獨佔鎖執行緒
            setExclusiveOwnerThread(current);            return true;
        }    // 如果當前執行緒等於已獲取鎖執行緒, 表現為可重入鎖特性
    } else if (current == getExclusiveOwnerThread()) {        int nextc = c + acquires;        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");       // 設定 State
        setState(nextc);        return true;
    }   // 如果state不等於0並且獨佔執行緒不是當前執行緒, 返回 false
    return false;
}

由於 tryAcquire 做了取反,如果設定 state 失敗並且獨佔鎖執行緒不是自己本身返回 false,透過取反會進入接下來的流程

public final void acquire(int arg) {    if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

Node 入隊流程

嘗試獲得鎖失敗,接下來會將執行緒組裝成為 Node 進行入隊流程

Node 是 AQS 中最基本的資料結構,也是 CLH 變體佇列中的節點,Node 有  SHARED(共享)、EXCLUSIVE(獨佔) 兩種模式,文章主要介紹 EXCLUSIVE 模式,不相關的屬性和方法不予介紹

下面列出關於 Node EXCLUSIVE 模式的一些關鍵方法以及狀態資訊

image
image

Node 中獨佔鎖相關的 waitStatus 屬性分別有以下幾種狀態

image.png
image.png

介紹完 Node 相關基礎知識,看一下請求鎖執行緒如何被包裝為 Node,又是如何初始化入隊的

private Node addWaiter(Node mode) {
    Node node = new Node(Thread.currentThread(), mode);    // 獲取等待佇列的尾節點
    Node pred = tail;   // 如果尾節點不為空, 將 node 設定為尾節點, 並將原尾節點 next 指向 新的尾節點node
    if (pred != null) {
        node.prev = pred;        if (compareAndSetTail(pred, node)) {
            pred.next = node;            return node;
        }
    }   // 尾部為空,enq 執行
    enq(node);    return node;
}

pred 為佇列的尾節點,根據尾節點是否為空會執行對應流程

  1. 尾節點不為空,證明佇列已被初始化,那麼需要將對應的 node(當前執行緒)設定為新的尾節點,也就是入隊操作;將 node 節點的前驅指標指向 pred(尾節點),並將 node 透過 CAS 方式設定為 AQS 等待佇列的尾節點,替換成功後將原來的尾節點後繼指標指向新的尾節點
  2. 尾節點為空,證明還沒有初始化佇列,執行 enq 方法進行初始化佇列

enq 方法執行初始化佇列操作,等待佇列中虛擬化的頭節點也是在這裡產生

private Node enq(final Node node) {    for (; ; ) {
        Node t = tail;        if (t == null) {           // 虛擬化一個空Node, 並將head指向空Node
            if (compareAndSetHead(new Node()))               // 將尾節點等於頭節點
                tail = head;
        } else {           // node上一條指向尾節點
            node.prev = t;           // 設定node為尾節點
            if (compareAndSetTail(t, node)) {               // 設定原尾節點的下一條指向node
                t.next = node;                return t;
            }
        }
    }
}

執行 enq 方法的前提就是佇列尾節點為空,為什麼還要再判斷尾節點是否為空?

因為 enq 方法中是一個死迴圈,迴圈過程中 t 的值是不固定的。假如執行 enq 方法時佇列為空,for 迴圈會執行兩遍不同的處理邏輯

  1. 尾節點為空,虛擬化出一個新的 Node 頭節點,這時佇列中只有一個元素,為了保證 AQS 佇列結構的完整性,會將尾節點指向頭節點,第一遍迴圈結束
  2. 第二遍不滿足尾節點為空條件,執行 else 語句塊,node 節點前驅指標指向尾節點,並將 node 透過 CAS 設定為新的尾節點,成功後設定原尾節點的後繼指標指向 node,至此入隊成功。返回的 t 無意義,只是為了終止死迴圈

畫兩張圖來理解 enq 方法整體初始化 AQS 佇列流程,假設T1、T2兩個執行緒爭取鎖,T1成功獲得鎖,T2進行入隊操作

  1. T2進行入隊操作,迴圈第一遍,尾節點為空。開始初始化頭節點,並將尾節點指向頭節點,最終佇列形式是這樣紙滴
image
image
  1. 迴圈第二遍,需要將 node 設定為新的尾節點。邏輯如下:尾節點不為空,設定 node 前驅指標指向尾節點,並將 node 設定為尾節點,原尾節點 next 指標指向 node

addWaiter 方法就是為了讓 Node 入隊,並且維護出一個雙向佇列模型

入隊執行成功後,會在 acquireQueued 再次嘗試競爭鎖,競爭失敗後會將執行緒阻塞

public final void acquire(int arg) {    if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

acquireQueued 方法會嘗試自旋獲取鎖,獲取失敗對當前執行緒實施阻塞流程,這也是為了避免無意義的自旋,對比 CLH 佇列效能最佳化的體現

final boolean acquireQueued(final Node node, int arg) {    boolean failed = true;    try {        boolean interrupted = false;        for (; ; ) {           // 獲取node上一個節點
            final Node p = node.predecessor();           // 如果node為頭節點 & 嘗試獲取鎖成功
            if (p == head && tryAcquire(arg)) {               // 此時當前node執行緒獲取到了鎖
               // 將node設定為新的頭節點
                setHead(node);               // help GC
                p.next = null;
                failed = false;                return interrupted;
            }            if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {        if (failed)
            cancelAcquire(node);
    }
}

透過 node.predecessor() 獲取節點的前驅節點,前驅節點為空丟擲空指標異常

final Node predecessor() throws NullPointerException {
    Node p = prev;    if (p == null)        throw new NullPointerException();    else
        return p;
}

獲取到前驅節點後進行兩步邏輯判斷

  1. 判斷前驅節點 p 是否為頭節點,為 true 進行嘗試獲取鎖,獲取鎖成功設定當前節點為新的頭節點,並將原頭節點的後驅指標設為空
  2. 前驅節點不是頭節點或者嘗試加鎖失敗,執行執行緒休眠阻塞操作

如果 node 獲得鎖後,setHead 將節點設定為佇列頭,從而實現出隊效果,出於 GC 的考慮,清空未使用的資料

private void setHead(Node node) {
    head = node;
    node.thread = null;
    node.prev = null;
}

shouldParkAfterFailedAcquire 需要重點關注下,流程相對比較難理解

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {    int ws = pred.waitStatus;    if (ws == Node.SIGNAL)        return true;    if (ws > 0) {        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }    return false;
}

ws 表示為當前申請鎖節點前驅節點的等待狀態,程式碼中包含三個邏輯,分別是:

  1. ws == Node.SIGNAL,表示需要將申請鎖節點進行阻塞
  2. ws > 0,表示等待佇列中包含被取消節點,需要調整佇列
  3. 如果 ws == Node.SIGNAL || ws >0 都為 false,使用 CAS 的方式將前驅節點等待狀態設定為 Node.SIGNAL

設定當前節點的前置節點等待狀態為 Node.SIGNAL,表示當前節點獲取鎖失敗,需要進行阻塞操作

還是透過幾張圖來理解流程,假設此時 T1、T2 執行緒來爭奪鎖

image
image

T1 執行緒獲得鎖,T2 進入 AQS 等待佇列排隊,並透過 CAS 將 T2 節點的前驅節點等待狀態置為 SIGNAL

image
image

執行切換前驅節點等待狀態後返回 false,繼續進行迴圈嘗試獲取同步狀態

這一步操作保證了執行緒能進行多次重試,儘量避免執行緒狀態切換

如果 T1 執行緒沒有釋放鎖,T2 執行緒第二次執行到 shouldParkAfterFailedAcquire 方法,因為前驅節點已設定為 SIGNAL,所以會直接返回 true,執行執行緒阻塞操作

private final boolean parkAndCheckInterrupt() {   // 將當前執行緒進行阻塞
    LockSupport.park(this);   // 方法返回了當前執行緒的中斷狀態,並將當前執行緒的中斷標識設定為false
    return Thread.interrupted();
}

LockSupport.park 方法將當前等待佇列中執行緒進行阻塞操作,執行緒執行一個從 RUNNABLE 到 WAITING 狀態轉變

如果執行緒被喚醒,透過執行 Thread.interrupted 檢視中斷狀態,這裡的中斷狀態會被傳遞到 acquire 方法

public final void acquire(int arg) {    if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))       // 如果執行緒被中斷, 這裡會再次設定中斷狀態
       // 因為如果執行緒中斷, 呼叫 Thread.interrupted 雖然會返回 true, 但是會清除執行緒中斷狀態
        selfInterrupt();
}

即使執行緒從 park 方法中喚醒後發現自己被中斷了,但是不影響接下來的獲取鎖操作,如果需要設定執行緒中斷來影響流程,可以使用 lockInterruptibly 獲得鎖,丟擲檢查異常 InterruptedException

cancelAcquire

取消排隊方法是 AQS 中比較難的知識點,不容易被理解

當執行緒因為自旋或者異常等情況獲取鎖失敗,會呼叫此方法進行取消正在獲取鎖的操作

private void cancelAcquire(Node node) {    // 不存在的節點直接返回
    if (node == null)        return;
    node.thread = null;    /**
     * waitStatus > 0 代表節點為取消狀態
     * while迴圈會將node節點的前驅指標指向一個非取消狀態的節點
     * pred等於當前節點的前驅節點(非取消狀態)
     */
    Node pred = node.prev;    while (pred.waitStatus > 0)
        node.prev = pred = pred.prev;    // 獲取過濾後的前驅節點的後繼節點
    Node predNext = pred.next;    // 設定node等待狀態為取消狀態
    node.waitStatus = Node.CANCELLED;    // 步驟一,如果node是尾節點,使用CAS將pred設定為新的尾節點
    if (node == tail && compareAndSetTail(node, pred)) {       // 設定pred(新tail)的後驅指標為空
        compareAndSetNext(pred, predNext, null);
    } else {        int ws;       // 步驟二,node的前驅節點pred(非取消狀態)!= 頭節點
        if (pred != head 
             /**
              * 1. pred等待狀態等於SIGNAL
              * 2. ws <= 0並且設定pred等待狀態為SIGNAL
              */
             && ((ws = pred.waitStatus) == Node.SIGNAL || (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) 
             // pred中執行緒不為空
             && pred.thread != null) {
            Node next = node.next;           /**
            * 1. 當前節點的後繼節點不為空
            * 2. 後繼節點等待狀態<=0(表示非取消狀態)
            */
            if (next != null && next.waitStatus <= 0)               // 設定pred的後繼節點設定為當前節點的後繼節點
                compareAndSetNext(pred, predNext, next);
        } else {           // 步驟三,如果當前節點為頭節點或者上述條件不滿足, 執行喚醒當前節點的後繼節點流程
            unparkSuccessor(node);
        }
        node.next = node; // help GC
    }
}

邏輯稍微複雜一些,比較重要是以下三個邏輯

  1. 步驟一當前節點為尾節點的話,設定 pred 節點為新的尾節點,成功設定後再將 pred 後繼節點設定為空(尾節點不會有後繼節點)
  2. 步驟二需要滿足以下四個條件才會將前驅節點(非取消狀態)的後繼指標指向當前節點的後繼指標1)當前節點不等於尾節點2)當前節點前驅節點不等於頭節點3)前驅節點的等待狀態不為取消狀態4)前驅節點的擁有執行緒不為空
  3. 如果不滿足步驟二的話,會執行步驟三相關邏輯,喚醒後繼節點

步驟一:

假設當前取消節點為尾節點並且前置節點無取消節點,現有等待佇列如下圖,執行下述邏輯

if (node == tail && compareAndSetTail(node, pred)) {
    compareAndSetNext(pred, predNext, null);
}
image
image

將 pred 設定為新的尾節點,並將 pred 後繼節點設定為空,因為尾節點不會有後繼節點了

T4 執行緒所在節點因無引用指向,會被 GC 垃圾回收處理

image.png
image.png

步驟二:

如果當前需要取消節點的前驅節點為取消狀態節點,如圖所示

image.png
image.png

設定 pred(非取消狀態)的後繼節點為 node 的後繼節點,並設定 node 的 next 為 自己本身

image.png
image.png

執行緒T2、T3所在節點因為被T4所直接或間接指向,如何進行GC?

AQS 等待佇列中取消狀態節點會在 shouldParkAfterFailedAcquire 方法中被 GC 垃圾回收

if (ws > 0) {    do {
        node.prev = pred = pred.prev;
    } while (pred.waitStatus > 0);
    pred.next = node;
}

T4 執行緒所在節點獲取鎖失敗嘗試停止時,會執行上述程式碼,執行後的等待佇列如下圖所示

image.png
image.png

等待佇列中取消狀態節點就可以被 GC 垃圾回收了,至此加鎖流程也就結束了,下面繼續看如何解鎖

獨佔解鎖原始碼解析

解鎖流程相對於加鎖簡單了很多,呼叫對應API-lock.unlock()

--- ReentrantLockpublic void unlock() {
    sync.release(1);
}
--- AQSpublic final boolean release(int arg) {   // 嘗試釋放鎖
    if (tryRelease(arg)) {
        Node h = head;        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);        return true;
    }    return false;
}

釋放鎖同步狀態

tryRelease 是定義在 AQS 中的抽象方法,透過 Sync 類重寫了其實現

protected final boolean tryRelease(int releases) {    int c = getState() - releases;   // 如果當前執行緒不等於擁有鎖執行緒, 丟擲異常
    if (Thread.currentThread() != getExclusiveOwnerThread())        throw new IllegalMonitorStateException();    boolean free = false;    if (c == 0) {
        free = true;       // 將擁有鎖執行緒設定為空
        setExclusiveOwnerThread(null);
    }   // 設定State狀態為0, 解鎖成功
    setState(c);    return free;
}

喚醒後繼節點

此時 State 值已被釋放,對於頭節點的判斷這塊流程比較有意思

Node h = head;if (h != null && h.waitStatus != 0)
  unparkSuccessor(h);

什麼情況下頭節點為空,當執行緒還在爭奪鎖,佇列還未初始化,頭節點必然是為空的

當頭節點等待狀態等於0,證明後繼節點還在自旋,不需要進行後繼節點喚醒

如果同時滿足上述兩個條件,會對等待佇列頭節點的後繼節點進行喚醒操作

private void unparkSuccessor(Node node) {   // 獲取node等待狀態
    int ws = node.waitStatus;    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);   // 獲取node的後繼節點
    Node s = node.next;   // 如果下個節點為空或者被取消, 遍歷佇列查詢非取消節點
    if (s == null || s.waitStatus > 0) {
        s = null;       // 從隊尾開始查詢, 等待狀態 <= 0 的節點
        for (Node t = tail; t != null && t != node; t = t.prev)            if (t.waitStatus <= 0)
                s = t;
    }   // 滿足 s != null && s.waitStatus <= 0
   // 執行 unpark
    if (s != null)
        LockSupport.unpark(s.thread);
}

為什麼查詢佇列中未被取消的節點需要從尾部開始?

這個問題有兩個原因可以解釋,分別是 Node 入隊和清理取消狀態的節點

  1. 先從 addWaiter 入隊時說起,compareAndSetTail(pred, node)、pred.next = node 並非原子操作,如果在執行 pred.next = node 前進行 unparkSuccessor,就沒有辦法透過 next 指標向後遍歷,所以才會從後向前找尋非取消的節點
  2. cancelAcquire 方法也有導致使用 head 無法遍歷全部 Node 的因素,因為先斷開的是 next 指標,prev 指標並未斷開

喚醒阻塞後流程

當執行緒獲取鎖失敗被 park 後進入了阻塞模式,前驅節點釋放鎖後會進行喚醒 unpark,被阻塞執行緒狀態迴歸 RUNNABLE 狀態

private final boolean parkAndCheckInterrupt() {   // 從此位置喚醒
    LockSupport.park(this);    return Thread.interrupted();
}

被喚醒執行緒檢查自身是否被中斷,返回自身中斷狀態到 acquireQueued

final boolean acquireQueued(final Node node, int arg) {    boolean failed = true;    try {        boolean interrupted = false;        for (; ; ) {            final Node p = node.predecessor();            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;                return interrupted;
            }            if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {        if (failed)
            cancelAcquire(node);
    }
}

假設自身被中斷,設定 interrupted = true,繼續透過迴圈嘗試獲取鎖,獲取鎖成功後返回 interrupted 中斷狀態

中斷狀態本身並不會對加鎖流程產生影響,被喚醒後還是會不斷進行獲取鎖,直到獲取鎖成功進行返回,返回中斷狀態是為了後續補充中斷紀錄

如果執行緒被喚醒後發現中斷,成功獲取鎖後會將中斷狀態返回,補充中斷狀態

public final void acquire(int arg) {    if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

selfInterrupt 就是對執行緒中斷狀態的一個補充,補充狀態成功後,流程結束

static void selfInterrupt() {
    Thread.currentThread().interrupt();
}

閱讀原始碼小技巧

1、從全域性掌握要閱讀的原始碼提供了什麼功能

這也是我一直推崇的學習原始碼方式,學習原始碼的關鍵點是抓住主線流程,在瞭解主線之前不要最開始就研究到原始碼實現細節中,否則很容易迷失在細枝末節的程式碼中

以文章中的 AQS 舉例,當你知道了它是一個抽象佇列同步器,使用它可以更簡單的構造鎖和同步器等實現

然後從中理解 tryAcquire、tryRelease 等方法實現,這樣是不是可以更好的理解與 AQS 與其子類相關的程式碼

2、把不易理解的原始碼貼上出來,整理好格式打好備註

一般原始碼中的行為格式和我們日常敲程式碼是不一樣的,而且 JDK 原始碼中的變數命名實在是慘不忍睹

所以就應該將難以理解的原始碼貼上出,標上對應註釋以及調整成易理解的格式,這樣對於原始碼的閱讀就會輕鬆很多


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70000181/viewspace-2773023/,如需轉載,請註明出處,否則將追究法律責任。

相關文章