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

推薦閱讀:

前言

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

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

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

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

試想有這麼一種場景:有四個線程由於業務需求需要同時佔用某資源,但該資源在同一個時刻只能被其中唯一線程所獨佔。那麼此時應該如何標識該資源已經被獨佔,同時剩餘無法獲取該資源的線程又該何去何從呢?

這裏就涉及到了關於共享資源的競爭與同步關係。對於不同的開發者來說,實現的思路可能會有不同。這時如果能夠有一個較爲通用的且性能較優同步框架,那麼可以在一定程度上幫助開發人員快速有效的完成多線程資源同步競爭方面的編碼。

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

從以下幾個點切入翻閱 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,那麼會從尾節點開始,從後往前尋找隊列中最靠前的有效節點。


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

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