到底什麼是AQS?面試時你能說明白嗎!

JavaBuild發表於2024-04-04

寫在開頭

上篇文章寫到CAS演算法時,裡面使用AtomicInteger舉例說明,這個類在java.unit.concurrent.atomic包中,儲存的都是一些原子類,除此之外,“java.unit.concurrent”,這個包作為Java中最重要的一個併發工具包,大部分的併發類都在其中,我們今天就來繼續學習這個包中的其他併發工具類。

image

Java併發包

本來今日計劃是學習ReentrantLock(可重入鎖)的,但開啟包後發現還有AbstractOwnableSynchronizer、AbstractQueuedSynchronizer、AbstractQueuedLongSynchronizer這三個類,基於它們在鎖中的重要性,我們今天就花一篇的時間,單獨來學習一下啦。

image

AQS相關類

AOS、AQS、AQLS

  • AOS(AbstractOwnableSynchronizer) : JDK1.6時釋出的,是AQS和AQLS的父類,這個類的主要作用是表示持有者與鎖之間的關係。
    image
AOS
  • AQS(AbstractQueuedSynchronizer) :JDK1.5時釋出,抽象佇列同步器,是一個用來構建鎖和同步器的框架,使用 AQS 能簡單且高效地構造出應用廣泛的同步器,諸如:ReentrantLock,Semaphore,ReentrantReadWriteLock,SynchronousQueue等等皆是基於 AQS 的。AQS 內部使用了一個 volatile 的變數 state(int型別) 來作為資源的標識。
  • AQLS(AbstractQueuedLongSynchronizer) :這個類誕生於JDK1.6,原因時上述的int型別的state資源,在當下的業務場景中,資源數量有可能超過int範圍,因此,便誕生了這個類,採用Long型別的state。
//AQS中共享變數,使用volatile修飾保證執行緒可見性
private volatile int state;

//AQLS中共享變數,採用long型別
private volatile long state;

AQS的底層原理

以上我們大致的介紹了一下AQS的周邊,在很多大廠的面試中提及AQS,被問到最多的就是:“麻煩介紹一下AQS的底層原理?”,很多同學都淺嘗輒止,導致答不出面試官滿意的答案,今天我們就花一定的篇幅去一起學習下AQS的底層結構與實現!

AQS的核心思想

AQS的核心思想或者說實現原理是:在多執行緒訪問共享資源時,若標識的共享資源空閒,則將當前獲取到共享資源的執行緒設定為有效工作執行緒,共享資源設定為鎖定狀態(獨佔模式下),其他執行緒沒有獲取到資源的執行緒進入阻塞佇列,等待當前執行緒釋放資源後,繼續嘗試獲取。

AQS的資料結構

其實AQS的實現主要基於兩個內容,分別是 stateCLH 佇列

①state

state 變數由 volatile 修飾,用於展示當前臨界資源的獲鎖情況。

// 共享變數,使用volatile修飾保證執行緒可見性
private volatile int state;

AQS內部還提供了獲取和修改state的方法,注意,這裡的方法都是final修飾的,意味著不能被子類重寫!

【原始碼解析1】

//返回同步狀態的當前值
protected final int getState() {
     return state;
}
 // 設定同步狀態的值
protected final void setState(int newState) {
     state = newState;
}
//原子地(CAS操作)將同步狀態值設定為給定值update如果當前同步狀態的值等於expect(期望值)
protected final boolean compareAndSetState(int expect, int update) {
      return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

②CLH雙向佇列

我們在上面提到了獨佔模式下,沒有獲取資源的執行緒會被放入佇列,然後阻塞、喚醒、鎖的重分配機制,就是基於CLH實現的。CLH 鎖 (Craig, Landin, and Hagersten locks)是一種自旋鎖的改進,是一個虛擬的雙向佇列,所謂虛擬是指沒有佇列的例項,內部僅存各結點之間的關聯關係。

AQS 將每條請求共享資源的執行緒封裝成一個 CLH 佇列鎖的一個節點(Node)來實現鎖的分配。在 CLH 佇列鎖中,一個節點表示一個執行緒,它儲存著執行緒的引用(thread)、 當前節點在佇列中的狀態(waitStatus)、前驅節點(prev)、後繼節點(next)。

image

CLH結構

CLH的原理

image

CLH原理描述

AQS資源共享

在AQS的框架中對於資源的獲取有兩種方式:

  • 獨佔模式(Exclusive) :資源是獨有的,每次只能一個執行緒獲取,如ReentrantLock;
  • 共享模式(Share) :資源可同時被多個執行緒獲取,具體可獲取個數可透過引數設定,如CountDownLatch。

①獨佔模式

以ReentrantLock為例,其內部維護了一個state欄位,用來標識鎖的佔用狀態,初始值為0,當執行緒1呼叫lock()方法時,會嘗試透過tryAcquire()方法(鉤子方法)獨佔該鎖,並將state值設定為1,如果方法返回值為true表示成功,false表示失敗,失敗後執行緒1被放入等待佇列中(CLH佇列),直到其他執行緒釋放該鎖。

但需要注意的是,線上程1獲取到鎖後,在釋放鎖之前,自身可以多次獲取該鎖,每獲取一次state加1,這就是鎖的可重入性,這也說明ReentrantLock是可重入鎖,在多次獲取鎖後,釋放時要釋放相同的次數,這樣才能保證最終state為0,讓鎖恢復到未鎖定狀態,其他執行緒去嘗試獲取!

image

獨佔模式流程圖

②共享模式

CountDownLatch(倒數計時器)就是基於AQS共享模式實現的同步類,任務分為 N 個子執行緒去執行,state 也初始化為 N(注意 N 要與執行緒個數一致)。這 N 個子執行緒開始執行任務,每執行完一個子執行緒,就呼叫一次 countDown() 方法。該方法會嘗試使用 CAS(Compare and Swap) 操作,讓 state 的值減少 1。當所有的子執行緒都執行完畢後(即 state 的值變為 0),CountDownLatch 會呼叫 unpark() 方法,喚醒主執行緒。這時,主執行緒就可以從 await() 方法(CountDownLatch 中的await() 方法而非 AQS 中的)返回,繼續執行後續的操作。

【注意】
一般情況下,子類只需要根據需求實現其中一種模式就可以,當然也有同時實現兩種模式的同步類,如 ReadWriteLock。

AQS的Node節點

上述的兩種共享模式、執行緒的引用、前驅節點、後繼節點等都儲存在Node物件中,我們接下來就走進Node的原始碼中一探究竟!

【原始碼解析2】

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的值,表示該結點(對應的執行緒)在等待某一條件
    static final int CONDITION = -2;
    /*waitStatus的值,表示有資源可用,新head結點需要繼續喚醒後繼結點(共享模式下,多執行緒併發釋放資源,而head喚醒其後繼結點後,需要把多出來的資源留給後面的結點;設定新的head結點時,會繼續喚醒其後繼結點)*/
    static final int PROPAGATE = -3;

    // 等待狀態,取值範圍,-3,-2,-1,0,1
    volatile int waitStatus;
    volatile Node prev; // 前驅結點
    volatile Node next; // 後繼結點
    volatile Thread thread; // 結點對應的執行緒
    Node nextWaiter; // 等待佇列裡下一個等待條件的結點


    // 判斷共享模式的方法
    final boolean isShared() {
        return nextWaiter == SHARED;
    }

    Node(Thread thread, Node mode) {     // Used by addWaiter
        this.nextWaiter = mode;
        this.thread = thread;
    }

    // 其它方法忽略,可以參考具體的原始碼
}

// AQS裡面的addWaiter私有方法
private Node addWaiter(Node mode) {
    // 使用了Node的這個建構函式
    Node node = new Node(Thread.currentThread(), mode);
    // 其它程式碼省略
}

CANCELLED: 表示當前節點(對應的執行緒)已被取消。當等待超時或被中斷,會觸發進入為此狀態,進入該狀態後節點狀態不再變化;
SIGNAL: 後面節點等待當前節點喚醒;
CONDITION: Condition 中使用,當前執行緒阻塞在Condition,如果其他執行緒呼叫了Condition的signal方法,這個節點將從等待佇列轉移到同步佇列隊尾,等待獲取同步鎖;
PROPAGATE: 共享模式,前置節點喚醒後面節點後,喚醒操作無條件傳播下去;
0:中間狀態,當前節點後面的節點已經喚醒,但是當前節點執行緒還沒有執行完成。

AQS的獲取資源與釋放資源

有了以上的知識積累後,我們再來看一下AQS中關於獲取資源和釋放資源的實現吧。

獲取資源

在AQS中獲取資源的是入口是acquire(int arg)方法,arg 是要獲取的資源個數,在獨佔模式下始終為 1。

【原始碼解析3】

public final void accquire(int arg) {
    // tryAcquire 再次嘗試獲取鎖資源,如果嘗試成功,返回true,嘗試失敗返回false
    if (!tryAcquire(arg) &&
        // 走到這,代表獲取鎖資源失敗,需要將當前執行緒封裝成一個Node,追加到AQS的佇列中
        //並將節點設定為獨佔模式下等待
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        // 執行緒中斷
        selfInterrupt();
}

tryAcquire()是一個可被子類具體實現的鉤子方法,用以在獨佔模式下獲取鎖資源,如果獲取失敗,則把執行緒封裝為Node節點,存入等待佇列中,實現方法是addWaiter(),我們繼續跟入原始碼去看看。

【原始碼解析4】

private Node addWaiter(Node mode) {
 //建立 Node 類,並且設定 thread 為當前執行緒,設定為排它鎖
 Node node = new Node(Thread.currentThread(), mode);
 // 獲取 AQS 中佇列的尾部節點
 Node pred = tail;
 // 如果 tail == null,說明是空佇列,
 // 不為 null,說明現在佇列中有資料,
 if (pred != null) {
  // 將當前節點的 prev 指向剛才的尾部節點,那麼當前節點應該設定為尾部節點
  node.prev = pred;
  // CAS 將 tail 節點設定為當前節點
  if (compareAndSetTail(pred, node)) {
   // 將之前尾節點的 next 設定為當前節點
   pred.next = node;
   // 返回當前節點
   return node;
  }
 }
 enq(node);
 return node;
}

// 自旋CAS插入等待佇列
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;
            }
        }
    }
}

在這部分原始碼中,將獲取資源失敗的執行緒封裝後的Node節點存入佇列尾部,考慮到多執行緒情況下的節點插入問題,這裡提供了自旋CAS的方式保證節點的安全性。

等待佇列中的所有執行緒,依舊從頭結點開始,一個個的嘗試去獲取共享資源,這部分的實現可以看acquireQueued()方法,我們繼續跟入。

【原始碼解析5】

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        // interrupted用於記錄執行緒是否被中斷過
        boolean interrupted = false;
        for (;;) { // 自旋操作
            // 獲取當前節點的前驅節點
            final Node p = node.predecessor();
            // 如果前驅節點是head節點,並且嘗試獲取同步狀態成功
            if (p == head && tryAcquire(arg)) {
                // 設定當前節點為head節點
                setHead(node);
                // 前驅節點的next引用設為null,這時節點被獨立,垃圾回收器回收該節點
                p.next = null; 
                // 獲取同步狀態成功,將failed設為false
                failed = false;
                // 返回執行緒是否被中斷過
                return interrupted;
            }
            // 如果應該讓當前執行緒阻塞並且執行緒在阻塞時被中斷,則將interrupted設為true
            if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        // 如果獲取同步狀態失敗,取消嘗試獲取同步狀態
        if (failed)
            cancelAcquire(node);
    }
}

在這個方法中,從等待佇列的head節點開始,迴圈向後嘗試獲取資源,獲取失敗則繼續阻塞,頭結點若獲取資源成功,則將後繼結點設定為頭結點,原頭結點從佇列中回收掉。

釋放資源

相對於獲取資源,AQS中的資源釋放就簡單多啦,我們直接上原始碼!

【原始碼解析6】

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
    int ws = node.waitStatus;
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);
    // 得到頭結點的後繼結點head.next
    Node s = node.next;
    // 如果這個後繼結點為空或者狀態大於0
    // 透過前面的定義我們知道,大於0只有一種可能,就是這個結點已被取消(只有 Node.CANCELLED(=1) 這一種狀態大於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)
        LockSupport.unpark(s.thread);
}

這裡的tryRelease(arg)透過是個鉤子方法,需要子類自己去實現,比如在ReentrantLock中的實現,會去做state的減少操作int c = getState() - releases;,畢竟這是一個可重入鎖,直到state的值減少為0,表示鎖釋放完畢!

接下來會檢查佇列的頭結點。如果頭結點存在並且waitStatus不為0,這意味著還有執行緒在等待,那麼會呼叫unparkSuccessor(Node h)方法來喚醒後續等待的執行緒。

總結

好啦,到這裡我們對於AQS的學習就告一段落啦,後面我們準備使用AQS去自定義一個同步類,持續關注唷😊😊😊

結尾彩蛋

如果本篇部落格對您有一定的幫助,大家記得留言+點贊+收藏呀。原創不易,轉載請聯絡Build哥!

image

如果您想與Build哥的關係更近一步,還可以關注“JavaBuild888”,在這裡除了看到《Java成長計劃》系列博文,還有提升工作效率的小筆記、讀書心得、大廠面經、人生感悟等等,歡迎您的加入!

image

相關文章