AQS 自定義同步鎖,挺難的!

架構文摘發表於2020-10-25

AQSAbstractQueuedSynchronizer的簡稱。

AbstractQueuedSynchronizer 同步狀態

AbstractQueuedSynchronizer 內部有一個state屬性,用於指示同步的狀態:

private volatile int state;

state的欄位是個int型的,它的值在AbstractQueuedSynchronizer中是沒有具體的定義的,只有子類繼承AbstractQueuedSynchronizer那麼state才有意義,如在ReentrantLock中,state=0表示資源未被鎖住,而state>=1的時候,表示此資源已經被另外一個執行緒鎖住。

AbstractQueuedSynchronizer中雖然沒有具體獲取、修改state的值,但是它為子類提供一些操作state的模板方法:

獲取狀態

    protected final int getState() {
        return state;
    }

更新狀態

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

CAS更新狀態

    protected final boolean compareAndSetState(int expect, int update) {
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
    }

AQS 等待佇列

AQS 等待列隊是一個雙向佇列,佇列中的成員都有一個prevnext成員,分別指向它前面的節點和後面的節點。

佇列節點

AbstractQueuedSynchronizer內部,等待佇列節點由內部靜態類Node表示:

static final class Node {
	...
}
節點模式

佇列中的節點有兩種模式:

  • 獨佔節點:同一時刻只能有一個執行緒訪問資源,如ReentrantLock
  • 共享節點:同一時刻允許多個執行緒訪問資源,如Semaphore
節點的狀態

等待佇列中的節點有五種狀態:

  • CANCELLED:此節點對應的執行緒,已經被取消
  • SIGNAL:此節點的下一個節點需要一個喚醒訊號
  • CONDITION:當前節點正在條件等待
  • PROPAGATE:共享模式下會傳播喚醒訊號,就是說當一個執行緒使用共享模式訪問資源時,如果成功訪問到資源,就會繼續喚醒等待佇列中的執行緒。

自定義同步鎖

為了便於理解,使用AQS自己實現一個簡單的同步鎖,感受一下使用AQS實現同步鎖是多麼的輕鬆。

下面的程式碼自定了一個CustomLock類,繼承了AbstractQueuedSynchronizer,並且還實現了Lock介面。
CustomLock類是一個簡單的可重入鎖,類中只需要重寫AbstractQueuedSynchronizer中的tryAcquiretryRelease方法,然後在修改少量的呼叫就可以實現一個最基本的同步鎖。

public class CustomLock extends AbstractQueuedSynchronizer implements Lock {

    @Override
    protected boolean tryAcquire(int arg) {
    
        int state = getState();
        if(state == 0){
            if( compareAndSetState(state, arg)){
                setExclusiveOwnerThread(Thread.currentThread());
                System.out.println("Thread: " + Thread.currentThread().getName() + "拿到了鎖");
                return true;
            }
        }else if(getExclusiveOwnerThread() == Thread.currentThread()){
            int nextState = state + arg;
            setState(nextState);
            System.out.println("Thread: " + Thread.currentThread().getName() + "重入");
            return true;
        }

        return false;
    }

    @Override
    protected boolean tryRelease(int arg) {

        int state = getState() - arg;

        if(getExclusiveOwnerThread() != Thread.currentThread()){
            throw new IllegalMonitorStateException();
        }

        boolean free = false;
        if(state == 0){
            free = true;
            setExclusiveOwnerThread(null);
            System.out.println("Thread: " + Thread.currentThread().getName() + "釋放了鎖");
        }

        setState(state);

        return free;
    }


    @Override
    public void lock() {
        acquire(1);
    }
 

  
    @Override
    public void unlock() {
        release(1);
    }
    ...
}

CustomLock是實現了Lock介面,所以要重寫lockunlock方法,不過方法的程式碼很少只需要呼叫AQS中的acquirerelease

然後為了演示AQS的功能寫了一個小演示程式,啟動兩根執行緒,分別命名為執行緒A執行緒B,然後同時啟動,呼叫runInLock方法,模擬兩條執行緒同時訪問資源的場景:

public class CustomLockSample {

    public static void main(String[] args) throws InterruptedException {

        Lock lock = new CustomLock();
        new Thread(()->runInLock(lock), "執行緒A").start();
        new Thread(()->runInLock(lock), "執行緒B").start();
    }

    private static void runInLock(Lock lock){

        try {
            lock.lock();
            System.out.println("Hello: " + Thread.currentThread().getName());
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }finally {
            lock.unlock();
        }

    }
}

訪問資源(acquire)

在CustomLock的lock方法中,呼叫了 acquire(1)acquire的程式碼如下 :

  public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }
  • CustomLock.tryAcquire(...)CustomLock.tryAcquire 判斷當前執行緒是否能夠訪問同步資源
  • addWaiter(...):將當前執行緒新增到等待佇列的隊尾,當前節點為獨佔模型(Node.EXCLUSIVE)
  • acquireQueued(...):如果當前執行緒能夠訪問資源,那麼就會放行,如果不能那當前執行緒就需要阻塞。
  • selfInterrupt:設定執行緒的中斷標記

注意: 在acquire方法中,如果tryAcquire(arg)返回true, 就直接執行完了,執行緒被放行了。所以的後面的方法呼叫acquireQueued、addWaiter都是tryAcquire(arg)返回false時才會被呼叫。

tryAcquire 的作用

tryAcquire在AQS類中是一個直接丟擲異常的實現:

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

而在我們自定義的 CustomLock 中,重寫了此方法:

  @Override
    protected boolean tryAcquire(int arg) {

        int state = getState();
        if(state == 0){
            if( compareAndSetState(state, arg)){
                setExclusiveOwnerThread(Thread.currentThread());
                System.out.println("Thread: " + Thread.currentThread().getName() + "拿到了鎖");
                return true;
            }
        }else if(getExclusiveOwnerThread() == Thread.currentThread()){
            int nextState = state + arg;
            setState(nextState);
            System.out.println("Thread: " + Thread.currentThread().getName() + "重入");
            return true;
        }

        return false;
    }

tryAcquire方法返回一個布而值,true表示當前執行緒能夠訪問資源,false當前執行緒不能訪問資源,所以tryAcquire的作用:決定執行緒是否能夠訪問受保護的資源tryAcquire裡面的邏輯在子類可以自由發揮,AQS不關心這些,只需要知道能不能訪問受保護的資源,然後來決定執行緒是放行還是進行等待佇列(阻塞)。

因為是在多執行緒環境下執行,所以不同的執行緒執行tryAcquire時會返回不同的值,假設執行緒A比執行緒B要快一步,先到達compareAndSetState設定state的值成員併成功,那執行緒A就會返回true,而 B 由於state的值不為0或者compareAndSetState執行失敗,而返回false。

執行緒B 搶佔鎖流程

上面訪問到執行緒A成功獲得了鎖,那執行緒B就會搶佔失敗,接著執行後面的方法。

執行緒的入隊

執行緒的入隊是邏輯是在addWaiter方法中,addWaiter方法的具體邏輯也不需要說太多,如果你知道連結串列的話,就非常容易理解了,最終的結果就是將新執行緒新增到隊尾。AQS的中有兩個屬性headtail分別指定等待佇列的隊首和隊尾。

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

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

需要注意的是在enq方法中,初始化佇列的時候,會新建一個Node做為headtail,然後在之後的迴圈中將引數node新增到隊尾,佇列初始化完後,裡面會有兩個節點,一個是空的結點new Node()另外一個就是對應當前執行緒的結點。

由於執行緒A在tryAcquire時返回了true,所以它會被直接放行,那麼只有B執行緒會進入addWaiter方法,此時的等待佇列如下:

注意: 等待佇列內的節點都是正在等待資源的執行緒,如果一個執行緒直接能夠訪問資源,那它壓根就不需要進入等待佇列,會被放行。

執行緒B 的阻塞

執行緒B被新增到等待佇列的尾部後,會繼續執行acquireQueued方法,這個方法就是AQS阻塞執行緒的地方,acquireQueued方法程式碼的一些解釋:

  • 外面是一個for (;;)無限迴圈,這個很重要
  • 會重新呼叫一次tryAcquire(arg)判斷執行緒是否能夠訪問資源了
  • node.predecessor()獲取引數node的前一個節點
  • shouldParkAfterFailedAcquire判斷當前執行緒獲取鎖失敗後,需不需要阻塞
  • parkAndCheckInterrupt()使用LockSupport阻塞當前執行緒,
 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);
        }
    }
shouldParkAfterFailedAcquire 判斷是否要阻塞

shouldParkAfterFailedAcquire接收兩個引數:前一個節點、當前節點,它會判斷前一個節點的waitStatus屬性,如果前一個節點的waitStatus=Node.SIGNAL就會返回true:

 private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        int ws = pred.waitStatus;
        if (ws == Node.SIGNAL)
            return true;
        if (ws > 0) {
           do {
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }

acquireQueued方法在迴圈中會多次呼叫shouldParkAfterFailedAcquire,在等待佇列中節點的waitStatus的屬性預設為0,所以第一次執行shouldParkAfterFailedAcquire會執行:

compareAndSetWaitStatus(pred, ws, Node.SIGNAL);

更新完pred.waitStatus後,節點的狀態如下:

然後shouldParkAfterFailedAcquire返回false,回到acquireQueued的迴圈體中,又去搶鎖還是失敗了,又會執行shouldParkAfterFailedAcquire,第二次迴圈時此時的pred.waitStatus等於Node.SIGNAL那麼就會返回true。

parkAndCheckInterrupt 阻塞執行緒

這個方法就比較直觀了, 就是將執行緒的阻塞住:

  private final boolean parkAndCheckInterrupt() {
        LockSupport.park(this);
        return Thread.interrupted();
    }
為什麼是一個for (;;)無限迴圈呢

先看一個for (;;)的退出條件,只有node的前一個節點是head並且tryAcquire返回true時才會退出迴圈,否則的話執行緒就會被parkAndCheckInterrupt阻塞。

執行緒被parkAndCheckInterrupt阻塞後就不會向下面執行了,但是等到它被喚醒後,它還在for (;;)體中,然後又會繼續先去搶佔鎖,然後如果還是失敗,那又會處於等待狀態,所以一直迴圈下去,就只有兩個結果:

  1. 搶到鎖退出迴圈
  2. 搶佔鎖失敗,等待下一次喚醒再次搶佔鎖

執行緒 A 釋放鎖

執行緒A的業務程式碼執行完成後,會呼叫CustomLock.unlock方法,釋放鎖。unlock方法內部呼叫的release(1)

     public void unlock() {
        release(1);
    }

release是AQS類的方法,它跟acquire相反是釋放的意思:

    public final boolean release(int arg) {
        if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }
        return false;
    }

方法體中的tryRelease是不是有點眼熟,沒錯,它也是在實現CustomLock類時重寫的方法,首先在tryRelease中會判斷當前執行緒是不是已經獲得了鎖,如果沒有就直接丟擲異常,否則的話計算state的值,如果state為0的話就可以釋放鎖了。

 protected boolean tryRelease(int arg) {

        int state = getState() - arg;

        if(getExclusiveOwnerThread() != Thread.currentThread()){
            throw new IllegalMonitorStateException();
        }

        boolean free = false;
        if(state == 0){
            free = true;
            setExclusiveOwnerThread(null);
            System.out.println("Thread: " + Thread.currentThread().getName() + "釋放了鎖");
        }

        setState(state);

        return free;
    }

release方法只做了兩件事:

  1. 呼叫tryRelease判斷當前執行緒釋放鎖是否成功
  2. 如果當前執行緒鎖釋放鎖成功,喚醒其他執行緒(也就是正在等待中的B執行緒)

tryRelease返回true後,會執行if裡面的程式碼塊:

if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }

先回顧一下現在的等待佇列的樣子:

根據上面的圖,來走下流程:

  • 首先拿到head屬性的物件,也就是佇列的第一個物件
  • 判斷head不等於空,並且waitStatus!=0,很明顯現在的waitStatus是等於Node. SIGNAL的,它的值是-1

所以if (h != null && h.waitStatus != 0)這個if肯定是滿足條件的,接著執行unparkSuccessor(h)

   private void unparkSuccessor(Node node) {
        int ws = node.waitStatus;
        if (ws < 0)
            compareAndSetWaitStatus(node, ws, 0);
       
        Node s = node.next;
        
        ...
        
        if (s != null)
            LockSupport.unpark(s.thread);
    }

unparkSuccessor首先將node.waitStatus設定為0,然後獲取node的下一個節點,最後呼叫LockSupport.unpark(s.thread)喚醒執行緒,至此我們的B執行緒就被喚醒了。

此時的佇列又回到了,執行緒B剛剛入隊的樣子:

執行緒B 喚醒之後

執行緒A釋放鎖後,會喚醒執行緒B,回到執行緒B的阻塞點,acquireQueued的for迴圈中:

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

執行緒喚醒後的第一件事就是,拿到它的上一個節點(當前是head結點),然後使用if判斷

if (p == head && tryAcquire(arg))

根據現在等待佇列中的節點狀態,p == head是返回true的,然後就是tryAcquire(arg)了,由於執行緒A已經釋放了鎖,那現在的執行緒B自然就能獲取到鎖了,所以tryAcquire(arg)也會返回true。

設定佇列頭

線路B拿到鎖後,會呼叫setHead(node)自己設定為佇列的頭:

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

呼叫setHead(node)後佇列會發生些變化 :

移除上一個節點

setHead(node)執行完後,接著按上一個節點完全移除:

p.next = null; 

此時的佇列:

執行緒B 釋放鎖

執行緒B 釋放鎖的流程與執行緒A基本一致,只是當前佇列中已經沒有需要喚醒的執行緒,所以不需要執行程式碼去喚醒其他執行緒:

if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }

h != null && h.waitStatus != 0這裡的h.waitStatus已經是0了,不滿足條件,不會去喚醒其他執行緒。

總結

文中通過自定義一個CustomLock類,然後通過檢視AQS原始碼來學習AQS的部分原理。通過完整的走完鎖的獲取、釋放兩個流程,加深對AQS的理解,希望對大家有所幫助。

歡迎關注我的公眾號:架構文摘,獲得獨家整理120G的免費學習資源助力你的架構師學習之路!

公眾號後臺回覆arch028獲取資料:

相關文章