一文搞懂到底什麼是 AQS

fuxing.發表於2024-07-04

前言

日常開發中,我們經常使用鎖或者其他同步器來控制併發,那麼它們的基礎框架是什麼呢?如何實現的同步功能呢?本文將詳細講解構建鎖和同步器的基礎框架--AQS,並根據原始碼分析其原理。


一、什麼是 AQS?

1. AQS 簡介

AQS(Abstract Queued Synchronizer),抽象佇列同步器,它是用來構建鎖或其他同步器的基礎框架。雖然大多數程式設計師可能永遠不會使用到它,但是知道 AQS 的原理有助於理解一些鎖或同步器的是如何執行的。

那麼有哪些同步器是基於 AQS 實現的呢?這裡僅是簡單介紹,詳情後續會單獨總結一篇文章。

同步器 說明
CountDownLatch 遞減的計數器,直至所有執行緒的任務都執行完畢,才繼續執行後續任務。
Semaphore 訊號量,控制同時訪問某個資源的數量。
CyclicBarrier 遞增的計數器,所有執行緒達到屏障時,才會繼續執行後續任務。
ReentrantLock 防止多個執行緒同時訪問共享資源,類似 synchronized 關鍵字。
ReentrantReadWriteLock 維護了讀鎖和寫鎖,讀鎖允許多執行緒訪問,讀鎖阻塞所有執行緒。
Condition 提供類似 Object 監視器的方法,於 Lock 配合可以實現等待/通知模式。
FutureTask 當一個執行緒需要等待另一執行緒把某個任務執行完後才能繼續執行,此時可以使用 FutureTask

如果你理解了 AQS 的原理,也可以基於它去自定義一個同步元件。

2. AQS 資料結構

AQS 核心是透過對同步狀態的管理,來完成執行緒同步,底層是依賴一個雙端佇列來完成同步狀態的管理

  • 當前執行緒獲取同步狀態失敗後,會構造成一個 Node 節點並加入佇列末尾,同實阻塞執行緒。
  • 當同步狀態釋放時,會把頭節點中的執行緒喚醒,讓其再次嘗試獲取同步狀態

如下圖,這裡只是簡單繪製,具體流程見下面原理分析:

image.png

這裡的每個 Node 節點都儲存著當前執行緒、等待資訊等。

3. 資源共享模式

我們在獲取共享資源時,有兩種模式:

模式 說明 示例
獨佔模式 Exclusive,資源同一時刻只能被一個執行緒獲取 ReentrantLock
共享模式 Share,資源可同時被多個執行緒獲取 Semaphore、CountDownLatch

二、AQS 原理分析

先簡單說下原理分析的流程:

  1. 同步狀態相關原始碼;
  2. 須重寫的方法;
  3. Node 節點結構分析;
  4. 獨佔模式下的同步狀態的獲取與釋放;
  5. 共享模式下的同步狀態的獲取與釋放;

1. 同步狀態相關

上面介紹到, AQS 核心是透過對同步狀態的管理,來完成執行緒同步,所以首先介紹管理同步狀態的三個方法,在自定義同步元件時,需要透過它們獲取和修改同步狀態。

//保證可見性
private volatile int state

//獲取當前同步狀態。
protected final int getState() {
    return state;
}

//設定當前同步狀態。
protected final void setState(int newState) {
    state = newState;
}

//使用 CAS 設定當前狀態,保證原子性。
protected final boolean compareAndSetState(int expect, int update) {
    // See below for intrinsics setup to support this
    return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

2. 須重寫的方法

AQS 是基於模板方法模式的,透過第一個 abstract 也可知道,AQS 是個抽象類,使用者需要繼承 AQS 並重寫指定方法。

以下這些方式是沒有具體實現的,需要在使用 AQS 時在子類中去實現具體方法,等到介紹一些同步元件時,會詳細說明如何重寫。

//獨佔式獲取同步狀態,實現該方法須查詢並判斷當前狀態是否符合預期,然後再進行CAS設定狀態。
protected boolean tryAcquire (int arg) 

//獨佔式釋放同步狀態,等待獲取同步狀態的執行緒將有機會獲取同步狀態。
protected boolean tryRelease (int arg) 

//共享式獲取同步狀態,返回大於0的值表示獲取成功,反之獲取失敗。
protected int tryAcquireShared (int arg)

//共享式釋放同步狀態。
protected boolean tryReleaseShared (int arg)

//當前同步器是否再獨佔模式下被執行緒佔用,一般用來表示是否被當前執行緒獨佔。
protected boolean isHeldExclusively ()

3. Node 原始碼

Node 是雙端佇列中的節點,是資料結構的重要部分,執行緒相關的資訊都存在每一個 Node 中。

3.1 Node 結構原始碼

原始碼如下:

static final class Node {
    //標記當前節點的執行緒在共享模式下等待。
    static final Node SHARED = new Node();
    
    //標記當前節點的執行緒在獨佔模式下等待。
    static final Node EXCLUSIVE = null;
    
    //waitStatus的值,表示當前節點的執行緒已取消(等待超時或被中斷)
    static final int CANCELLED =  1;
    
    //waitStatus的值,表示後繼節點的執行緒需要被喚醒
    static final int SIGNAL    = -1;
    
    //waitStatus的值,表示當前節點在等待某個條件,正處於condition等待佇列中
    static final int CONDITION = -2;
    
    //waitStatus的值,表示在當前有資源可用,能夠執行後續的acquireShared操作
    static final int PROPAGATE = -3;

    //等待狀態,值如上,1、-1、-2、-3。
    volatile int waitStatus;
    
    //前趨節點
    volatile Node prev;

    //後繼節點
    volatile Node next;
    
    //當前執行緒
    volatile Thread thread;
    
    //等待佇列中的後繼節點,共享模式下值為SHARED常量
    Node nextWaiter;
    
    //判斷共享模式的方法
    final boolean isShared() {
        return nextWaiter == SHARED;
    }
    
    //返回前趨節點,沒有報NPE
    final Node predecessor() throws NullPointerException {
        Node p = prev;
        if (p == null)
            throw new NullPointerException();
        else
            return p;
    }

    //下面是三個構造方法
    Node() {}    // Used to establish initial head or SHARED marke
    
    Node(Thread thread, Node mode) {     // Used by addWaiter
        this.nextWaiter = mode;
        this.thread = thread;
    }
    Node(Thread thread, int waitStatus) { // Used by Condition
        this.waitStatus = waitStatus;
        this.thread = thread;
    }
}

3.2 設定頭尾節點

Unsafe 類中,提供了一個基於 CAS 的設定頭尾節點的方法,AQS 呼叫該方法進行設定頭尾節點,保證併發程式設計中的執行緒安全。

//CAS自旋設定頭節點
private final boolean compareAndSetHead(Node update) {
    return unsafe.compareAndSwapObject(this, headOffset, null, update);
}


//CAS自旋設定尾節點,expect為當前執行緒“認為”的尾節點,update為當前節點
private final boolean compareAndSetTail(Node expect, Node update) {
    return unsafe.compareAndSwapObject(this, tailOffset, expect, update);
}
    

4. 獨佔模式

資源同一時刻只能被一個執行緒獲取,如 ReentrantLock。

4.1 獲取同步狀態

程式碼如下,呼叫 acquire 方法可以獲取同步狀態,底層就是呼叫須重寫方法中的 tryAcquire。如果獲取失敗則進入同步佇列中,即使後續對執行緒進行終端操作,執行緒也不會從同步佇列中移除。

public final void acquire(int arg) {
    //呼叫須重寫方法中的tryAcquire
    if (!tryAcquire(arg) &&
        //失敗則進入同步佇列中
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

獲取失敗會先呼叫 addWaiter 方法將當前執行緒封裝成獨佔式模式的節點,新增到AQS的佇列尾部,原始碼如下。

private Node addWaiter(Node mode) {
    //將當前執行緒封裝成對應模式下的Node節點
    Node node = new Node(Thread.currentThread(), mode);

    Node pred = tail;//尾節點
    if (pred != null) {
        //雙端佇列需要兩個指標指向
        node.prev = pred;
        //透過CAS方式
        if (compareAndSetTail(pred, node)) {
            //新增到佇列尾部
            pred.next = node;
            return node;
        }
    }
    //等待佇列中沒有節點,或者新增佇列尾部失敗則呼叫end方法
    enq(node);
    return node;
}

//Node節點透過CAS自旋的方式被新增到佇列尾部,直到新增成功為止。
private Node enq(final Node node) {
    //死迴圈,類似 while(1)
    for (;;) {
        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;
            }
        }
    }
}

此時,透過 addWaiter 已經將當前執行緒封裝成獨佔模式的 Node 節點,併成功放入佇列尾部。接下來會呼叫acquireQueued 方法在等待佇列中排隊。

final boolean acquireQueued(final Node node, int arg) {
    //獲取資源失敗標識
    boolean failed = true;
    try {
        //執行緒是否被中斷標識
        boolean interrupted = false;
        //死迴圈,類似 while(1)
        for (;;) {
            //獲取當前節點的前趨節點
            final Node p = node.predecessor();

            //前趨節點是head,即佇列的第二個節點,可以嘗試獲取資源
            if (p == head && tryAcquire(arg)) {
                //資源獲取成功將當前節點設定為頭節點
                setHead(node);
                p.next = null; // help GC,表示head節點出佇列
                failed = false;
                return interrupted;
            }
            //判斷當前執行緒是否可以進入waitting狀態,詳解見下方
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())	//阻塞當前執行緒,詳解見下方
                interrupted = true;
        }
    } finally {
        if (failed)
            //取消獲取同步狀態,原始碼見下方的取消獲取同步狀態章節
            cancelAcquire(node);
    }
}

//將當前節點設定為頭節點
private void setHead(Node node) {
    head = node;
    node.thread = null;
    node.prev = null;
}

//判斷當前執行緒是否可以進入waitting狀態
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    //獲取前趨節點的等待狀態,含義見上方Node結構原始碼
    int ws = pred.waitStatus;
    if (ws == Node.SIGNAL)	//表示當前節點的執行緒需要被喚醒
        return true;
    if (ws > 0) {	//表示當前節點的執行緒被取消

        //則當前節點一直向前移動,直到找到一個waitStatus狀態小於或等於0的節點
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        //排在這個節點的後面
        pred.next = node;
    } else {
        //透過CAS設定等待狀態
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

//阻塞當前執行緒
private final boolean parkAndCheckInterrupt() {
    //底層呼叫的UnSafe類的方法 park:阻塞當前執行緒, unpark:使給定的執行緒停止阻塞
    LockSupport.park(this);
    //中斷執行緒
    return Thread.interrupted();
}

acquireQueued 方法中,只有當前驅節點等於 head 節點時,才能夠嘗試獲取同步狀態,這時為什麼呢?

因為 head 節點是佔有資源的節點,它釋放後才會喚醒它的後繼節點,所以需要檢測。還有一個原因是因為如果遇到了非 head 節點的其他節點出隊或因中斷而從等待中喚醒,這時種情況則需要判斷前趨節點是否為 head 節點,是才允許獲取同步狀態。

獲取同步狀態的整體流程圖如下:

image.png

4.2 釋放同步狀態

呼叫須重寫方法中的 tryAcquire 進行同步狀態的釋放,成功則喚醒佇列中最前面的執行緒,具體如下。

public final boolean release(int arg) {
    //呼叫須重寫方法中的tryRelease
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            //喚醒後繼節點的執行緒,詳情見下方
            unparkSuccessor(h);
        return true;
    }
    return false;
}

//喚醒後繼節點的執行緒
private void unparkSuccessor(Node node) {
    //獲取當前節點的等待狀態
    int ws = node.waitStatus;
    if (ws < 0)
        //小於0則,則嘗試CAS設為0
        compareAndSetWaitStatus(node, ws, 0);

    //獲取後繼節點
    Node s = node.next;

    //後繼節點為空或者等待狀態大於0,代表被節點被取消
    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)
        //底層呼叫的UnSafe類的方法 park:阻塞當前執行緒, unpark:使給定的執行緒停止阻塞
        LockSupport.unpark(s.thread);
}

4.3 其他情況的獲取同步狀態

除此之外,獨佔模式下 AQS 還提供了兩個獲取同步狀態的方法,可中斷的獲取同步狀態和超時獲取同步狀態。

acquire 方法獲取鎖失敗的執行緒是不能被 interrupt 方法中斷的,所以提供了另一個方法 ,從而讓獲取鎖失敗等待的執行緒可以被中斷。底層原始碼與

public final void acquireInterruptibly(int arg)
        throws InterruptedException {
    if (Thread.interrupted())//中斷則丟擲異常
        throw new InterruptedException();
    if (!tryAcquire(arg))
        doAcquireInterruptibly(arg);
}

透過呼叫 tryAcquireNanos 可以在超時時間內獲取同步狀態,可以理解為是上述中斷獲取同步狀態的增強版。


public final boolean tryAcquireNanos(int arg, long nanosTimeout)
        throws InterruptedException {
    if (Thread.interrupted())//中斷則丟擲異常
        throw new InterruptedException();
    return tryAcquire(arg) ||
        doAcquireNanos(arg, nanosTimeout);
}

上面個兩個方法的原始碼均與普通的獨佔獲取同步狀態的原始碼基本類似,感興趣的話可以自行閱讀,這裡不做贅述。

5. 共享模式

資源可同時被多個執行緒獲取,如 Semaphore、CountDownLatch。

5.1 獲取同步狀態

程式碼如下,呼叫 acquireShared 方法可以獲取同步狀態,底層就是先呼叫須重寫方法中的 tryAcquireShared。

tryAcquireShared 返回值的含義:

  • 負數:表示獲取資源失敗
  • 0:表示獲取資源成功,但是沒有剩餘資源
  • 正數:表示獲取資源成功,還有剩餘資源
public final void acquireShared(int arg) {
    //呼叫須重寫方法中的tryAcquireShared
    if (tryAcquireShared(arg) < 0)
        //獲取資源失敗,將當前執行緒放入佇列的尾部並阻塞
        doAcquireShared(arg);
}

若獲取資源失敗,呼叫如下方法將當前執行緒放入佇列的尾部並阻塞,直到有其他執行緒釋放資源並喚醒當前執行緒。

//部分方法與獨佔模式下的方法公用,這裡不再重複說明,詳情見獨佔模式下的獲取同步狀態原始碼。
private void doAcquireShared(int arg) {
    //將當前執行緒封裝成獨佔式模式的節點,新增到AQS的佇列尾部,原始碼在獨佔模式中已分析。
    final Node node = addWaiter(Node.SHARED);

    //獲取資源失敗標識
    boolean failed = true;
    try {
        //執行緒被打斷表示
        boolean interrupted = false;
        
        //死迴圈,類似 while(1)
        for (;;) {
            //獲取當前節點的前趨節點
            final Node p = node.predecessor();
            //前趨節點是head,即佇列的第二個節點,可以嘗試獲取資源
            if (p == head) {
                int r = tryAcquireShared(arg);
                if (r >= 0) {
                    //將當前節點設定為頭節點,若還有剩餘資源,則繼續喚醒佇列中後面的執行緒。
                    setHeadAndPropagate(node, r);
                    p.next = null; // help GC 表示head節點出佇列
                    if (interrupted)
                        selfInterrupt();
                    failed = false;
                    return;
                }
            }
            //判斷當前執行緒是否可以進入waitting狀態,原始碼在獨佔模式中已分析。
            if (shouldParkAfterFailedAcquire(p, node) &&
                //阻塞當前執行緒,原始碼在獨佔模式中已分析。
                parkAndCheckInterrupt()) 
                interrupted = true;
        }
    } finally {
        if (failed)
            //取消獲取同步狀態,原始碼見下方的取消獲取同步狀態章節
            cancelAcquire(node);
    }
}

/*
 * propagate就是tryAcquireShared的返回值
 *	● 負數:表示獲取資源失敗
 *	● 0:表示獲取資源成功,但是沒有剩餘資源
 *	● 正數:表示獲取資源成功,還有剩餘資源
 */
private void setHeadAndPropagate(Node node, int propagate) {
    //將當前節點設定為頭節點,原始碼在獨佔模式中已分析。
    Node h = head; //這時的h是舊的head
    setHead(node);

    // propagate > 0:還有剩餘資源
    // h == null 和 h = head) == null: 不會成立,因為addWaiter已執行
    // waitStatus < 0:若沒有剩餘資源,但waitStatus又小於0,表示可能有新資源釋放
    // 括號中的 waitStatus < 0: 這裡的 h 是此時的新的head(當前節點),
    if (propagate > 0 || h == null || h.waitStatus < 0 ||
        (h = head) == null || h.waitStatus < 0) {
        
        //獲取當前節點的後繼節點
        Node s = node.next;

        //後繼節點不存在或者是共享鎖都需要喚醒,可理解為只要後繼節點不是獨佔模式,都要喚醒
        //可能會導致不必要的喚醒
        if (s == null || s.isShared())
            //喚醒操作在此方法中,詳情見下方的釋放原始碼
            doReleaseShared();
    }
}

5.2 釋放同步狀態

程式碼如下,呼叫 releaseShared 方法可以釋放同步狀態,底層就是先呼叫須重寫方法中的 tryReleaseShared。

public final boolean releaseShared(int arg) {
    //呼叫須重寫方法中的tryReleaseShared
    if (tryReleaseShared(arg)) {
        //嘗試釋放資源成功,會繼續喚醒佇列中後面的執行緒。
        doReleaseShared();
        return true;
    }
    return false;
}

//喚醒佇列中後面的執行緒
private void doReleaseShared() {

    //死迴圈,自旋操作
    for (;;) {
        //獲取頭節點
        Node h = head;
        if (h != null && h != tail) {
            int ws = h.waitStatus;
            //signal表示後繼節點需要被喚醒
            if (ws == Node.SIGNAL) {
                //自旋將頭節點的waitStatus狀態設定為0
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    continue;            // loop to recheck cases
                
                //喚醒頭節點的後繼節點,原始碼見獨佔模式的釋放
                unparkSuccessor(h);
            }
            //後繼節點不需要喚醒,則把當前節點狀態設定為PROPAGATE確保以後可以傳遞下去
            else if (ws == 0 &&
                     !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                continue;                // loop on failed CAS
        }
        //判斷頭節點是否變化,沒有則退出迴圈。
        //有變化說明其他執行緒已經獲取了同步狀態,需要進行重試操作。
        if (h == head)                   // loop if head changed
            break;
    }
}

6. 取消獲取同步狀態

無論是獨佔模式還是共享模式,所有的程獲取同步狀態的過程中,如果發生異常或是超時喚醒等,都需要將當前的節點出隊,原始碼如下。

//一般在獲取同步狀態方法的finally塊中
private void cancelAcquire(Node node) {
    if (node == null)
        return;
    node.thread = null;		//當前執行緒節點設為null
    Node pred = node.prev;		//獲取前驅節點

    //前趨節點為取消狀態,向前遍歷找到非取消狀態的節點
    while (pred.waitStatus > 0)
        node.prev = pred = pred.prev;

    Node predNext = pred.next;	//獲取非取消節點的下一個節點

    node.waitStatus = Node.CANCELLED;	//將當前節點的等待狀態設為取消狀態

    //當前節點是尾節點,則自旋將尾節點設定為前一個非取消節點
    if (node == tail && compareAndSetTail(node, pred)) {
        //將尾節點設為前一個非取消的節點,並將其後繼節點設為null,help GC
        compareAndSetNext(pred, predNext, null);
    } else {
     
        int ws;//用於表示等待狀態

        //pred != head:前一個非取消的節點非頭節點也非尾節點
        //ws == Node.SIGNAL:當前等待狀態為待喚醒
        //若不是待喚醒則CAS設定為待喚醒狀態
        //前一個非取消的節點的執行緒不為null
        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 {
            //前一個非取消的節點為頭節點
            //喚醒後繼節點的執行緒,詳情見獨佔模式釋放同步狀態的原始碼
            //喚醒是為了執行shouldParkAfterFailedAcquire()方法,詳解見上面的acquireQueued原始碼
            //該方法中從後往前遍歷找到第一個非取消的節點並將中間的移除佇列
            unparkSuccessor(node);
        }
        //移除當前節點
        node.next = node; // help GC
    }
}

三、總結

AQS 是用來構建鎖或其他同步器的基礎框架,底層是一個雙端佇列。支援獨佔和共享兩種模式下的資源獲取與釋放,基於 AQS 可以自定義不同型別的同步元件。

在獨佔模式下,獲取同步狀態時,AQS 維護了一個雙端佇列,獲取失敗的執行緒都會被加入到佇列中進行自旋,移出佇列的條件就是前趨節點為 head 節點併成功獲取同步狀態。釋放同步狀態時,會喚醒 head 節點的後繼節點。

在共享模式下,獲取同步狀態時,同樣維護了一個雙端佇列,獲取失敗的的執行緒也會加入到佇列中進行自旋,移除佇列的條件也與獨佔模式一樣。

但是在喚醒操作上,在資源數量足夠的情況下,共享模式會將喚醒事件傳遞到後面的共享節點上,進行了後續節點的喚醒,解所成功後仍會喚醒後續節點。

關於 AQS 重要的幾個元件的特點、原理以及對應的應用場景,後續會單獨寫一篇文章。若發現其他問題歡迎指正交流。


參考:

[1] 翟陸續/薛賓田. Java 併發程式設計之美.

[2] 方騰飛/魏鵬/程曉明. Java 併發程式設計的藝術.

[3] Lev Vygotsky. Java 併發程式設計實踐

相關文章