Java 併發程式設計 ----- AQS(抽象佇列同步器)

擁抱心中的夢想發表於2018-05-16

一、什麼是 AQS ?

AQS即AbstractQueuedSynchronizer的縮寫,是併發程式設計中實現同步器的一個框架。框架,框架,重要的事情說三遍,框架就是說它幫你處理了很大一部分的邏輯,其它功能需要你來擴充套件。想想你使用Spring框架的場景,Spring幫助開發者實現IOC容器的bean依賴管理,標籤解析等,我們只需要對bean進行配置即可,其他不用管。

AQS基於一個FIFO雙向佇列實現,被設計給那些依賴一個代表狀態的原子int值的同步器使用。我們都知道,既然叫同步器,那個肯定有個代表同步狀態(臨界資源)的東西,在AQS中即為一個叫state的int值,該值通過CAS進行原子修改。

在AQS中存在一個FIFO佇列,佇列中的節點表示被阻塞的執行緒,佇列節點元素有4種型別, 每種型別表示執行緒被阻塞的原因,這四種型別分別是:

  • CANCELLED : 表示該執行緒是因為超時或者中斷原因而被放到佇列中
  • CONDITION : 表示該執行緒是因為某個條件不滿足而被放到佇列中,需要等待一個條件,直到條件成立後才會出隊
  • SIGNAL : 表示該執行緒需要被喚醒
  • PROPAGATE : 表示在共享模式下,當前節點執行釋放release操作後,當前結點需要傳播通知給後面所有節點

由於一個共享資源同一時間只能由一條執行緒持有,也可以被多個執行緒持有,因此AQS中存在兩種模式,如下:

  • 1、獨佔模式

    獨佔模式表示共享狀態值state每次能由一條執行緒持有,其他執行緒如果需要獲取,則需要阻塞,如JUC中的ReentrantLock

  • 2、共享模式

    共享模式表示共享狀態值state每次可以由多個執行緒持有,如JUC中的CountDownLatch

二、AQS 中的核心資料結構和方法

1、既然AQS是基於一個FIFO佇列的框架,那麼我們先來看下佇列的元素節點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;
    
    // waitStatus只取上面CANCELLED、SIGNAL、CONDITION、PROPAGATE四種取值之一
    volatile int waitStatus;

    // 表示前驅節點
    volatile Node prev;

    // 表示後繼節點
    volatile Node next;

    // 佇列元素需要關聯一個執行緒物件
    volatile Thread thread;

    // 表示下一個waitStatus值為CONDITION的節點
    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() {}

    /**
     *  當有執行緒需要入隊時,那麼就建立一個新節點,然後關聯該執行緒物件,由addWaiter()方法呼叫
     */
    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;
    }
}
複製程式碼

總結下Node節點資料結構設計,佇列中的元素,肯定是為了儲存由於某種原因導致無法獲取共享資源state而被入隊的執行緒,因此Node中使用了waitStatus表示節點入隊的原因,使用Thread物件來表示節點所關聯的執行緒。至於prev,next,則是一般雙向佇列資料結構必須提供的指標,用於對佇列進行相關操作。

2、AQS中的共享狀態值

之前提到,AQS是基於一個共享的int型別的state值來實現同步器同步的,其宣告如下:

 /**
 * 同步狀態值
 */
private volatile int state;

/**
 * 獲取同步狀態值
 */
protected final int getState() {
    return state;
}

/**
 * 修改同步狀態值
 */
protected final void setState(int newState) {
    state = newState;
}
複製程式碼

由原始碼我們可以看出,AQS宣告瞭一個int型別的state值,為了達到多執行緒同步的功能,必然對該值的修改必須多執行緒可見,因此,state採用volatile修飾,而且getState()setState()方法採用final進行修飾,目的是限制AQS的子類只能呼叫這兩個方法對state的值進行設定和獲取,而不能對其進行重寫自定義設定/獲取邏輯。

AQS中提供對state值修改的方法不僅僅只有setState()getState(),還有諸如採用CAS機制進行設定的compareAndSetState()方法,同樣,該方法也是採用final修飾的,不允許子類重寫,只能呼叫。

3、AQS中的tryXXX方法

一般基於AQS實現的同步器,如ReentrantLock,CountDownLatch等,對於state的獲取操作,子類只需重寫其tryAcquire()tryAcquireShared()方法即可,這兩個方法分別對應獨佔模式和共享模式下對state的獲取操作;而對於釋放操作,子類只需重寫tryRelease()tryReleaseShared()方法即可。

至於如何維護佇列的出隊、入隊操作,子類不用管,AQS已經幫你做好了。

三、AQS 設計妙處

優秀的專案總會有亮點可挖,AQS也是。小編在看了AQS的原始碼之後,結合其他作者相關部落格,總結了以下兩點感覺很優秀的設計點,這是我們應該學習的,前輩總是那麼優秀。

1、自旋鎖

當我們執行一個有確定結果的操作,同時又需要併發正確執行,通常可以採用自旋鎖實現。在AQS中,自旋鎖採用 死迴圈 + CAS 實現。針對AQS中的enq()進行講解:

private Node enq(final Node node) {
    // 死迴圈 + CAS ,解決入隊併發問題
    /**
     * 假設有三個執行緒同時都需要入隊操作,那麼使用死迴圈和CAS可保證併發安全,同一時間只有一個節點安全入隊,入隊失敗的執行緒則迴圈重試
     * 
     * 1、如果不要死迴圈可以嗎?只用CAS.
     *   不可以,因為如果其他執行緒修改了tail的值,導致1處程式碼返回false,那麼方法enq方法將推出,導致該入隊的節點卻沒能入隊
     * 
     * 2、如果只用死迴圈,不需要CAS可以嗎?
     *   不可以,首先不需要使用CAS,那就沒必要再使用死迴圈了,再者,如果不使用CAS,那麼當執行1處程式碼時,將會改變佇列的結構
     */
    for (;;) {
        // 獲取尾部節點
        Node t = tail;
        // 如果還沒有初始化,那麼就初始化
        if (t == null) { // Must initialize
            if (compareAndSetHead(new Node()))
                // 剛開始肯定是頭指標和尾指標相等
                tail = head;
        } else {
            // 當前結點的前驅節點等於尾部節點
            node.prev = t;
            // 如果當前尾結點仍然是t,那麼執行入隊並返回true,否則返回false,然後重試
            if (compareAndSetTail(t, node)) {   // 1
                t.next = node;
                return t;
            }
        }
    }
}
複製程式碼

首先入隊操作要求的最終結果必須是一個節點插入到佇列中去,只能成功,不能失敗!然而這個入隊的操作是需要併發執行的,有可能同時有很多的執行緒需要執行入隊操作,因此我們需要採取相關的執行緒同步機制。自旋鎖採取樂觀策略,即使用了CAS中的compareAndSet()操作,如果某次執行返回fasle,那麼當前操作必須重試,因此,採用for死迴圈直到成功為止,成功,則break跳出for迴圈或者直接return操作退出方法。

2、模板方法

在AQS中,模板方法設計模式體現在其acquire()、release()方法上,我們先來看下原始碼:

 public final void acquire(int arg) {
        // 首先嚐試獲取共享狀態,如果獲取成功,則tryAcquire()返回true
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }
複製程式碼

其中呼叫tryAcquire()方法的預設實現是丟擲一個異常,也就是說tryAcquire()方法留給子類去實現,acquire()方法定義了一個模板,一套處理邏輯,相關具體執行方法留給子類去實現。

關於更多模板方法設計模式,可以查閱談一談我對‘模板方法’設計模式的理解(Template)

四、自定義自己的併發同步器

下邊以JDK文件的一個例項進行介紹:

class Mutex implements Lock, java.io.Serializable {
    // 自定義同步器
    private static class Sync extends AbstractQueuedSynchronizer {
        // 判斷是否鎖定狀態
        protected boolean isHeldExclusively() {
            return getState() == 1;
        }

        // 嘗試獲取資源,立即返回。成功則返回true,否則false。
        public boolean tryAcquire(int acquires) {
            assert acquires == 1; // 這裡限定只能為1個量
            if (compareAndSetState(0, 1)) {//state為0才設定為1,不可重入!
                setExclusiveOwnerThread(Thread.currentThread());//設定為當前執行緒獨佔資源
                return true;
            }
            return false;
        }

        // 嘗試釋放資源,立即返回。成功則為true,否則false。
        protected boolean tryRelease(int releases) {
            assert releases == 1; // 限定為1個量
            if (getState() == 0)//既然來釋放,那肯定就是已佔有狀態了。只是為了保險,多層判斷!
                throw new IllegalMonitorStateException();
            setExclusiveOwnerThread(null);
            setState(0);//釋放資源,放棄佔有狀態
            return true;
        }
    }

    // 真正同步類的實現都依賴繼承於AQS的自定義同步器!
    private final Sync sync = new Sync();

    //lock<-->acquire。兩者語義一樣:獲取資源,即便等待,直到成功才返回。
    public void lock() {
        sync.acquire(1);
    }

    //tryLock<-->tryAcquire。兩者語義一樣:嘗試獲取資源,要求立即返回。成功則為true,失敗則為false。
    public boolean tryLock() {
        return sync.tryAcquire(1);
    }

    //unlock<-->release。兩者語文一樣:釋放資源。
    public void unlock() {
        sync.release(1);
    }

    //鎖是否佔有狀態
    public boolean isLocked() {
        return sync.isHeldExclusively();
    }
}
複製程式碼

實現自己的同步類一般都會自定義同步器(sync),並且將該類定義為內部類,供自己使用;而同步類自己(Mutex)則實現某個介面,對外服務。當然,介面的實現要直接依賴sync,它們在語義上也存在某種對應關係!!而sync只用實現資源state的獲取-釋放方式tryAcquire-tryRelelase,至於執行緒的排隊、等待、喚醒等,上層的AQS都已經實現好了,我們不用關心。

除了Mutex,ReentrantLock/CountDownLatch/Semphore這些同步類的實現方式都差不多,不同的地方就在獲取-釋放資源的方式tryAcquire-tryRelelase。掌握了這點,AQS的核心便被攻破了!

相關文章