美團後臺篇中的ReentrantLock

Montos發表於2020-02-10

之前的文章中,簡單的介紹了ReentrantLock鎖。那麼這裡我就要進行裡面的方法以及屬性介紹啦(此文章基於裡面的非公平鎖進行說明)!!!

ReentrantLock

ReentrantLock 特性概覽

ReentrantLock 意思為可重入鎖,指的是一個執行緒能夠對一個臨界資源重複加鎖。這裡就對ReentrantLock 跟常用的 Synchronized 進行比較。

ReentrantLock Synchronized
鎖實現機制 依賴AQS 監視器模式
靈活性 支援響應中斷、超時、嘗試獲取鎖 不靈活
釋放形式 必須顯示呼叫unlock()進行解鎖 自動釋放監視器
鎖型別 必須顯示呼叫unlock()進行解鎖 自動釋放監視器
條件佇列 可關聯多個條件佇列 關聯一個條件佇列
可重入性 可重入 可重入

ReentrantLock 與 AQS 的關聯

final void lock() {
          if (compareAndSetState(0, 1)) // 設定同步狀態
              setExclusiveOwnerThread(Thread.currentThread());//當前執行緒設定為獨佔執行緒。
          else
              acquire(1);// 設定失敗,進入acquire 方法進行後續處理。
      }
複製程式碼

上面的程式碼就是非公平鎖加鎖的方法。主要是做了兩點:

  • 若通過 CAS 設定變數 State(同步狀態)成功,也就是獲取鎖成功,則將當前 執行緒設定為獨佔執行緒。
  • 若通過 CAS 設定變數 State(同步狀態)失敗,也就是獲取鎖失敗,則進入 Acquire 方法進行後續處理。

如果設定同步狀態失敗,則會進入到對應的acquire()方法中去進行加鎖處理。而acquire()無論是非公平鎖或公平鎖,最後呼叫的都是父類中的方法。

AQS(AbstractQueuedSynchronizer)

先通過下面的架構圖來整體瞭解一下 AQS 框架:

美團後臺篇中的ReentrantLock

  • 圖中有顏色的為 Method,無顏色的為 Attribution
  • 當有自定義同步器接入時,只需重寫第一層所需要的部分方法即可,不需要關 注底層具體的實現流程。當自定義同步器進行加鎖或者解鎖操作時,先經過第 一層的 API 進入 AQS 內部方法,然後經過第二層進行鎖的獲取,接著對於獲 取鎖失敗的流程,進入第三層和第四層的等待佇列處理,而這些處理方式均依 賴於第五層的基礎資料提供層。

AQS原理概覽:

如果被請求的共享資源空閒,那麼就將當前請求資源的執行緒設定為有效的工作執行緒,將共享資源設定為鎖定狀態;如果共享資源被佔用,就需要一定的阻塞等待喚醒機制來保證鎖分配。這個機制主要用的是 CLH 佇列的變體實現的,將暫時獲取不到鎖的執行緒加入到佇列中。

CLH:Craig、Landin and Hagersten 佇列,是單向連結串列,AQS 中的佇列是CLH變體的虛擬雙向佇列(FIFO),AQS 是通過將每條請求共享資源的執行緒封裝成一個節點來實現鎖的分配。

美團後臺篇中的ReentrantLock
AQS 使用一個 Volatileint 型別的成員變數來表示同步狀態,通過內建的 FIFO 佇列來完成資源獲取的排隊工作,通過 CAS 完成對 State 值的修改。

AQS資料結構:

AQS中最基本的資料結構是-節點。內含方法如下:

美團後臺篇中的ReentrantLock
解釋一下幾個方法和屬性值的含義:

方法和屬性值 含義
waitStatus 當前節點在佇列中的狀態
thread 表示處於該節點的執行緒
prev 前驅指標
predecessor 返回前驅節點,沒有的話丟擲 NPE
nextWaiter 指向下一個處於 CONDITION 狀態的節點(由於本篇文章不講述 ConditionQueue 佇列,這個指標不多介紹)
next 後繼指標

執行緒兩種鎖的模式:

模式 含義
SHARED 表示執行緒以共享的模式等待鎖
EXCLUSIVE 表示執行緒正在以獨佔的方式等待鎖

waitStatus 有下面幾個列舉值:

列舉 含義
0 當一個 Node 被初始化的時候的預設值
CANCELLED 為 1,表示執行緒獲取鎖的請求已經取消了
CONDITION 為 -2,表示節點在等待佇列中,節點執行緒等待喚醒
PROPAGATE 為 -3,當前執行緒處在 SHARED 情況下,該欄位才會使用
SIGNAL 為 -1,表示執行緒已經準備好了,就等資源釋放了

AQS中的同步狀態:

AQS 中維護了一個名為 state 的欄位,意為同步狀態,是由 Volatile 修飾的,用於展示當前臨界 資源的獲鎖情況。

  /**
   * The synchronization state.
   */
  private volatile int state;
複製程式碼

獨佔模式情況下:

美團後臺篇中的ReentrantLock
共享模式情況下:
美團後臺篇中的ReentrantLock

 AQS 重要方法與 ReentrantLock 的關聯

下面列舉了自定義同步器需要實現以下方法,一般來說,自定義同步器要麼是獨佔方式,要麼是共享方式,它們也只需實現tryAcquire-tryReleasetryAcquireShared-tryReleaseShared 中 的 一 種 即可。AQS 也支援自定義同步器同時實現獨佔和共享兩種方式,如 ReentrantReadWriteLockReentrantLock是獨佔鎖,所以實現了tryAcquire-tryRelease

方法名 描述
protected boolean isHeldExclusively() 該執行緒是否正在獨佔資源。只有用到 Condition 才需要去實現它。
protected boolean tryAcquire(int arg) 獨佔方式。arg 為獲取鎖的次數,嘗試獲取資源,成功則返回 True,失敗則返回 False。
protected boolean tryRelease(int arg) 獨佔方式。arg 為釋放鎖的次數,嘗試釋放資源,成功則返回 True,失敗則返回 False。
protected int tryAcquireShared(int arg) 共享方式。arg為獲取鎖的次數,嘗試獲取資源。負數表示失敗;0 表示成功,但沒有剩餘可用資源;正數表示成功,且有剩餘資源。
protected boolean tryReleaseShared(int arg) 共享方式。arg 為釋放鎖的次數,嘗試釋放資源,如果釋放後允許喚醒後續等待結點返回 True,否則返回 False。

下圖舉例說明 非公平鎖與AQS之間方法的關聯之處:

美團後臺篇中的ReentrantLock
加鎖和解鎖的互動流程:

美團後臺篇中的ReentrantLock

加鎖:

  1. 通過 ReentrantLock 的加鎖方法 Lock 進行加鎖操作。
  2. 會呼叫到內部類 Sync 的 Lock 方法,由於 Sync#lock 是抽象方法,根據 ReentrantLock 初始化選擇的公平鎖和非公平鎖,執行相關內部類的 Lock 方 法,本質上都會執行 AQS 的 Acquire 方法。
  3. AQS 的 Acquire 方法會執行 tryAcquire 方法,但是由於 tryAcquire 需要自 定義同步器實現,因此執行了 ReentrantLock 中的 tryAcquire 方法,由於 ReentrantLock 是通過公平鎖和非公平鎖內部類實現的 tryAcquire 方法,因此會根據鎖型別不同,執行不同的 tryAcquire。
  4. tryAcquire 是獲取鎖邏輯,獲取失敗後,會執行框架 AQS 的後續邏輯,跟 ReentrantLock 自定義同步器無關。

解鎖:

  1. 通過 ReentrantLock 的解鎖方法 Unlock 進行解鎖。
  2. Unlock 會呼叫內部類 Sync 的 Release 方法,該方法繼承於 AQS。
  3. Release 中會呼叫 tryRelease 方法,tryRelease 需要自定義同步器實現, tryRelease 只在 ReentrantLock 中的 Sync 實現,因此可以看出,釋放鎖的 過程,並不區分是否為公平鎖。
  4. 釋放成功後,所有處理由 AQS 框架完成,與自定義同步器無關。

從上面的描述,大概可以總結出 ReentrantLock 加鎖解鎖時 API 層核心方法的對映關係:

美團後臺篇中的ReentrantLock

通過ReentrantLock理解 AQS

從上面的簡單分析,我們知道如果當前執行緒沒有獲取到鎖的話,則會進入到等待佇列中去,我們接下來看看執行緒是何時以及怎樣被加入進等待佇列中的。

執行緒加入等待佇列

當執行 Acquire(1) 時,會通過 tryAcquire 獲取鎖。在這種情況下,如果獲取鎖 失敗,就會呼叫 addWaiter 加入到等待佇列中去。

private Node addWaiter(Node mode) {
        Node node = new Node(Thread.currentThread(), mode);
        // Try the fast path of enq; backup to full enq on failure
        Node pred = tail;
        if (pred != null) {
            node.prev = pred;
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
        enq(node);
        return node;
    }
複製程式碼

主要的流程如下:

  1. 通過當前的執行緒和鎖模式新建一個節點。
  2. Pred 指標指向尾節點 Tail。
  3. 將 New 中 Node 的 Prev 指標指向 Pred。
  4. 通過 compareAndSetTail 方法,完成尾節點的設定。這個方法主要是對 tailOffset 和 Expect 進行比較,如果 tailOffset 的 Node 和 Expect 的 Node 地址是相同的,那麼設定 Tail 的值為 Update 的值(利用的是CAS)。
  5. 如果 Pred 指標是 Null(說明等待佇列中沒有元素),或者當前 Pred 指標和 Tail 指向的位置不同(說明被別的執行緒已經修改),就需要看一下 Enq 的方法。
private Node enq(final Node node) {
        for (;;) {
            Node t = tail;
            if (t == null) { // Must initialize
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }
複製程式碼

如果沒有被初始化,需要進行初始化一個頭結點出來。但請注意,初始化的頭 結點並不是當前執行緒節點,而是呼叫了無參建構函式的節點。如果經歷了初始化或 者併發導致佇列中有元素,則與之前的方法相同。其實,addWaiter 就是一個在雙 端連結串列新增尾節點的操作,需要注意的是,雙端連結串列的頭結點是一個無參建構函式 的頭結點。

總結下執行緒獲取鎖的步驟:

  1. 當沒有執行緒獲取到鎖時,執行緒 1 獲取鎖成功。
  2. 執行緒 2 申請鎖,但是鎖被執行緒 1 佔有。
  3. 如果再有執行緒要獲取鎖,依次在佇列中往後排隊即可。
    美團後臺篇中的ReentrantLock

回到上邊的程式碼,hasQueuedPredecessors 是公平鎖加鎖時判斷等待佇列中 是否存在有效節點的方法。如果返回 False,說明當前執行緒可以爭取共享資源;如果 返回 True,說明佇列中存在有效節點,當前執行緒必須加入到等待佇列中。

    public final boolean hasQueuedPredecessors() {
        // The correctness of this depends on head being initialized
        // before tail and on head.next being accurate if the current
        // thread is first in queue.
        Node t = tail; // Read fields in reverse initialization order
        Node h = head;
        Node s;
        return h != t &&
            ((s = h.next) == null || s.thread != Thread.currentThread());
    }
複製程式碼

看到這裡,我們理解一下 h != t && ((s = h.next) == null || s.thread != Thread. currentThread());

為什麼要判斷的頭結點的下一個節點?第一個節點儲存的資料是 什麼?

其實在雙向連結串列中,第一個節點為虛節點,其實並不儲存任何資訊,只是佔位。真 正的第一個有資料的節點,是在第二個節點開始的。當 h != t 時:如果 (s =h.next) == null,等待佇列正在有執行緒進行初始化,但只是進行到了 Tail 指 向 Head,沒有將Head 指向 Tail,此時佇列中有元素,需要返回 True(這塊 具體見下邊程式碼分析)。 如果 (s = h.next) != null,說明此時佇列中至少有一 個有效節點。如果此時 s.thread == Thread.currentThread(),說明等待隊 列的第一個有效節點中的執行緒與當前執行緒相同,那麼當前執行緒是可以獲取資源 的;如果 s.thread != Thread.currentThread(),說明等待佇列的第一個有效 節點執行緒與當前執行緒不同,當前執行緒必須加入進等待佇列。

 1 if (t == null) { // Must initialize
 2               if (compareAndSetHead(new Node()))
 3                   tail = head;
 4           } else {
 5               node.prev = t;
 6               if (compareAndSetTail(t, node)) {
 7                   t.next = node;
 8                   return t;
 9               }
 10           }
複製程式碼

節點入隊不是原子操作,所以會出現短暫的 head != tail,此時 Tail 指向最後 一個節點,而且 Tail 指向 Head。如果 Head 沒有指向 Tail(可見 5、6、7 行), 這種情況下也需要將相關執行緒加入佇列中。所以這塊程式碼是為了解決極端情況下的 併發問題。

等待佇列中執行緒出佇列時機

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

上文解釋了 addWaiter 方法,這個方法其實就是把對應的執行緒以 Node 的資料 結構形式加入到雙端佇列裡,返回的是一個包含該執行緒的 Node。而這個Node 會作為引數,進入到 acquireQueued方法中。acquireQueued 方法可以對排隊中的線 程進行“獲鎖”操作。 總的來說,一個執行緒獲取鎖失敗了,被放入等待佇列,acquireQueued 會把放 入佇列中的執行緒不斷去獲取鎖,直到獲取成功或者不再需要獲取(中斷)。 下面我們從“何時出佇列?”和“如何出佇列?”兩個方向來分析一下 acquireQueued原始碼:

final boolean acquireQueued(final Node node, int arg) {
        // 標記是否成功拿到資源
        boolean failed = true;
        try {
             // 標記等待過程中是否中斷過
            boolean interrupted = false;
            // 開始自旋,要麼獲取鎖,要麼中斷
            for (;;) {
                // 獲取當前節點的前驅節點
                final Node p = node.predecessor();
                // 如果 p 是頭結點,說明當前節點在真實資料佇列的首部,就嘗試獲取鎖(別忘了頭結點是虛節點)
                if (p == head && tryAcquire(arg)) {
                    // 獲取鎖成功,頭指標移動到當前 node
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
                // 說明 p 為頭節點且當前沒有獲取到鎖(可能是非公平鎖被搶佔了)或者是 p不為頭結點,這個時候就要判斷當前 node 是否要被阻塞(被阻塞條件:前驅節點的waitStatus 為 -1),防止無限迴圈浪費資源。具體兩個方法下面細細分析
                if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }
複製程式碼

注:setHead 方法是把當前節點置為虛節點,但並沒有修改 waitStatus,因為 它是一直需要用的資料。

private void setHead(Node node) {
         head = node;
         node.thread = null;
         node.prev = null; 
}
// java.util.concurrent.locks.AbstractQueuedSynchronizer
// 靠前驅節點判斷當前執行緒是否應該被阻塞
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        // 獲取頭結點的節點狀態
        int ws = pred.waitStatus;
        // 說明頭結點處於喚醒狀態
        if (ws == Node.SIGNAL)
            return true; 
        // 通過列舉值我們知道 waitStatus>0 是取消狀態
        if (ws > 0) {
            do {
            // 迴圈向前查詢取消節點,把取消節點從佇列中剔除
             node.prev = pred = pred.prev;
             } while (pred.waitStatus > 0);
            pred.next = node;
         } else {
        // 設定前任節點等待狀態為 SIGNAL
         compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
         }
        return false; 
    
}
複製程式碼

parkAndCheckInterrupt 主要用於掛起當前執行緒,阻塞呼叫棧,返回當前執行緒的中斷狀態。

private final boolean parkAndCheckInterrupt() {
         LockSupport.park(this);
         return Thread.interrupted();
}
複製程式碼

上述方法的流程圖如下:

美團後臺篇中的ReentrantLock
從上圖可以看出,跳出當前迴圈的條件是當“前置節點是頭結點,且當前執行緒獲 取鎖成功”。為了防止因死迴圈導致 CPU 資源被浪費,我們會判斷前置節點的狀態 來決定是否要將當前執行緒掛起,具體掛起流程用流程圖表示如下(shouldParkAfterFailedAcquire 流程):
美團後臺篇中的ReentrantLock

從佇列中釋放節點的疑慮打消了,那麼又有新問題了:

  1. shouldParkAfterFailedAcquire中取消節點是怎麼生成的呢?什麼時候會把一個節點的 waitStatus 設定為-1 ?
  2. 是在什麼時間釋放節點通知到被掛起的執行緒呢?

CANCELLED 狀態節點生成

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);
        }
    }
複製程式碼

通過 cancelAcquire 方法,將 Node 的狀態標記為 CANCELLED。接下來, 我們逐行來分析這個方法的原理:

private void cancelAcquire(Node node) {
        // 將無效節點過濾
        if (node == null)
            return;
        // 設定該節點不關聯任何執行緒,也就是虛節點
        node.thread = null;
        Node pred = node.prev;
        // 通過前驅節點,跳過取消狀態的 node
        while (pred.waitStatus > 0)
            node.prev = pred = pred.prev;
        // 獲取過濾後的前驅節點的後繼節點
        Node predNext = pred.next;
        // 把當前 node 的狀態設定為 CANCELLED
        node.waitStatus = Node.CANCELLED;
        // 如果當前節點是尾節點,將從後往前的第一個非取消狀態的節點設定為尾節點
        // 更新失敗的話,則進入 else,如果更新成功,將 tail 的後繼節點設定為 null
        if (node == tail && compareAndSetTail(node, pred)) {
            compareAndSetNext(pred, predNext, null);
        } else {
            // 如果當前節點不是 head 的後繼節點,
            // 1: 判斷當前節點前驅節點的是否為 SIGNAL,
            // 2: 如果不是,則把前驅節點設定為 SINGAL 看是否成功
            // 如果 1 和 2 中有一個為 true,再判斷當前節點的執行緒是否為 null
            // 如果上述條件都滿足,把當前節點的前驅節點的後繼指標指向當前節點的後繼節點
            int ws;
            if (pred != head && ((ws = pred.waitStatus) == Node.SIGNAL || (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) && pred.thread != null) {
                Node next = node.next;
                if (next != null && next.waitStatus <= 0)
                    compareAndSetNext(pred, predNext, next);
            } else {
                // 如果當前節點是 head 的後繼節點,或者上述條件不滿足,那就喚醒當前節點的後繼節點
                unparkSuccessor(node);
            }
            node.next = node; // help GC
        }
    }
複製程式碼

當前流程:

  1. 獲取當前節點的前驅節點,如果前驅節點的狀態是 CANCELLED,那就一直 往前遍歷,找到第一個 waitStatus <= 0 的節點,將找到的 Pred 節點和當前 Node 關聯,將當前 Node 設定為 CANCELLED。

根據當前節點的位置,考慮以下三種情況:

1. 當前節點是尾節點。
2. 當前節點是 Head 的後繼節點。
3. 當前節點不是 Head 的後繼節點,也不是尾節點。
複製程式碼

根據上述第二條,我們來分析每一種情況的流程。

當前節點是尾節點:

美團後臺篇中的ReentrantLock
當前節點是 Head 的後繼節點:
美團後臺篇中的ReentrantLock
當前節點不是 Head 的後繼節點,也不是尾節點:
美團後臺篇中的ReentrantLock

通過上面的流程,我們對於 CANCELLED 節點狀態的產生和變化已經有了大致 的瞭解,但是為什麼所有的變化都是對 Next 指標進行了操作,而沒有對 Prev 指標 進行操作呢?什麼情況下會對 Prev 指標進行操作?

  1. 執行 cancelAcquire 的時候,當前節點的前置節點可能已經從佇列中出去了 (已經執行過 Try 程式碼塊中的 shouldParkAfterFailedAcquire 方法了),如果此時修改 Prev 指標,有可能會導致 Prev 指向另一個已經移除佇列的 Node, 因此這塊變化 Prev 指標不安全。 shouldParkAfterFailedAcquire 方法中, 會執行下面的程式碼,其實就是在處理Prev 指標。shouldParkAfterFailedAcquire 是獲取鎖失敗的情況下才會執行,進入該方法後,說明共享資源已 被獲取,當前節點之前的節點都不會出現變化,因此這個時候變更 Prev 指標 比較安全。
do {
 node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
複製程式碼

如何解鎖

由於 ReentrantLock 在解鎖的時候,並不區分公平鎖和非公平鎖,所以我們直接看解鎖的原始碼:

public void unlock() {
    sync.release(1);
}
複製程式碼
public final boolean release(int arg) {
        if (tryRelease(arg)) {
             Node h = head;
        // 頭結點不為空並且頭結點的 waitStatus 不是初始化節點情況,解除執行緒掛起狀態
        if (h != null && h.waitStatus != 0)
             unparkSuccessor(h);
            return true;
         }
        return false; 
}
複製程式碼
// 方法返回當前鎖是不是沒有被執行緒持有
protected final boolean tryRelease(int releases) {
    // 減少可重入次數
    int c = getState() - releases;
    // 當前執行緒不是持有鎖的執行緒,丟擲異常
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    // 如果持有執行緒全部釋放,將當前獨佔鎖所有執行緒設定為 null,並更新 state
    if (c == 0) {
         free = true;
         setExclusiveOwnerThread(null);
     }
    setState(c);
    return free;
}
複製程式碼

這裡的判斷條件為什麼是 h != null && h.waitStatus != 0h == null 則說明Head 還沒初始化。初始情況下,head == null,第一個節點入隊,Head 會被初始化一個虛擬節點。所以說,這裡如果還沒來得及入隊,就會出 現 head == null 的情況。 h != null && waitStatus == 0 表明後繼節點對應的執行緒仍在執行中,不需要喚醒。 h != null && waitStatus < 0 表明後繼節點可能被阻塞了,需要喚醒。

private void unparkSuccessor(Node node) {
    // 獲取頭結點 waitStatus
    int ws = node.waitStatus;
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);
    // 獲取當前節點的下一個節點
     Node s = node.next;
    // 如果下個節點是 null 或者下個節點被 cancelled,就找到佇列最開始的非cancelled 的節點
    if (s == null || s.waitStatus > 0) {
         s = null;
    // 就從尾部節點開始找,到隊首,找到佇列第一個 waitStatus<0 的節點。
    for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0)
                s = t;
             }
    // 如果當前節點的下個節點不為空,而且狀態 <=0,就把當前節點 unpark
    if (s != null)
        LockSupport.unpark(s.thread);
}
複製程式碼

為什麼要從後往前找第一個非 Cancelled 的節點呢?原因如下。 之前的 addWaiter 方法:

private Node addWaiter(Node mode) {
     Node node = new Node(Thread.currentThread(), mode);
    // Try the fast path of enq; backup to full enq on failure
     Node pred = tail;
    if (pred != null) {
         node.prev = pred;
    if (compareAndSetTail(pred, node)) {
             pred.next = node;
             return node;
         }
     }
     enq(node);
    return node;
}
複製程式碼

從這裡可以看到節點入隊並不是原子操作,也就是說,node.prev = pred;compareAndSetTail(pred, node) 這兩個地方可以看作Tail 入隊的原子操作, 但是此時 pred.next = node; 還沒執行,如果這個時候執行了 unparkSuccessor 方法,就沒辦法從前往後找了,所以需要從後往前找。還有一點原因,在產生 CANCELLED 狀態節點的時候,先斷開的是 Next 指標,Prev 指標並未斷開,因此 也是必須要從後往前遍歷才能夠遍歷完全部的 Node。 綜 上 所 述, 如 果 是 從 前 往 後 找, 由 於 極 端 情 況 下 入 隊 的 非 原 子 操 作 和 CANCELLED 節點產生過程中斷開 Next 指標的操作,可能會導致無法遍歷所 有的節點。所以,喚醒對應的執行緒後,對應的執行緒就會繼續往下執行。繼續執行acquireQueued 方法以後,中斷如何處理?

中斷恢復後的執行流程

喚醒後,會執行 return Thread.interrupted();,這個函式返回的是當前執行執行緒的中斷狀態,並清除。

private final boolean parkAndCheckInterrupt() {
     LockSupport.park(this);
    return Thread.interrupted();
}
複製程式碼

再 回 到 acquireQueued 代 碼, 當 parkAndCheckInterrupt 返 回True 或者 False 的時候,interrupted 的值不同,但都會執行下次迴圈。如果這個時候獲取鎖成功,就會把當前 interrupted返回。

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);
         }
}
複製程式碼

如果 acquireQueued 為 True,就會執行 selfInterrupt 方法。

static void selfInterrupt() {
    Thread.currentThread().interrupt();
}
複製程式碼

該方法其實是為了中斷執行緒。但為什麼獲取了鎖以後還要中斷執行緒呢?這部分屬 於 Java 提供的協作式中斷知識內容,感興趣同學可以查閱一下。這裡簡單介紹一下:

  1. 當中斷執行緒被喚醒時,並不知道被喚醒的原因,可能是當前執行緒在等待中被中斷,也可能是釋放了鎖以後被喚醒。因此我們通過 Thread.interrupted() 方法檢查中斷標記(該方法返回了當前執行緒的中斷狀態,並將當前執行緒的中斷 標識設定為 False),並記錄下來,如果發現該執行緒被中斷過,就再中斷一次。
  2. 執行緒在等待資源的過程中被喚醒,喚醒後還是會不斷地去嘗試獲取鎖,直到搶到鎖為止。也就是說,在整個流程中,並不響應中斷,只是記錄中斷記錄。 最後搶到鎖返回了,那麼如果被中斷過的話,就需要補充一次中斷。 這裡的處理方式主要是運用執行緒池中基本運作單元 Worder 中的 runWorker, 通過 Thread.interrupted() 進行額外的判斷處理,感興趣的同學可以看下 ThreadPoolExecutor 原始碼。

小結

Q:某個執行緒獲取鎖失敗的後續流程是什麼呢?

A:存在某種排隊等候機制,執行緒繼續等待,仍然保留獲取鎖的可能,獲取鎖流程仍在繼續。

Q:既然說到了排隊等候機制,那麼就一定會有某種佇列形成,這樣的佇列是什麼資料結構呢?

A:是 CLH 變體的 FIFO 雙端佇列。

Q:處於排隊等候機制中的執行緒,什麼時候可以有機會獲取鎖呢?

A:可以詳細看下上面的 ==> 等待佇列中執行緒出佇列時機

Q:如果處於排隊等候機制中的執行緒一直無法獲取鎖,需要一直等待麼?還是有別的策略來解決這一問?

A:執行緒所在節點的狀態會變成取消狀態,取消狀態的節點會從佇列中釋放,具體可看上文的 ==>CANCELLED狀態節點生成

Q:Lock 函式通過 Acquire 方法進行加鎖,但是具體是如何加鎖的呢?

A:AQS 的 Acquire 會呼叫 tryAcquire 方法,tryAcquire 由各個自定義同步器實現,通過 tryAcquire 完成加鎖過程。

AQS 應用

ReentrantLock 的可重入應用

ReentrantLock 的可重入性是 AQS很好的應用之一,在瞭解完上述知識點以後,我們得知ReentrantLock實現可重入的方法。在 ReentrantLock 裡面,不管是公平鎖還是非公平鎖,都有一段邏輯。

公平鎖:

if (c == 0) {
    if (!hasQueuedPredecessors() && 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; 
}
複製程式碼

非公平鎖:

if (c == 0) {
    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");
        setState(nextc);
        return true; 
}
複製程式碼

從上面這兩段都可以看到,有一個同步狀態 State 來控制整體可重入的情況。StateVolatile修飾的,用於保證一定的可見性和有序性。

接下來看 State 這個欄位主要的過程:

  1. State 初始化的時候為 0,表示沒有任何執行緒持有鎖。
  2. 當有執行緒持有該鎖時,值就會在原來的基礎上 +1,同一個執行緒多次獲得鎖是,就會多次 +1,這裡就是可重入的概念。
  3. 解鎖也是對這個欄位 -1,一直到 0,此執行緒對鎖釋放。

JUC中的應用場景

除了上邊 ReentrantLock 的可重入性的應用,AQS 作為併發程式設計的框架,為很多其他同步工具提供了良好的解決方案。下面列出了 JUC 中的幾種同步工具,大體介紹一下 AQS 的應用場景:

同步工具 同步工具與AQS的關聯
ReentrantLock 使用 AQS 儲存鎖重複持有的次數。當一個執行緒獲取鎖時,ReentrantLock記錄當前獲得鎖的執行緒標識,用於檢測是否重複獲取,以及錯誤執行緒試圖解鎖操作時異常情況的處理。
Semaphore 使用 AQS 同步狀態來儲存訊號量的當前計數。tryRelease 會增加計數,acquireShared 會減少計數。
CountDownLatch 使用 AQS 同步狀態來表示計數。計數為 0 時,所有的 Acquire 操作(CountDownLatch 的 await 方法)才可以通過。
ReentrantReadWriteLock 使用 AQS 同步狀態中的 16 位儲存寫鎖持有的次數,剩下的 16 位用於儲存讀鎖的持有次數。
ThreadPoolExecutor Worker 利用 AQS 同步狀態實現對獨佔執行緒變數的設定(tryAcquire 和tryRelease)。

總結

我們日常開發中使用併發的場景太多,但是對併發內部的基本框架原理了解的人卻不多。而且多執行緒情況下,尋找問題所在也是一個很頭大的問題。只有夯實基礎,才能走的更遠。

參考文章: 美團後臺篇

相關文章