AQS原理淺析

AstrophelYang發表於2019-07-26

        鎖是最常用的同步方法之一,在高併發的環境下激烈的鎖競爭會導致程式的效能下降,所以我們自然有必要深入的學習一下鎖的相關知識。

         java的內建鎖一直都是備受爭議的,在JDK 1.6之前,synchronized這個重量級鎖其效能一直都是較為低下,雖然在1.6後,進行大量的鎖優化策略,如自適應自旋,鎖消除,鎖粗化,輕量級鎖,偏向鎖等等,但是與Lock相比synchronized還是存在一些缺陷的:雖然synchronized提供了便捷性的隱式獲取鎖釋放鎖機制(基於JVM機制),但是它卻缺少了獲取鎖與釋放鎖的可操作性,可中斷、超時獲取鎖,且它為獨佔式在高併發場景下效能大打折扣。

         在《深入理解Java虛擬機器》這本書上,作者說了這句話:與其說ReentrantLock效能好,還不如說synchronized還有很大優化的餘地。在JDK1.6之後,人們發現synchronized與ReentrantLock的效能基本上是完全持平的(但是在JDK是1.8做基礎測試時synchronized的效能還是不如ReentrantLock,原因暫未發現)。虛擬機器在未來的效能改進中肯定會更加偏向於原生的synchronized,所以還是提倡synchronized能實現需求的情況下,優先考慮使用synchronized來進行同步。但是在jdk1.8測試中lock的使用比當確實使用synchroinzed同步時我們的效能瓶頸時,我們可以用ReentrantLock來進行效能的測試,如果確實更優,我們就可以選擇用ReetrantLock來進行同步。

        在介紹Lock之前,我們需要先熟悉一個非常重要的基礎元件,JUC包下的核心基礎元件。也是實現大部分同步需求的基礎。學習該元件是學習JUC繞不開的一塊內容。該元件就是AQS

AQS簡介

  • AQS:AbstractQueuedSynchronizer,即佇列同步器。它是構建鎖或者其他同步元件的基礎框架(如ReentrantLock、ReentrantReadWriteLock、Semaphore等)。
  • AQS解決了子類實現同步器時涉及當的大量細節問題,例如獲取同步狀態、FIFO同步佇列。基於AQS來構建同步器可以帶來很多好處。自定義同步器在實現時只需要實現共享資源state的獲取與釋放方式即可,至於具體執行緒等待佇列的維護(如獲取資源失敗入隊/喚醒出隊等),AQS已經在頂層實現好了,所以使用AQS不僅能夠極大地減少實現工作,而且也不必處理在多個位置上發生的競爭問題。
  • 在基於AQS構建的同步器中,只能在一個時刻發生阻塞,從而降低上下文切換的開銷,提高了吞吐量。同時在設計AQS時充分考慮了可伸縮行,因此J.U.C中所有基於AQS構建的同步器均可以獲得這個優勢。
  • AQS的主要使用方式是繼承,子類通過繼承同步器並實現它的抽象方法來管理同步狀態。
  • AQS使用一個int型別的成員變數state來表示同步狀態,當state>0時表示已經獲取了鎖,當state = 0時表示釋放了鎖。它提供了三個方法(getState()、setState(int newState)、compareAndSetState(int expect,int update))來對同步狀態state進行操作,當然AQS可以確保對state的操作是安全的。
  • AQS通過內建的FIFO同步佇列來完成資源獲取執行緒的排隊工作,如果當前執行緒獲取同步狀態失敗(鎖)時,AQS則會將當前執行緒以及等待狀態等資訊構造成一個節點(Node)並將其加入同步佇列,同時會阻塞當前執行緒,當同步狀態釋放時,則會把節點中的執行緒喚醒,使其再次嘗試獲取同步狀態。

AQS常用方法

    關於state的方法主要有一下三種

  • getState():返回同步狀態的當前值;

  • setState(int newState):設定當前同步狀態;

  • compareAndSetState(int expect, int update):使用CAS設定當前狀態,該方法能夠保證狀態設定的原子性

   自定義同步器實現時主要實現以下幾種方法

  • tryAcquire(int arg):獨佔式獲取同步狀態,獲取同步狀態成功後,其他執行緒需要等待該執行緒釋放同步狀態才能獲取同步狀態

  • tryRelease(int arg):獨佔式釋放同步狀態;

  • tryAcquireShared(int arg):共享式獲取同步狀態,返回值大於等於0則表示獲取成功,否則獲取失敗;

  • tryReleaseShared(int arg):共享式釋放同步狀態;

  • isHeldExclusively():當前同步器是否在獨佔式模式下被執行緒佔用,一般該方法表示是否被當前執行緒所獨佔;

其餘方法

  • acquire(int arg):獨佔式獲取同步狀態,如果當前執行緒獲取同步狀態成功,則由該方法返回,否則,將會進入同步佇列等待,該方法將會呼叫可重寫的tryAcquire(int arg)方法;

  • acquireInterruptibly(int arg):與acquire(int arg)相同,但是該方法響應中斷,當前執行緒為獲取到同步狀態而進入到同步佇列中,如果當前執行緒被中斷,則該方法會丟擲InterruptedException異常並返回;

  • tryAcquireNanos(int arg,long nanos):超時獲取同步狀態,如果當前執行緒在nanos時間內沒有獲取到同步狀態,那麼將會返回false,已經獲取則返回true;

  • acquireShared(int arg):共享式獲取同步狀態,如果當前執行緒未獲取到同步狀態,將會進入同步佇列等待,與獨佔式的主要區別是在同一時刻可以有多個執行緒獲取到同步狀態;

  • acquireSharedInterruptibly(int arg):共享式獲取同步狀態,響應中斷;

  • tryAcquireSharedNanos(int arg, long nanosTimeout):共享式獲取同步狀態,增加超時限制;

  • release(int arg):獨佔式釋放同步狀態,該方法會在釋放同步狀態之後,將同步佇列中第一個節點包含的執行緒喚醒;

  • releaseShared(int arg):共享式釋放同步狀態;

CLH

       CLH同步佇列是一個FIFO雙向佇列,AQS依賴它來完成同步狀態的管理,當前執行緒如果獲取同步狀態失敗時,AQS則會將當前執行緒已經等待狀態等資訊構造成一個節點(Node)並將其加入到CLH同步佇列,同時會阻塞當前執行緒,當同步狀態釋放時,會把首節點喚醒(公平鎖),使其再次嘗試獲取同步狀態。

在CLH同步佇列中,一個節點表示一個執行緒,它儲存著執行緒的引用(thread)、狀態(waitStatus)、前驅節點(prev)、後繼節點(next),其資料結構如下

                                                       

 

   其實就是個雙端雙向連結串列

   資料定義如下

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;
    /**
     * 節點在等待佇列中,節點執行緒等待在Condition上,當其他執行緒對Condition呼叫了signal()後,改節點將會從等待佇列中轉移到同步佇列中,加入到同步狀態的獲取中
     */
    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() {
    }
    Node(Thread thread, Node mode) {
        this.nextWaiter = mode;
        this.thread = thread;
    }
    Node(Thread thread, int waitStatus) {
        this.waitStatus = waitStatus;
        this.thread = thread;
    }
}

    可以看到AQS支援兩種同步模式,分別是Exclusive(獨佔,只有一個執行緒能執行,如ReentrantLock)和Share(共享,多個執行緒可同時執行,如Semaphore/CountDownLatch)。這樣方便使用者實現不同型別的同步元件。簡而言之,AQS為使用者提供了多樣的底層支撐,具體如何組裝實現,使用者可以自由發揮。

   入列

   CHL這種連結串列式結構入列,無非就是tail指向新節點、新節點的前驅節點指向當前最後的節點,當前最後一個節點的next指向當前節點,直接看原始碼相關操作在addWaiter(Node node)方法裡。此方法用於將當前執行緒加入到等待佇列的隊尾,並返回當前執行緒所在的結點  

    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;
            }
        }
        //多次嘗試
        enq(node);
        return node;
    }

    addWaiter(Node node)先通過快速嘗試設定尾節點,如果失敗,則呼叫enq(Node node)方法設定尾節點

  private Node enq(final Node node) {
        //多次嘗試,直到成功為止
        for (;;) {
            Node t = tail;
            //tail不存在,設定為首節點
            if (t == null) {
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
                //設定為尾節點
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }

        此方法用於將node加入隊尾,該方法核心就是通過CAS自旋的方式來設定尾節點,知道獲得預期的結果即新增節點成功,當前執行緒才會返回。(這種方式很經典AtomicInteger.getAndIncrement()方法也是這樣做的)

     出列

     CLH同步佇列遵循FIFO(先進先出),首節點的執行緒釋放同步狀態後,將會喚醒它的後繼節點(next),而後繼節點將會在獲取同步狀態成功時將自己設定為首節點,這個過程非常簡單,head執行該節點並斷開原首節點的next和當前節點的prev即可,注意在這個過程是不需要使用CAS來保證的,因為只有一個執行緒能夠成功獲取到同步狀態。

同步狀態的獲取與釋放

       AQS的設計模式採用的模板方法模式,子類通過繼承的方式,實現它的抽象方法來管理同步狀態,對於子類而言它並沒有太多的活要做,AQS提供了大量的模板方法來實現同步,主要是分為三類:獨佔式獲取和釋放同步狀態、共享式獲取和釋放同步狀態、查詢同步佇列中的等待執行緒情況。自定義子類使用AQS提供的模板方法就可以實現自己的同步語義。

    獨佔式同步狀態獲取

    此方法是獨佔模式下執行緒獲取共享資源的頂層入口。如果獲取到資源,執行緒直接返回,否則進入等待佇列,直到獲取到資源為止,且整個過程忽略中斷的影響。這也正是lock()的語義,當然不僅僅只限於lock()。也就是說由於執行緒獲取同步狀態失敗加入到CLH同步佇列中,後續對執行緒進行中斷操作時,執行緒不會從同步佇列中移除獲取到資源後。下面是acquire()的原始碼:

public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }
  • tryAcquire:去嘗試獲取鎖,獲取成功則設定鎖狀態並返回true,否則返回false。該方法由自定義同步元件自己實現(通過state的get/set/CAS),該方法必須要保證執行緒安全的獲取同步狀態。

  • addWaiter:如果tryAcquire返回FALSE(獲取同步狀態失敗),則呼叫該方法將當前執行緒加入到CLH同步佇列尾部,並標記為獨佔模式。

  • acquireQueued:當前執行緒會根據公平性原則來進行阻塞等待(自旋),直到獲取鎖為止;如果在整個等待過程中被中斷過,則返回true,否則返回false。

  • selfInterrupt:如果執行緒在等待過程中被中斷過,它是不響應的。只是獲取資源後才再進行自我中斷selfInterrupt(),將中斷補上。

 tryAcquire(int)

protected boolean tryAcquire(int arg) {
        throw new UnsupportedOperationException();
    }

      該方法直接丟擲異常,具體實現交自定義同步器類實現。這裡之所以沒有定義成abstract,是因為獨佔模式下只用實現tryAcquire-tryRelease,而共享模式下只用實現tryAcquireShared-tryReleaseShared。如果都定義成abstract,那麼每個模式也要去實現另一模式下的介面。

 acquireQueued

   在執行到此方法時已經說明一點:該執行緒獲取資源失敗,已經被放入等待佇列尾部了。所以 acquireQueued方法就是讓執行緒進入等待狀態休息,直到其他執行緒徹底釋放資源後喚醒該執行緒,獲取所需資源,然後執行該執行緒所需執行的任務。

   acquireQueued方法為一個自旋的過程,也就是說當前執行緒(Node)進入同步佇列後,就會進入一個自旋的過程,每個節點都會自我觀察,當條件滿足,獲取到同步狀態後,就可以從這個自旋過程中退出,否則會一直執行下去。

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)) {
                    /*將head指向該節點*/
                    setHead(node);
                   /* 方便GC回收垃圾 */
                    p.next = null; 
                    failed = false;
                   /*返回等待過程中是否被中斷過*/
                    return interrupted;
                }
                /*獲取失敗,執行緒就進入waiting狀態,直到被unpark()*/
                if (shouldParkAfterFailedAcquire(p, node) &&parkAndCheckInterrupt())
                    /*如果等待過程中被中斷過一次,就標記為true*/
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

       從上面程式碼中可以看到,當前執行緒會一直嘗試獲取同步狀態,當然前提是隻有其前驅節點為頭結點才能夠嘗試獲取同步狀態,理由:

  • 保持FIFO同步佇列原則。

  • 頭節點釋放同步狀態後,將會喚醒其後繼節點,後繼節點被喚醒後需要檢查自己是否為頭節點。

shouldParkAfterFailedAcquire(Node, Node)

    此方法主要用於檢查狀態,檢視當前節點是否進入waiting狀態

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus;//拿到前驅節點的狀態
    if (ws == Node.SIGNAL)
        //狀態為SIGNAL,如果前驅節點處於等待狀態,直接返回true
        return true;
    if (ws > 0) {
        /*
         * 如果前驅節點放棄了,那就一直往前找,直到找到最近一個正常等待的狀態,並排在它的後邊。
         * 注意:那些放棄的結點,由於被自己“加塞”到它們前邊,它們相當於形成一個無引用鏈,稍後就會被GC回收
         */
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
         //如果前驅節點正常,那就把前驅的狀態通過CAS的方式設定成SIGNAL
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

 這段程式碼主要檢查當前執行緒是否需要被阻塞,具體規則如下:

  1. 如果當前執行緒的前驅節點狀態為SINNAL,則表明當前執行緒需要被阻塞,呼叫unpark()方法喚醒,直接返回true,當前執行緒阻塞

  2. 如果當前執行緒的前驅節點狀態為CANCELLED(ws > 0),則表明該執行緒的前驅節點已經等待超時或者被中斷了,則需要從CLH佇列中將該前驅節點刪除掉,直到回溯到前驅節點狀態 <= 0 ,返回false

  3. 如果前驅節點非SINNAL,非CANCELLED,則通過CAS的方式將其前驅節點設定為SINNAL,返回false

      整個流程中,如果前驅結點的狀態不是SIGNAL,那麼自己就不能被阻塞,需要去找個安心的休息點(前驅節點狀態 <= 0 ),同時可以再嘗試下看有沒有機會去獲取資源。

     如果 shouldParkAfterFailedAcquire(Node pred, Node node) 方法返回true,則呼叫parkAndCheckInterrupt()方法阻塞當前執行緒: 

private final boolean parkAndCheckInterrupt() {
        //呼叫park()使執行緒進入waiting狀態
          LockSupport.park(this); 
          //如果被喚醒,檢視自己是不是被中斷的
          return Thread.interrupted();
 }

    parkAndCheckInterrupt() 方法主要是把當前執行緒掛起,從而阻塞住執行緒的呼叫棧,同時返回當前執行緒的中斷狀態。

實現細節有點複雜 未完待續

相關文章