【乾貨!!】十分鐘帶你搞懂 Java AQS 核心設計與實現!!!

使徒行者白發表於2020-11-23

前言

這篇文章寫完放著也蠻久的了,今天終於釋出了,對於拖延症患者來說也真是不容易~哈哈哈。

言歸正傳,其實吧。。我覺得對於大部分想了解 AQS 的朋友來說,明白 AQS 是個啥玩意兒以及為啥需要 AQS,其實是最重要的。就像我一開始去看 AQS 的時候,抱著程式碼就啃,看不懂就去網上搜。。但是網上文章千篇一律。。大部分都是給你逐行分析下程式碼然後就沒了。。。但其實對我們來說我知道為啥要這麼幹其實也相當重要。。

嗯。。所以就有了這篇文章。。筆者會先給你介紹下 AQS 的作者為啥要整這個東西。。然後筆者再結合自身感悟給你劃了劃重點。。如果你認真讀了。。肯定會有所收穫的哦

一、AQS 是什麼?為什麼需要 AQS ?

試想有這麼一種場景:有四個執行緒由於業務需求需要同時佔用某資源,但該資源在同一個時刻只能被其中唯一執行緒所獨佔。那麼此時應該如何標識該資源已經被獨佔,同時剩餘無法獲取該資源的執行緒又該何去何從呢? 這裡就涉及到了關於共享資源的競爭與同步關係。對於不同的開發者來說,實現的思路可能會有不同。這時如果能夠有一個較為通用的且效能較優同步框架,那麼可以在一定程度上幫助開發人員快速有效的完成多執行緒資源同步競爭方面的編碼。

AQS 正是為了解決這個問題而被設計出來的。AQS 是一個集同步狀態管理、執行緒阻塞、執行緒釋放及佇列管理功能與一身的同步框架。其核心思想是當多個執行緒競爭資源時會將未成功競爭到資源的執行緒構造為 Node 節點放置到一個雙向 FIFO 佇列中。被放入到該佇列中的執行緒會保持阻塞直至被前驅節點喚醒。值得注意的是該佇列中只有隊首節點有資格被喚醒競爭鎖。

如果希望更具體的瞭解 AQS 設計初衷與原理,可以看下連結中的翻譯版論文《The java.util.concurrent Synchronizer Framework》 https://www.cnblogs.com/dennyzhangdd/p/7218510.html

如果你能耐心看完上面這篇論文,接著再從以下幾個點切入翻閱 AQS 原始碼,那就相當如魚得水了:

  • 同步狀態的處理
  • FIFO 佇列的設計,如何處理未競爭到資源的執行緒
  • 競爭失敗時執行緒如何處理
  • 共享資源的釋放

後面的章節主要會結合 AQS 原始碼,介紹下獨佔模式下鎖競爭及釋放相關內容。

二、同步狀態的處理

private volatile int state;

翻閱下 AQS 原始碼,不難發現有這麼一個 volatile 型別的 state 變數。通俗的說這個 state 變數可以用於標識當前鎖的佔用情況。打個比方:當 state 值為 1 的時候表示當前鎖已經被某執行緒佔用,除非等佔用的鎖的執行緒釋放鎖後將 state 置為 0,否則其它執行緒無法獲取該鎖。這裡的 state 變數用 volatile 關鍵字保證其在多執行緒之間的可見性。

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

同時,我們發現 AQS 預留了個口子可以供開發人員按照自身需求進行二次重構。因此也就出現了類似與 ReentrantLock 可重入鎖、CountDownLatch 等實現。

三、AQS 靈魂佇列的設計

對於整個 AQS 框架來說,佇列的設計可以說重中之重。那麼為什麼 AQS 需要一個佇列呢?

對於一個資源同步競爭框架來說,如何處理沒有獲取到鎖的執行緒是非常重要的,比方說現在有 ABCD 四個執行緒同時競爭鎖,其中執行緒 A 競爭成功了。那麼剩下的執行緒 BCD 該咋辦呢? 我們可以嘗試試想下自己會如何解決:

  1. 執行緒自旋等待,不斷重新嘗試獲取鎖。這樣雖然可以滿足需求,但是眾多執行緒同時自旋等待實際上是對 CPU 資源的一種浪費,這麼做不太合適。
  2. 將執行緒掛起,等待鎖釋放時喚醒,再競爭獲取。如果等待的執行緒比較多,同時被喚醒可能會發生“驚群”問題。

上面兩種方法的可行性其實都不太高,對於一個同步框架來說,當有多個執行緒嘗試競爭資源時,我們並不希望所有的執行緒同時來競爭鎖。而且更重要的是,能夠有效的監控當前處於等待過程中的執行緒也十分必要。那麼這個時候藉助 FIFO 佇列管理執行緒,既可以有效的幫助開發者監控執行緒,同時也可以在一定程度上減少飢餓問題出現的概率(執行緒先入先出)。

除此之外 AQS 中用於存放執行緒的佇列還有以下幾點考量:

  1. Node 節點的設計
  • 前驅、後繼節點,分別儲存當前節點在佇列中的前驅節點和後繼節點
  • 節點狀態:節點擁有不同的狀態可以幫助我們更好的管理佇列中的執行緒。在本文中我們只討論 SIGNAL 和 CANCEL 狀態。當前驅節點的狀態為 SIGNAL 時,表示當前節點可以被安全掛起,鎖釋放時當前執行緒會被喚醒去嘗試重新獲取鎖;CANCEL 狀態表示當前執行緒被取消,無需再嘗試獲取鎖,可以被移除佇列
  // 執行緒被取消
  static final int CANCELLED =  1;
  // 後續執行緒在鎖釋放後可以被喚醒
  static final int SIGNAL    = -1;
  // 當前執行緒在 condition 佇列中
  static final int CONDITION = -2;
  // 沒有深入體會,表示下一次共享式同步狀態獲取將會無條件被傳播下去
  static final int PROPAGATE = -3;
  1. AQS 中的雙向執行緒佇列 由於 Node 前驅和後繼節點的存在。這裡儲存 Node 的佇列實際上是一個雙向佇列。在這個佇列裡前驅節點的存在會更重要些:當前新節點被插入到佇列中時,如果前驅節點狀態為取消狀態。我們可以通過前驅節點不斷往前回溯,完成一個類似滑動視窗的功能,跳過無效執行緒,從而幫助我們更有效的管理等待佇列中執行緒。而且上面也提過了,等待執行緒都放在佇列中,一方面可以管控等待執行緒,另一方面也可以較少飢餓現象發生的概率。

  2. HEAD 和 TAIL HEAD 和 TAIL 節點分別指向佇列的首尾節點。當第一次往佇列中塞入一個新的節點時會構造一個虛擬節點作為 HEAD 頭節點。為什麼需要虛擬的 HEAD 頭節點呢?因為在 AQS 的設計理念中,當前節點能夠安心自我阻塞的前提條件是前驅節點在釋放鎖資源時,能夠喚醒後繼節點。而插入到第一個佇列中的節點,沒有前驅節點怎麼辦,我們就構造一個虛擬節點來滿足需求

同時 HEAD 和 TAIL 節點的存在加上雙向佇列的設計,整體的佇列就顯的非常靈活。

四、資源競爭(獲取鎖)

這一章節開始我們將結合原始碼對 AQS 獲取鎖的流程進行討論。

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

acquire 方法用於獲取鎖,這裡可以拆解為三步:

  • tryAcquired: 看名字就知道用於嘗試獲取所,並不保證一定可以獲取鎖,具體邏輯由子類實現。如果在這一步成功獲取到了鎖,後面的邏輯也就沒有必要繼續執行了。
  • addWaiter嘗試競爭鎖資源失敗後,我們就要考慮將這個執行緒構造成一個節點插入到佇列中了。這裡的 addWaiter() 方法會將當前執行緒包裝成一個 Node 節點後,維護到 FIFO 雙向佇列中。
 private Node addWaiter(Node mode) {
    // 將當前執行緒包裝成一個 Node 節點
    Node node = new Node(Thread.currentThread(), mode);
    Node pred = tail;
    // 如果 tail 節點不為空:新節點的前驅指向 tail,原尾節點的後繼指向當前節點,當前節點成為新的尾節點
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    // 第一次往佇列中新增節點時,會執行 enq 方法
    enq(node);
    return node;
}
private Node enq(final Node node) {
    for (;;) {
     // head 和 tail 在初始情況下都為 null
        Node t = tail;
        if (t == null) { // 初始化一個空節點用於幫助喚醒佇列中的第一個有效執行緒
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            // 這段邏輯用於考慮多執行緒併發的場景,如果此時佇列中已經有了節點
            // 再次嘗試將當前節點插至隊尾
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}

這段邏輯不復雜:

  1. 當我們處理第一個節點時,此時 tail 節點為 null,因此會執行 enq() 方法。可以看到 enq 方法實際是一個死迴圈,只有當節點成功被插入到佇列後,才能跳出去迴圈。那這麼做的目的是什麼呢?其實不難看出,這裡是為了應對多執行緒競爭而採取的妥協之策。多個執行緒同時執行這段邏輯時,只有一個執行緒可以成功呼叫 compareAndSetHead() 並將 head 頭指向一個新的節點,此時的 head 和 tail 都指向一個空節點。這個空節點的作用前面已經提過了,用於幫助後繼節點可以在合適的場景下自我阻塞等待被喚醒。其它併發執行的執行緒執行 compareAndSetHead() 方法失敗後,發現 tail 已經不為 null 了,依次將自己插入到 tail 節點後。
  2. 當 tail 節點不為空時,表示此時佇列中有資料。因此我們藉助 CAS 將新節點插入到尾節點之後,同時將 tail 指向新節點
  • acquireQueued
final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            final Node p = node.predecessor();
            // 前驅節點為 head 時,嘗試獲取鎖
            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);
    }
}

這裡又是一個死迴圈

  • 這裡需要注意的是隻有前驅節點為 head 時,我們才會再次嘗試獲取鎖。也就是在當前佇列中,只有隊首節點才會嘗試獲取鎖。這裡也體現瞭如何降低飢餓現象發生的概率。如果成功獲取到了鎖:將 node 節點設定為頭節點,同時將前驅節點的 next 設定為 null 幫助 gc。

  • 如果 node 節點前驅節點不為 head 或者獲取鎖失敗,執行 shouldParkAfterFailedAcquire() 方法判斷當前執行緒是否需要阻塞,如果需要阻塞則會呼叫 parkAndCheckInterrupt() 方法掛起當前執行緒

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus;
    if (ws == Node.SIGNAL)
        /*
         * 當前驅節點狀態為 SIGNAL 時,表示呼叫 release 釋放前驅節點佔用的鎖時,
         * 前驅會喚醒當前節點,可安全掛起當前執行緒等待被喚醒
         */

        return true;
    if (ws > 0) {
        /*
         * 前驅節點處於取消狀態,我們需要跳過這個節點,並且重試
         */

        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        // waitStatus 為 0 或 PROPAGATE 走的這裡。後文會分析下什麼時候 waitStatus 可能為 0
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

當節點狀態為 SIGNAL 時,表示當前執行緒可以被安全掛起。waitStats 大於0表示當前執行緒已經被取消,我們需要往前回溯找到有效節點。

在開始閱讀這段程式碼時,一直想不通在哪些場景下 waitStatus 的狀態可能為 0,在參閱了其它筆者分析的文章再加上自己的理解後,總結出以下兩種場景:

  1. 當我們往佇列中新插入一個節點時。隊尾節點的 waitStatus 值應為初始狀態 0。此時執行 shouldParkAfterFailedAcquire() 方法會執行最後一個判斷條件將前驅 waitStatus 狀態更新為 SIGNAL,同時方法返回 false 。然後會繼續執行一次 acquireQueued() 中的死迴圈,此時前驅節點的狀態已經被更新為 SIGNAL,再次執行 shouldParkAfterFailedAcquire() 方法會返回 true,當前執行緒即可放心的將自己掛起,等待被執行緒喚醒。
  2. 當呼叫 release() 方法釋放鎖時,會將佔用鎖的節點的 waitStatus 狀態更新為 0,同時會呼叫 LockSupport.unpark() 方法喚醒後繼節點。當後繼節點被喚醒之後,會繼續執行被掛起之前執行的 acquireQueued() 方法中的 for 迴圈再次嘗試獲取鎖。但是被喚醒並不代表一定可以獲取到鎖,如果獲取不到鎖則會再次執行 shouldParkAfterFailedAcquire() 方法。

為什麼說被喚醒的執行緒不一定可以獲取到鎖呢?

對於基礎的 acquire 方法來說,沒有任何規則規定隊首節點一定可以獲取到鎖。當我們在喚醒佇列中的第一個有效執行緒時,此時如果出現了一個執行緒 A 嘗試獲取鎖,那麼該執行緒會呼叫 acquire() 方法嘗試獲取鎖,如果運氣不錯,執行緒 A 完全有可能會竊取當前處於佇列頭中的執行緒獲取鎖的機會。因此基礎的 acquire 方法實際上是不公平的。那麼為什麼這麼做?

如果佇列頭處於解除阻塞過程中,這一段時間實際上沒有執行緒可以獲取資源,屬於一種資源浪費。所以這裡只能認為是有一定概率的公平。

五、資源釋放(釋放鎖)

public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}
private void unparkSuccessor(Node node) {
    // 當狀態小於 0 時,更新 waitStatus 值為 0
    int ws = node.waitStatus;
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);
    // 如果後繼節點為 null 或者狀態為取消,從尾結點向前查詢狀態不為取消的可用節點
    Node s = node.next;
    if (s == null || s.waitStatus > 0) {
        s = null;
        for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0)
                s = t;
    }
    // 喚醒被阻塞的執行緒
    if (s != null)
        LockSupport.unpark(s.thread);
}

release 整體流程比較簡單。需要我們注意的就是為什麼此時需要把 head 節點的狀態更新為 0,主要是便於喚起後續節點,這個問題第四章節也已經聊過了,就不贅述了。

另外,當前節點的後繼為 null 或者 後繼節點的狀態為 CANCEL,那麼會從尾節點開始,從後往前尋找佇列中最靠前的有效節點。


如果你覺得文章寫的還不錯,快給筆者點個贊吧,你的鼓勵是筆者創作最大的支援!!!!!!

相關文章