一篇文章瞭解什麼是AQS和應用

JieMingLi發表於2019-04-07

瞭解AQS

簡介

AbstractQueueSynchronized的縮寫,也叫抽象的佇列式同步器。定義了一套多執行緒訪問共享資源的同步器框架。

字如其名,他是一個抽象類,所以大部分同步類都是繼承於它,然後重寫部分方法即可。

比如說ReentrantLock/Sema

phore/CountDownLatch都是AQS的具體實現類。

功能

AQS維護了一個共享資源State和一個FIFO的等待佇列,當有多個執行緒爭搶資源的時候就會阻塞進入此佇列。

執行緒在爭搶State這個共享資源的時候,會被封裝成一個Node節點,也就是說在AQS的等待佇列裡面的元素都是Node型別的物件。

PS:阻塞佇列中,不包括Head節點。

一篇文章瞭解什麼是AQS和應用

在瞭解AQS之前,我們先了解下Node內部是怎樣的,我們先來看看原始碼

static final class Node {
    // 標識節點當前在共享模式下
    static final Node SHARED = new Node();
    // 標識節點當前在獨佔模式下
    static final Node EXCLUSIVE = null;

    // ======== 下面的幾個int常量是給waitStatus用的 ===========
    // 程式碼此執行緒取消了爭搶這個鎖
    static final int CANCELLED =  1;
    /** waitStatus value to indicate successor's thread needs unparking */
    // 官方的描述是,其表示當前node的後繼節點對應的執行緒需要被喚醒
    //通俗的話來說就是,如果A節點被設定為SIGNAL,假如B是A的後繼節點,那麼B需要依賴A節點來喚醒才能拿到鎖
    static final int SIGNAL    = -1;
   
    // 本文不分析condition,所以略過吧,下一篇文章會介紹這個
    static final int CONDITION = -2;
    
    // 同樣的不分析,略過吧
    static final int PROPAGATE = -3;
    // =====================================================


    // 取值為上面的1、-1、-2、-3,或者0(以後會講到)
    // 這麼理解,暫時只需要知道如果這個值大於0代表此執行緒取消了等待,
    // ps: 半天搶不到鎖,不搶了,ReentrantLock是可以指定timeouot的
    volatile int waitStatus;
    // 前驅節點的引用
    volatile Node prev;
    // 後繼節點的引用
    volatile Node next;
    // 這個就是本執行緒
    volatile Thread thread;

}
複製程式碼

結構

//頭結點,當前持有鎖的執行緒
private transient volatile Node head;

//尾節點,每次有新的節點進來,都要放在尾節點後面
private transient volatile Node tail;

//當前鎖的狀態,值為0的時候,表示共享資源沒有被佔用,1的時候表示有一個執行緒佔用,如果大於1則表示重入
private volatile int state;

// 代表當前持有獨佔鎖的執行緒,舉個最重要的使用例子,因為鎖可以重入
// reentrantLock.lock()可以巢狀呼叫多次,所以每次用這個來判斷當前執行緒是否已經擁有了鎖
// if (currentThread == getExclusiveOwnerThread()) {state++}
private transient Thread exclusiveOwnerThread; 
複製程式碼

因為AQS只是提供了一個模板,那麼具體資源的獲取方式和釋放方式就由具體的實現類來決定。

下面我們跟著看原始碼一起看看AQS到底是什麼東西

對於State的訪問,AQS定義了以下3種方式

  1. getState()
  2. setState()
  3. compareAndSetState()

AQS定義了兩種訪問資源的方式

  1. 獨佔模式 ,也就是說只有一個執行緒可以訪問資源,如ReentranctLock
  2. 共享模式,表示可以有多個執行緒訪問資源,如Semaphore/CountDownLatch

上面我們說過,具體的同步實現器就是實現資源state的獲取和釋放的方式就好了,關於佇列的維護,Node節點的入隊出隊或者獲取資源失敗等操作,AQS已經實現好

自定義的同步器只要實現以下方法,就可以實現出不同的同步器

  • 獨佔模式
    1. tryAcquire(int),嘗試獲取資源,獲取成功的話返回true,否則false
    2. tryRealease(int),嘗試釋放資源,釋放成功的話返回true,否則false
  • 共享模式
    1. tryAcquireShared(int),嘗試獲取資源。負數表示失敗;0表示成功,但沒有剩餘可用資源;正數表示成功,且有剩餘資源。
    2. tryReleaseShared(int),嘗試釋放資源,如果釋放後允許喚醒後續等待結點返回true,否則返回false。

PS:以上的方法在AQS上是沒有實現的,只有在具體的同步類實現器才會實現。

獨佔模式的原始碼分析

以獨佔模式的ReentractLock的公平鎖為例子

加鎖

其實在每個具體的同步類(獨佔模式)的操作資源的介面中,最終呼叫的是AQS的acquire方法(比如說ReentractLock的公平鎖)

一篇文章瞭解什麼是AQS和應用

一篇文章瞭解什麼是AQS和應用

所以我們看acquire的方法具體是怎麼實現,至於其他不同的同步器的方法呼叫,也差不多都理解了。

acquire的原始碼

關於解釋都在程式碼裡面了

 public final void acquire(int arg) {//arg = 1,表示同步器想要1個state資源
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }
// tryAcquire,顧名思義,就是先嚐試獲取一下,如果獲取成功,就返回true,那麼獲取資源也就結束了,否則,就把當前執行緒設定為獨佔模式(EXCLUSIVE),壓到阻塞佇列中。
// addWaiter就是把當前執行緒封裝成Node物件,並且設定為獨佔模式(EXCLUSIVE),加入阻塞佇列


複製程式碼

下面繼續看tryAcquire的原始碼

tryAcquire的原始碼

注意:這裡用ReentranctLock只是為了方便舉例子,不同的同步器實現不同的方法而已.

 protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
     		//獲取資源
            int c = getState();
     		//如果c為0,說明資源沒有執行緒佔用,則可以去搶
            if (c == 0) {
                //既然是公平鎖,那麼肯定就講究先來後到
                //hasQueuedPredecessors先看看前面有沒有Node節點在等待,如果沒有,就通過CAS去獲取一下
                //在這裡存在著執行緒競爭,所以有可能成功有可能失敗,如果成功獲得資源,那麼compareAndSetState返回true,否則false
                if (!hasQueuedPredecessors() &&
                    compareAndSetState(0, acquires)) {
                    //到這裡說明前面沒有執行緒在等待並且成功搶佔到臨界資源
                    //所以就設定當前執行緒為佔有資源的執行緒,方便後面判斷重入
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            else if (current == getExclusiveOwnerThread()) {
                //到這裡說明是重入的問題
                int nextc = c + acquires;
                if (nextc < 0)
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
     		//到這裡就是臨界資源被佔用,而且不是重入的情況,也就是說head節點都還沒釋放資源!
            return false;
        }
複製程式碼

下面繼續看addWaiter的原始碼和acquireQueued的原始碼

addWaiter原始碼

 private Node addWaiter(Node mode) {//傳入的是Node.EXCLUSIVE
     	//將當前執行緒封裝成Node物件,並設定為獨佔模式
        Node node = new Node(Thread.currentThread(), mode);
        // Try the fast path of enq; backup to full enq on failure
     	//找到尾節點
        Node pred = tail;
     	//如果找到隊尾,說明佇列不為空(如果只有head,其實佇列式為空)
        if (pred != null) {
            //把隊尾設定為插入節點的字首節點
            node.prev = pred;
            //通過CAS操作,將傳入的執行緒放到隊尾,這裡用CAS操作,是因為此時可能會有多個執行緒插入隊尾,所以在此時隊尾元素是不太確定的
            if (compareAndSetTail(pred, node)) {
                //進入這裡,說明當前隊尾元素就是當前執行緒,設定字首節點就好了!
                pred.next = node;
                return node;
            }
        }
     	//如果程式碼執行到這一步,說明有兩種情況
     	//1. 現在佇列為空
     	//2. 將當前執行緒代表的節點插入佇列的時候,有其他執行緒也要插入該佇列並且成功成為隊尾元素.
        enq(node);
        return node;
    }


/*enq函式------------------分界線------------------------------------*/

 private Node enq(final Node node) {//傳入的是當前執行緒所代表的節點
        for (;;) {//自旋
            Node t = tail; //找到隊尾元素
            if (t == null) { // Must initialize
                //到這裡代表佇列為空,那麼通過CAS操作加入頭結點,此時還是可能有多個執行緒會跑到這裡競爭
                if (compareAndSetHead(new Node()))
                    /*
                    到這裡說明當前執行緒設定head成功(競爭成功),注意,這個head是直接新建的,此時waitStatus == 0(到後面會說)	
                    雖然設定了head,tail還是null,所設定一下tail,讓它不為null,方便下次for迴圈執行else語句從而進行將當前執行緒代表的節點設定在head後面,自己跟著思路走一下。
                    */
                    tail = head;
            } else {
                /*
                到達這裡也要分下情況
                1. 	是addWaiter想把當前執行緒加入隊尾失敗的時候
                2.  是上個if語句設定head節點成功之後,下一次for迴圈了
                不過上面的兩種情況,都是想要把當前執行緒設定為隊尾節點,也是通過CAS操作。因為此時也是有多個執行緒競爭的,如果成功就設定成功,如果失敗就自旋操作,不斷地嘗試設定為隊尾節點。
                */
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }

複製程式碼

通過上面的簡要分析,我們知道addWaiter最終的結果就是返回一個插入隊尾或者head後面的節點。接下來acquireQueued就是插入佇列的執行緒進行等待,如果輪到這個執行緒去拿資源了就去拿。相當於在飯堂排隊打菜一樣。一個視窗只能為一位學生打菜,沒輪到打菜的同學可以休息做其他事情。

acquireQueued原始碼

解釋一下:如果acquireQueued函式返回true,則表示會進入中斷處理,不會進行掛起,也就是說打菜的同學不會休息,所以一般是返回false的

 final boolean acquireQueued(final Node node, int arg) {//傳入的是當前已經加入隊尾的節點和想要獲取的State
        boolean failed = true;
        try {
            boolean interrupted = false;//預設設定沒有中斷
            for (;;) {//這裡也是自旋
                //獲取傳入這個節點的前驅節點
                final Node p = node.predecessor();
                
                /*
                tryAcquire
                如果p是head,也就是說當前這個執行緒的節點是阻塞佇列的第一個節點,那麼就去嘗試獲取state,畢竟這個是佇列嘛,先來先到。有可能成功,也有可能失敗。
                因為head節點表示的是當前正在擁有資源的執行緒,不知道能否成功是因為不知道head節點有沒有釋放資源,其實在ReentractLock的tryAcquire就是判斷state是否為0,如果為0,則表示沒有執行緒擁有該資源,也就是說head節點釋放了該資源,那麼即可獲取。
                還有一個原因就是在enq的時候,如果佇列沒有節點,也就是初始化head節點的時候,沒有設定任何執行緒,也就是說head沒有佔用資源,那麼當前執行緒作為阻塞佇列的對頭,可以去嘗試去獲取state,萬一得了呢?!
                */
                if (p == head && tryAcquire(arg)) {
                   //到這裡是當前執行緒獲取state成功,將當前節點設定為head節點,
                    setHead(node);
                    p.next = null; // help GC,讓之前的head方便被JVM回收
                    failed = false;//表示獲取state成功
                    return interrupted;//表示期間有沒有被中斷過
                }
                //到這裡是說明 要麼代表當前執行緒的節點不是阻塞佇列的頭結點  要麼嘗試獲取state資源失敗
                //不管是哪種情況,說明當前加入節點的執行緒想要知道自己此時的狀態是什麼,若是休息,但是誰告訴我下一次到我了?若不是休息,那麼就找到可以休息的地方或者說到我打菜了。所以這裡就用了waitStatus的變數表示
                //
                /*
                如果有A Node物件,直接排在A後面,佇列是這樣的 A<=>B<=>C
                1. 如果A的waitStatus =  -1 ,表示說A 如果佔用了state資源,那麼排隊在A後面的第一個Node節點(B節點)可以先休息(B執行緒掛起)了,如果A釋放了資源那麼就會喚醒B,也就是A對B說,你先去休息吧,我好了就叫你
                2 .如果 A的waitStatu = 1,表示說A這個執行緒已經不想排隊獲取這個資源了,這裡設定這個值主要是方便當前節點找到可以讓它可以他安心休息的地方。
                3. waitStatu = 0 表示A是初始化狀態
                */
                //這裡是 主要是為了找到node可以休息的地方。如果找到就休息,如果找不到,那麼說明node前面就是head了,下一次迴圈檢查能不能獲取資源就好了!
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }


//---------------setHead原始碼-------------------
private void setHead(Node node) {
    	//將head指標指向傳入的節點
        head = node;
    	//這裡設定head節點的執行緒為null,同步實現器在實現tryAcquire成功的時候會把當前執行緒儲存下來的
        node.thread = null;
    	//這裡是當前node的字首
        node.prev = null;
   }



//---------------
 private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {//傳入的是前驅節點和當前節點
     	//獲取前驅節點的狀態,方便當前節點的接下來的狀態
        int ws = pred.waitStatus;
     	
        if (ws == Node.SIGNAL)
            //進到這裡就說明waitStatus = -1,也就說明node應該可以休息了,也就是執行緒掛起
            /*
             * This node has already set status asking a release
             * to signal it, so it can safely park.
             */
            return true;
        if (ws > 0) {
            /*
             * Predecessor was cancelled. Skip over predecessors and
             * indicate retry.
             */
            //這裡就是說前驅節點已經放棄排隊了,當前節點往前找看看能不能找到沒有取消排隊的節點,看看能不能排在他們後面
            do {
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
            /*
             * waitStatus must be 0 or PROPAGATE.  Indicate that we
             * need a signal, but don't park yet.  Caller will need to
             * retry to make sure it cannot acquire before parking.
             */
            //到這裡很有可能是waitStatus為0的分支,上面我們都沒有設定waitStatus
            // 我們在enq的時候用了new Node()和在addWaiter剛開始的時候也是用了 new Node(mode,arg),這兩處都是新增tail的時候
            // 如果沒有設定waitStatus的時候,是預設為0的,也就是說是初始化狀態
            // 如果到達這裡前驅節點都是tail,我們就要將隊尾的狀態設定為-1,讓傳進來的node節點可以找到休息點。或者是已經釋放資源的head,那麼下次node可以變為head了!!
            // 設定可能會失敗,因為這裡也會有執行緒競爭,競爭不過,這裡也是通過自旋,直到能找到休息點為止。
            //也有可能是pre節點已經是head節點了,還沒有釋放state資源,此時pre(head)的waitStatus == -1
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
     	//這裡為什麼返回false呢,是因為如果這時候head已經是node的pre,那麼如果到這裡head已經釋放完資源之後,node下一次就可以直接獲取資源啦!如果head還沒釋放資源,那麼下一次node就直接去休息。
     	// 如果返回的是true的話,如果這時候head已經是node的pre,head已經釋放完資源,那麼到後面執行緒節點就掛起,那麼誰來喚醒node節點?
        return false;
    }


//--------------------------
 private final boolean parkAndCheckInterrupt() {
     //如果shouldParkAfterFailedAcquire返回的是true,若是false則不會執行到這裡!
        LockSupport.park(this);//執行緒從這裡掛起,如果被喚醒和中斷那麼繼續從這裡往下執行。
        return Thread.interrupted();
    }
複製程式碼

解鎖

從上面的解釋我們知道,如果掛起等待的執行緒需要獲取資源,是需要字首節點的waitStatus為SIGNAL的執行緒喚醒的,也就是head節點。

在獨佔模式中,具體的同步器實現類最終用到的是AQS的release方法,開始的時候說過了,具體的同步實現器只要實現tryRelease方法即可。

比如說ReentranctLock的unlock

一篇文章瞭解什麼是AQS和應用

release的原始碼

 public final boolean release(int arg) {
        if (tryRelease(arg)) {//這裡是先嚐試釋放一下資源,一般都可以釋放成功,除了多次重入但只釋放一次的情況。
            Node h = head;
            //這裡判斷的是 阻塞佇列是否還存在和head節點是否是tail節點,因為之前說過,佇列的尾節點的waitStatus是為0的
            if (h != null && h.waitStatus != 0)
                //到這裡就說明head節點已經釋放成功啦,就先去叫醒後面的直接節點去搶資源吧
                unparkSuccessor(h);
            return true;
        }
        return false;
    }
複製程式碼

tryRelease原始碼

protected final boolean tryRelease(int releases) {
    		//對state的操作就是釋放資源
            int c = getState() - releases;
    		//如果執行釋放操作的不是所擁有資源的執行緒,丟擲異常。
            if (Thread.currentThread() != getExclusiveOwnerThread())
                throw new IllegalMonitorStateException();
            boolean free = false;
    		//判斷是不是有巢狀鎖
            if (c == 0) {
                //如果到達這裡,說明臨界資源已經獲得自由啦,沒有執行緒佔用它啦!所以設定free = true
                free = true;
                //同時會把擁有資源的執行緒設定為null
                setExclusiveOwnerThread(null);
            }
            setState(c);
            return free;
        }
複製程式碼

unparkSuccessor原始碼

private void unparkSuccessor(Node node) {//傳入的是head節點
        /*
         * If status is negative (i.e., possibly needing signal) try
         * to clear in anticipation of signalling.  It is OK if this
         * fails or if status is changed by waiting thread.
         */
        int ws = node.waitStatus;
    	//這裡設定一下head的waitStatus,因為之前除了有節點加入佇列的時候會把head節點ws = -1,基本沒有其他地方設定,所以這裡基本都是為-1的,CAS設定為0主要是head後面的直接節點不會掛起等待。
        if (ws < 0)
            compareAndSetWaitStatus(node, ws, 0);

        /*
         * Thread to unpark is held in successor, which is normally
         * just the next node.  But if cancelled or apparently null,
         * traverse backwards from tail to find the actual
         * non-cancelled successor.
         */
    	//下面的程式碼是如果阻塞佇列有取消等待的節點,那麼就把他們移除阻塞隊伍,找到真正想要獲取資源在等待的head後面的直接節點。
        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)
            //到這裡就說明找到了那個節點,也就是head後面的第一個沒有取消等待的節點,這個節點可能已經掛起或者還在掛起的過程中,反正都會執行喚醒執行緒的函式。這樣如果是掛起的執行緒,就繼續執行下一次自旋,下一次自旋肯定拿到鎖,進行操作。因為已經滿足了是1. 喚醒的節點是阻塞佇列的第一個節點,2. head節點已經釋放資源了!
            LockSupport.unpark(s.thread);
    }

複製程式碼

總結

  1. 本篇文章簡述了AQS的大概作用和原理。
  2. 以ReentrantLock(獨佔模式)的公平鎖為例子,分析了AQS的關於阻塞佇列是怎麼的操作。
  3. 寫這篇文章主要是為了加強自己的關於多執行緒的基礎。為了學習,看了許多篇大佬的部落格文章,記錄下來,加入了自己的理解,希望錯誤少一點。得以這些大佬為榜樣,站在巨人的肩膀去學習,希望能夠學得更快,更高效,希望以後也可以寫出自己的高品質文章!

參考

  1. www.javadoop.com/post/Abstra…

  2. www.cnblogs.com/waterystone…

相關文章