全網最詳細的AbstractQueuedSynchronizer(AQS)原始碼剖析(一)AQS基礎

酒冽發表於2021-12-11

AbstractQueuedSynchronizer(以下簡稱AQS)的內容確實有點多,博主考慮再三,還是決定把它拆成三期。原因有三,一是放入同一篇部落格勢必影響閱讀體驗,而是為了表達對這個偉大基礎併發元件的崇敬之情。第三點其實是為了偷懶。
又扯這麼多沒用的,還是直接步入正題吧~

AQS介紹

AQS是一個抽象類,它是實現多種併發同步工具的核心元件。比如大名鼎鼎的可重入鎖(ReentrantLock),它的底層實現就是藉助內部類Sync,而Sync類就是繼承了AQS並實現了AQS定義的若干鉤子方法。這些併發同步工具包括:

從設計模式上來看,AQS主要使用的是模板方法模式(Template Method Pattern)。它提供了若干鉤子方法供子類實現(如tryAcquire、tryRelease等),AQS的模板方法(如acquire、release等)會呼叫這些鉤子方法。子類使用AQS的方式就是直接呼叫AQS的模板方法,並重寫這些模板方法涉及到的特定鉤子方法即可。不需要呼叫的鉤子方法可以不用重寫,AQS為它們均提供了預設實現:丟擲UnsupportedOperationException異常

此外,AQS也提供了其他一些方法供子類呼叫,如getState、hasQueuedPredecessors等方法,方便子類獲取、判斷同步器的狀態

什麼是鉤子方法?
鉤子方法的概念源於模板方法模式,這種模式是在一個方法中定義了演算法的骨架,某些關鍵步驟會交給子類去實現。模板方法在不改變演算法本身結構的情況下,允許子類自定義其中一些關鍵步驟
這些關鍵步驟可以由父類定義成方法,這些方法可以是抽象方法,或鉤子方法

  • 抽象方法:父類定義但不實現,由abstract關鍵字標識
  • 鉤子方法:父類定義且實現,但這種實現一般都是空實現,並沒有任何意義,這麼做只是為了方便子類根據需要重寫特定的鉤子方法,而不用實現所有的鉤子方法

AQS的核心思想:

  • 使用一個volatile int變數state(也被稱為資源),進行同步控制,但是state在不同的同步工具實現中具有不同的語義。另外配合Unsafe類提供的CAS操作,原子性地修改state值,保證其執行緒安全性
  • AQS內部維護了一個同步佇列,用來管理排隊的執行緒。另外需要藉助LockSupport類提供的執行緒阻塞、喚醒方法
作者:酒冽        出處:https://www.cnblogs.com/frankiedyz/p/15673957.html
版權:本文版權歸作者和部落格園共有
轉載:歡迎轉載,但未經作者同意,必須保留此段宣告;必須在文章中給出原文連線;否則必究法律責任

AQS的基本結構

狀態state

AQS使用volatile int變數state來作為核心狀態,所有的同步控制都是圍繞這個state來進行的,volatile保證其記憶體可見性,並使用CAS確保state的修改是原子性的。volatile和CAS同時存在,就保證了state的執行緒安全性

對於不同的同步工具實現來說,語義是不同的,如下:

  • ReentratntLock:表示當前執行緒獲取鎖的重入次數,0表示鎖空閒
  • ReentrantReadWriteLock:state的高16位表示讀鎖數量,低16位表示寫鎖數量
  • CountDownLatch:表示當前的計數值
  • Semaphore:表示當前可用訊號量的個數

針對state這個核心狀態,AQS提供了getState、setState等多個獲取、修改方法,原始碼如下:

private volatile int state;

protected final int getState() {
    return state;
}

protected final void setState(int newState) {
    state = newState;
}

protected final boolean compareAndSetState(int expect, int update) {
    // See below for intrinsics setup to support this
    return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
作者:酒冽        出處:https://www.cnblogs.com/frankiedyz/p/15673957.html
版權:本文版權歸作者和部落格園共有
轉載:歡迎轉載,但未經作者同意,必須保留此段宣告;必須在文章中給出原文連線;否則必究法律責任

同步佇列

Node類

AQS內部維護了一個同步佇列(網上有些文章會叫它為CLH佇列,至於為啥叫這個我也不知道-_-||,但不重要~)。佇列中的每個節點都是Node型別。其原始碼如下:

static final class Node {
    
    static final Node SHARED = new Node();
    static final Node EXCLUSIVE = null;

    static final int CANCELLED =  1;
    static final int SIGNAL    = -1;
    static final int CONDITION = -2;
    static final int PROPAGATE = -3;

    volatile int waitStatus;

    volatile Node prev;
    volatile Node next;

    volatile Thread thread;

    Node nextWaiter;

    final boolean isShared() {
        return nextWaiter == SHARED;
    }

    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 marker
    }

    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;
    }
}

prev、next用於儲存該節點的前驅、後繼節點,表明這個同步佇列是一個雙向佇列

Node的thread域儲存了對應的執行緒,只有在建立時賦值,使用完要null掉,以方便GC

Node使用SHAREDEXCLUSIVE兩個常量來標記該執行緒是由於獲取共享資源、互斥資源失敗,而被阻塞並放入到同步佇列中進行等待

Node使用waitStatus來記錄當前執行緒的等待狀態,通過CAS進行修改。它的取值可以是:

  • CANCELLED:表示該節點由於超時中斷而被取消。該狀態不會再轉變為其他狀態,而且該節點的執行緒再也不會被阻塞
  • SIGNAL:表示其後繼節點(後面相鄰的那個節點)需要被喚醒,即該執行緒被釋放或被取消時,必須喚醒其後繼節點
  • CONDITION:表示該節點的執行緒在條件佇列中等待,而非在同步佇列中。如果該條件變數signal該節點後,該節點會被轉移到同步佇列中參與資源競爭
  • PROPAGATE:只有在共享模式下才會被用到,表示無條件傳播狀態。引入這個狀態是為了解決共享模式下併發釋放而引起的執行緒掛起的bug,這裡不多解釋,網上有文章給出了更加詳細的解釋,見下方

AQS:為什麼需要PROPAGATE?
AQS原始碼深入分析之共享模式-你知道為什麼AQS中要有PROPAGATE這個狀態嗎?

同步佇列的結構

AQS中維護了一個同步佇列,它通過兩個指標標記隊頭隊尾,分別是headtail,原始碼如下:

private transient volatile Node head;

private transient volatile Node tail;

該佇列的出入規則遵循FIFO(First In, First Out)

注意:如果該同步佇列非空,那麼head其實並不是指向第一個執行緒對應的Node,而是指向一個空的Node

接下來讓我們剖析一下AQS針對這個同步佇列設計的入隊、出隊演算法

入隊演算法

入隊事件主要線上程嘗試獲取資源失敗時觸發。當執行緒嘗試獲取資源失敗之後,會將該執行緒加入到同步佇列的隊尾

入隊演算法的原始碼見AQS的addWaiter方法,如下:

// mode可以是Node.EXCLUSIVE或Node.SHARED
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;
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    enq(node);
    return node;
}

首先為該執行緒建立一個Node節點,mode可以是Node.EXCLUSIVE或Node.SHARED,表示兩種不同的模式。
之後直接CAS試圖將其入隊。這裡注意,如果佇列本身為空,或CAS競爭失敗,才會進入enq方法。這裡addWaiter方法出於效能考慮,先嚐試快捷的入隊方式,不成功才執行eng方法

eng方法是完整的入隊邏輯,原始碼如下:

private Node enq(final Node node) {
    for (;;) {
        Node t = tail;
        if (t == null) { // 如果佇列為空,則將head和tail初始化為同一個空Node
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            node.prev = t;
            if (compareAndSetTail(t, node)) {	// 不斷CAS直到成功為止
                t.next = node; 
                return t;
            }
        }
    }
}

enq中的程式碼都包含在for迴圈中,如果CAS失敗,就會不斷迴圈CAS直到成功為止

注意,這段程式碼也體現出同步佇列的三個特點

  • 入隊都是從隊尾
  • 進入佇列的操作都是CAS操作,保證了執行緒安全性
  • 如果佇列為空,則head和tail都為null;如果不為空,head指向的節點並不是第一個執行緒對應的節點,而是一個啞節點

出隊演算法

出隊事件主要發生在:位於同步佇列中的執行緒再次獲取資源,併成功獲取時

出隊演算法在AQS中並沒有直接對應的方法,而是零散分佈在某些方法中。因為獲取資源失敗而被阻塞的執行緒被喚醒後,會重新嘗試獲取資源。如果獲取成功,則會執行出隊邏輯

例如,在acquireQueued中,就包含了出隊事件:

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            final Node p = node.predecessor();
            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);
    }
}

private void setHead(Node node) {
    head = node;
    node.thread = null;
    node.prev = null;
}

出隊的邏輯體現在第6-9行,此時p指向head指向的空節點,而node是隊首元素(不是第一個空節點)
首先呼叫setHead方法,將head指向node、將node的thread域、prev域置空,然後將head的next域置空,以方便該節點的GC

節點的取消

執行緒會因為超時或中斷而被取消,之後不會再參與鎖的競爭,會等待GC

取消的過程見cancelAcquire方法,該方法的呼叫時機都是在獲取資源失敗之後,而失敗就是由於超時或中斷。其原始碼如下:

private void cancelAcquire(Node node) {
    if (node == null)
        return;

    node.thread = null;		// 將thread域置空以方便GC

    // 向前遍歷並跳過被取消的Node
    Node pred = node.prev;
    while (pred.waitStatus > 0)
        node.prev = pred = pred.prev;

    Node predNext = pred.next;
    node.waitStatus = Node.CANCELLED;

    // 如果是tail,那麼將tail修改為pred
    if (node == tail && compareAndSetTail(node, pred)) {
        compareAndSetNext(pred, predNext, null);
    } else {
        int ws;
        if (pred != head &&
            ((ws = pred.waitStatus) == Node.SIGNAL || (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
            pred.thread != null) {
            // 如果node的next需要signal,那麼就將pred的next設為node的next
            Node next = node.next;
            if (next != null && next.waitStatus <= 0)
                compareAndSetNext(pred, predNext, next);
        } else {
            unparkSuccessor(node);
        }
        node.next = node; // help GC
    }
}

總之,cancelAcquire方法就是將目標節點node的thread域置空,並將waitStatus置為CANCELLED

這裡有一個問題:node的後繼節點next的prev指標仍然指向node,沒有更新為pred,這不僅語義上是錯誤的,而且會阻礙node被GC。那麼何時進行更新?
答:任何其他執行緒嘗試獲取鎖失敗之後,都會被放入同步佇列,然後呼叫shouldParkAfterFailedAcquire方法判斷是否應該被阻塞。如果發現當前節點的前驅節點被置為CANCELLED,就會執行:

do {
    node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);

此外,cancelAcquire方法也會做類似的操作,如下:

Node pred = node.prev;
while (pred.waitStatus > 0)
	node.prev = pred = pred.prev;

這兩處都會更新被取消節點的後繼節點的prev指標,所以前面說到的的問題根本不存在

注意:cancelAcquire的呼叫時機一般都是在獲取鎖邏輯後面的finally塊中,如果獲取失敗就會呼叫cancelAcquire方法。獲取失敗的原因主要有兩個,中斷或超時

總結:

  • 節點被取消的原因:獲取鎖超時或在獲取的過程中被中斷
  • 取消節點的主要邏輯:將其waitStatus修改為CANCELLED。再將節點thread域置空,將指向它的next指標指向其後繼節點,以方便GC

好了,到這裡為止,我們就完成了對AQS基本結構的分析。這裡如果有不懂的地方,可以暫時跳過,等看完後續部落格再回頭看這篇,應該就能明白了
下一篇我們會逐步剖析AQS如何實現對資源的獲取和釋放,go go go!

相關文章