併發程式設計——詳解 AQS CLH 鎖

莫那·魯道發表於2019-03-04
  1. 從 acquire 方法開始 —— 獲取
  2. 為什麼 AQS 需要一個虛擬 head 節點
  3. reelase 方法如何釋放鎖
  4. 總結

前言

AQS 是 JUC 中的核心,其中封裝了資源的獲取和釋放,在我們之前的 併發程式設計之 AQS 原始碼剖析 文章中,我們已經從 ReentranLock 那裡分析了鎖的獲取和釋放。但我有必要再次解釋 AQS 的核心 CLH 鎖。

這裡引用一下別人對於 CLH 的解釋:

CLH CLH(Craig, Landin, and Hagersten locks): 是一個自旋鎖,能確保無飢餓性,提供先來先服務的公平性。

CLH鎖也是一種基於連結串列的可擴充套件、高效能、公平的自旋鎖,申請執行緒只在本地變數上自旋,它不斷輪詢前驅的狀態,如果發現前驅釋放了鎖就結束自旋。

Java AQS 的設計對 CLH 鎖進行了優化或者說變體。

我們還是從程式碼開始說起吧。

1. 從 acquire 方法開始 —— 獲取

acquire 方法是獲取鎖的常用方法。程式碼如下:

public final void acquireQueued(int arg) {
	// 當 tryAcquire 返回 true 就說明獲取到鎖了,直接結束。
	// 反之,返回 false 的話,就需要執行後面的方法。
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}
複製程式碼

只要子類的 tryAcquire 方法返回 false,那麼就說明獲取鎖事變,就需要將自己加入佇列。

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;
    // 如果 tail 節點不是 null,就將新節點的 pred 節點設定為 tail 節點。
    // 並且將新節點設定成 tail 節點。
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    // 如果 tail 節點是  null,或者 CAS 設定 tail 失敗。
    // 在 enq 方法中處理
    enq(node);
    return node;
}
複製程式碼

將自己加入了尾部,並更新了 tail 節點。

private Node enq(final Node node) {
    for (;;) {
        Node t = tail;
        // 如果 tail 是 null,就建立一個虛擬節點,同時指向 head 和 tail,稱為 初始化。
        if (t == null) { // Must initialize
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {// 如果不是 null
        	// 和 上個方法邏輯一樣,將新節點追加到 tail 節點後面,並更新佇列的 tail 為新節點。
        	// 只不過這裡是死迴圈的,失敗了還可以再來 。
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}
複製程式碼

enq 方法的邏輯是什麼呢?當 tail 是 null(沒有初始化佇列),就需要初始化佇列了。CAS 設定 tail 失敗,也會走這裡,需要在 enq 方法中迴圈設定 tail。直到成功。

注意:這裡會建立一個虛擬節點。

2. 為什麼 AQS 需要一個虛擬 head 節點

為什麼要建立一個虛擬節點呢?

事情要從 Node 類的 waitStatus 變數說起,簡稱 ws。每個節點都有一個 ws 變數,用於這個節點狀態的一些標誌。初始狀態是 0。如果被取消了,節點就是 1,那麼他就會被 AQS 清理。

還有一個重要的狀態:SIGNAL —— -1,表示:噹噹前節點釋放鎖的時候,需要喚醒下一個節點。

所有,每個節點在休眠前,都需要將前置節點的 ws 設定成 SIGNAL。否則自己永遠無法被喚醒

而為什麼需要這麼一個 ws 呢?—— 防止重複操作。假設,當一個節點已經被釋放了,而此時另一個執行緒不知道,再次釋放。這時候就錯誤了。

所以,需要一個變數來保證這個節點的狀態。而且修改這個節點,必須通過 CAS 操作保證執行緒安全。

So,回到我們之前的問題:為什麼要建立一個虛擬節點呢?

每個節點都必須設定前置節點的 ws 狀態為 SIGNAL,所以必須要一個前置節點,而這個前置節點,實際上就是當前持有鎖的節點。

問題在於有個邊界問題:**第一個節點怎麼辦?**他是沒有前置節點的。

那就建立一個假的。

這就是為什麼要建立一個虛擬節點的原因。

總結下來就是:每個節點都需要設定前置節點的 ws 狀態(這個狀態為是為了保證資料一致性),而第一個節點是沒有前置節點的,所以需要建立一個虛擬節點

回到我們的 acquireQueued 方法證實一下:

// 這裡返回的節點是新建立的節點,arg 是請求的數量
final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
        	// 找上一個節點
            final Node p = node.predecessor();
            // 如果上一個節點是 head ,就嘗試獲取鎖
            // 如果 獲取成功,就將當前節點設定為 head,注意 head 節點是永遠不會喚醒的。
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            // 在獲取鎖失敗後,就需要阻塞了。
            // shouldParkAfterFailedAcquire ---> 檢查上一個節點的狀態,如果是 SIGNAL 就阻塞,否則就改成 SIGNAL。
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}
複製程式碼

這個方法有 2 個邏輯:

  1. 如何將自己掛起?
  2. 被喚醒之後做什麼?

先回答第二個問題: 被喚醒之後做什麼?

嘗試拿鎖,成功之後,將自己設定為 head,斷開和 next 的連線。

再看第二個問題:如何將自己掛起?

注意:掛起自己之前,需要將前置節點的 ws 狀態設定成 SIGNAL,告訴他:你釋放鎖的時候記得喚醒我。

具體邏輯在 shouldParkAfterFailedAcquire 方法中:

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus;
    //  如果他的上一個節點的 ws 是 SIGNAL,他就需要阻塞。
    if (ws == Node.SIGNAL)
    	// 阻塞
        return true;
    // 前任被取消。 跳過前任並重試。
    if (ws > 0) {
        do {
        	// 將前任的前任 賦值給 當前的前任
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        // 將前任的前任的 next 賦值為 當前節點
        pred.next = node;
    } else { 
    	// 如果沒有取消 || 0 || CONDITION || PROPAGATE,那麼就將前任的 ws 設定成 SIGNAL.
    	// 為什麼必須是 SIGNAL 呢?
    	// 答:希望自己的上一個節點在釋放鎖的時候,通知自己(讓自己獲取鎖)
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    // 重來
    return false;
}
複製程式碼

該方法的主要邏輯就是將前置節點的狀態修改成 SIGNAL。其中如果前置節點被取消了,就跳過他。

那麼肯定,在前置節點釋放鎖的時候,肯定會喚醒這個節點。看看釋放的邏輯吧。

3. reelase 方法如何釋放鎖

先來一波程式碼:

public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;
        // 所有的節點在將自己掛起之前,都會將前置節點設定成 SIGNAL,希望前置節點釋放的時候,喚醒自己。
        // 如果前置節點是 0 ,說明前置節點已經釋放過了。不能重複釋放了,後面將會看到釋放後會將 ws 修改成0.
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}
複製程式碼

從這個方法的判斷就可以看出,head 必須不等於 0。為什麼呢?當一個節點嘗試掛起自己之前,都會將前置節點設定成 SIGNAL -1,就算是第一個加入佇列的節點,在獲取鎖失敗後,也會將虛擬節點設定的 ws 設定成 SIGNAL。

而這個判斷也是防止多執行緒重複釋放。

那麼肯定,在釋放鎖之後,肯定會將 ws 狀態設定成 0。防止重複操作。

程式碼如下:

private void unparkSuccessor(Node node) {
    int ws = node.waitStatus;
    if (ws < 0)
    	// 將 head 節點的 ws 改成 0,清除訊號。表示,他已經釋放過了。不能重複釋放。
        compareAndSetWaitStatus(node, ws, 0);

    Node s = node.next;
    // 如果 next 是 null,或者 next 被取消了。就從 tail 開始向上找節點。
    if (s == null || s.waitStatus > 0) {
        s = null;
        // 從尾部開始,向前尋找未被取消的節點,直到這個節點是 null,或者是 head。
        // 也就是說,如果 head 的 next 是 null,那麼就從尾部開始尋找,直到不是 null 為止,找到這個 head 就不管了。
        // 如果是 head 的 next 不是 null,但是被取消了,那這個節點也會被略過。
        for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0)
                s = t;
    }
    // 喚醒 head.next 這個節點。
    // 通常這個節點是 head 的 next。
    // 但如果 head.next 被取消了,就會從尾部開始找。
    if (s != null)
        LockSupport.unpark(s.thread);
}
複製程式碼

如果 ws 小於 0,我們假設是 SIGNAL,就修改成 0. 證實了我們的想法。

如果他的 next 是 null,說明 next 取消了,那麼就從尾部開始向上尋找(不從尾部也沒辦法)。當然找的過程中,也跳過了失效的節點。

最後,喚醒他。

喚醒之後的邏輯是什麼樣子的還記得嗎?

複習一下:拿鎖,設定自己為 head,斷開前任 head 和自己的連線。

4. 總結

AQS 使用的 CLH 鎖,需要一個虛擬 head 節點,這個節點的作用是防止重複釋放鎖。當第一個進入佇列的節點沒有前置節點的時候,就會建立一個虛擬的。

來一幅圖嘗試解釋 AQS 吧:

  1. 新增節點時

    image.png
  2. 更新 tail

image.png
  1. 喚醒節點時,之前的 head 取消了
image.png

相關文章