抽象佇列同步器(獨佔鎖)

eacape發表於2022-05-23

基礎介紹

JUC中的許多併發類都繼承了AbstractQueuedSynchronizer(AQS),如CountDownLatch、ReentrantLock、ThreadLocalExcutor等。

它主要實現了對同步狀態的管理以及對阻塞執行緒進行排隊、等待通知,就拿ReetrantLock為例,它有以下的功能

  • 獲取鎖
  • 爭搶這把鎖卻沒有成功的這些執行緒要被存放到一個集合中
  • 釋放鎖,集合中的執行緒會被喚醒重現來爭搶鎖
  • 使用鎖來建立Condition物件
  • .....

上述這寫功能都是依賴於AQS實現的,因為ReetrantLock是隻能被一個執行緒獲取,所以它是一把獨佔鎖,而像ReentrantReadWriteLock中的ReadLock是可以被多個執行緒共享的,也就是說它是一把共享鎖。AQS中既提供了獨佔鎖的一些底層的實現,也提供了共享鎖的實現。

所以AQS中的內容主要可以分為四部分

  1. CLH佇列:儲存等待執行緒,其主要是通過雙向連結串列的方式實現,CLH是它的發明者的三個大佬的名字的首字母。
  2. 獨佔鎖
  3. 共享鎖
  4. Condition實現

AQS程式碼概覽

AbstractQueuedSynchronizer這個類當中包含兩個內部類,其中ConditionObject就是Condition功能的主要實現,一般建立Condition的方式就是Lock.newCondition(),而我們通過檢視ReentrantLock原始碼可以發現,其實際建立的Condition就是一個ConditionObject例項。

Node是等待執行緒的載體,也就是等待執行緒所在的雙向連結串列上的節點。

AbstractQueuedSynchronizer中有大量的方法,其中類似於tryAcquire和tryAcquireShared就是"類似方法"在獨佔鎖和共享鎖中的不同實現。

下圖中是AbstractQueuedSynchronizer中的一些成員變數,其中head和tail都是一個Node變數分別用於表示隊頭和隊尾節點,state表示同步狀態,stateOffset表示state變數相對於java物件的偏移量,也就是相對於AbstractQueuedSynchronizer.class的偏移量(class也是一個物件,java中萬物皆物件),主要是用於後面使用CAS的方式給相應變數設定值、修改值等操作,headOffset、tailOffset同理,waitStatusOffset和nextOffset是相對於Node.class的偏移量。另外在AbstractQueuedSynchronizer 的父類AbstractOwnableSynchronizer中還有一個重要的變數exclusiveOwnerThread表示獨佔模式下擁有當前鎖的執行緒。

Node類解析

static final class Node {
    //用於標記共享模式
    static final Node SHARED = new Node();
    //用於標記獨佔模式
    static final Node EXCLUSIVE = null;

    // waitStatus 為這個值的時候 表示執行緒已經被取消
    static final int CANCELLED =  1;
    // waitStatus 為這個值的時候 表示後繼執行緒需要取消阻塞
    static final int SIGNAL    = -1;
    // waitStatus 為這個值的時候 表示執行緒處於Condition下的等待狀態
    static final int CONDITION = -2;
    //waitStatus 為這個值的時候 表示下個acquireShared操作將被允許
    static final int PROPAGATE = -3;

    /**
     *   這個欄位可能有以下狀態
     *   SIGNAL:     該節點的後繼節點被(或即將)阻塞(通過停放),因此當前節點在
     *               釋放或取消時必須解除其後繼節點的停放。為了避免競爭,獲取方法
     *               必須首先表明它們需要一個訊號,然後重試原子獲取,然後在失敗時
     *               阻塞。
     *   CANCELLED:  節點因超時或中斷而被取消
     *   CONDITION:  這個節點被用於condition佇列,在裝個狀態下這個節點不會被用於
     *               同步佇列。
     *   PROPAGATE:  這個節點是被共享的
     *   0:          以上都不是
     *
     *   這個欄位的初始值為0,且是通過cas的方式對他進行安全寫操作
     */
    volatile int waitStatus;
      
    //前置節點
    volatile Node prev;
    //繼承節點
    volatile Node next;
    //該節點擁有的執行緒
    volatile Thread thread;

    //可能有兩種作用
    //1.獨佔模式下的condition條件下的等待節點
    //2.用於判斷是共享模式
    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;
    }
}

通過ReentrantLock窺探AQS獨佔鎖

下面我們通過幾個例項來探究AQS中的一些方法的實現以及在ReentrantLock中起到的作用。

最簡單的例項

下面我們就通過一個簡單的lock & unLock例項來入手

通過斷點進入,lock的具體實現在ReentrantLock的NonfairSync內部類中,這是由於我們為lock物件設定的非公平鎖。

然後我們會進入AQS中的compareAndSetState方法,它主要是通過cas的方式判斷state是否為0,是-就將其更改為1並返回true、否-不修改直接返回false,若為0就意味著這個鎖現在是沒有被任何執行緒佔有的,然後我們將它的狀態更改為1表示將其佔用。

返回是後,將AQS中的獨佔執行緒的欄位賦值為當前執行緒,然後就加鎖成功了。

然後我們進入ReentrantLock的unlock方法,這個方法的主要實現就是在AQS的release方法中

然後進入tryRelease方法,會獲取當前鎖的狀態,然後用c表示要被更改成的目標狀態,校驗後,將鎖的獨佔執行緒置為空並修改其狀態欄位state為目標狀態,到這裡鎖已經解除佔用了。

tryRelease返回為true後會判斷AQS中存不存在等待節點,如果存在則就將其喚醒(後面會看這裡的原始碼)

重入鎖例項

我們使用同一把ReentrantLock進行兩次lock操作,由於第一次和上面的簡單例項流程是一樣的所以我們只關注第二次lock和unLock

此時,由於已經lock過一次,即state=1,所以compareAndSetState(0,1)不會賦值成功,所以會進入到acquire方法,進而會首先進入到tryAcquire方法

我們會在TryAcquire中再次判斷鎖的狀態(因為在此過程中上一次lock可能被釋放),然後由於當前執行緒就是這把鎖的獨佔執行緒,所以我們是可重入這把鎖的,最後將state的值改為2代表這把鎖被當前執行緒重入了兩次。

由於tryAcquire(1)返回的是true所以!tryAcquire(1)為false導致程式不會進入acquire方法中的後續執行流程,到此,意味著第二次lock已經完成。

和簡單例項中的unlock一樣,程式會先進入release方法然後進入tryRelease方法,再這裡面因為更改後的state為1所以不會講當前鎖的獨佔執行緒設定為null(會在最後一次unlock中設定)

鎖競爭例項

鎖競爭就會涉及到等待佇列以及等待節點的阻塞與喚醒,所以它的一系列操作的複雜度相對於上面的例子要更高一些。使用以下例項來體驗一下多執行緒競爭鎖的過程。

t1會首先獲取到lock,這過程與無競爭鎖的獲取是一樣的,主要的不同點在於t2獲取鎖和t1釋放鎖的過程。

在idea中可以在下入這個位置切換除錯的執行緒

在t1執行緒獲取到鎖之後,我們切換到t2執行緒,發現idea此時已經給我們標註了lock這把鎖已經被t1佔用了。

然後會進入到acquire方法,由於此時t1已經佔用了鎖,所以state ≠ 0且擁有鎖的當前執行緒為t1≠t2所以 tryAcquire返回的是false,因此程式會進入addWaiter方法。

在這個方法中,會首先將t2執行緒封裝到一個Node物件當中,然後通過tail節點判斷佇列是否被初始化了,由於CLH佇列此時並沒有元素存在,所以會進入到enq方法進行佇列的首次初始化。

在enq中會初始化這個佇列會初始化佇列,然後將傳入的node插入到隊尾,在這裡面我們看到了for(;;)的死迴圈(優雅點可以叫做自旋),那麼它的作用是什麼呢?

在此次進入到enq中實際上for只進行了兩次,第一次給頭節點設定了一個沒有實際資料的head節點,第二次將傳入的node加入到了隊尾,那麼以上工作我們是可以在一次迴圈中完成的,就比如以下程式碼塊中的實現方式

private Node enq(final Node node) {
    Node t = tail;
    if (t == null) { // Must initialize
        if (compareAndSetHead(new Node()))
            tail = head;
    } 
    node.prev = t;
    if (compareAndSetTail(t, node)) {
        t.next = node;
        return t;
    }
}

其實自旋是為了保證執行緒安全,在t2執行緒獲取鎖的時候可能也有其它執行緒正在爭搶lock,就比如恰好有執行緒在t2執行Node t = tailcompareAndSetHead(new Node())之間的時候初始化成功了佇列設定了head節點, 那麼compareAndSetHead就會返回false不會進入這個分支,這時候就會重新獲取tail節點再將傳入的node節點插入到tail地next中,但是,此時tail可能也會被別的執行緒更改,那麼就需要不斷地自旋嘗試修改直到成功位置,自旋結束。

addWaiter結束後會進入acquireQueued,這個方法主要是會進行鎖地爭搶以及阻塞等待,最後根據failed欄位判斷是否要取消獲取執行緒,這種情況一般就是狀態被置為了Canceled

shouldParkAfterFailedAcquire判斷節點是否應該阻塞等待,如果這個節點為SIGNAL狀態就說明該節點的後繼節點應該被阻塞,繼而會執行parkAndCheckInterrupt方法對其進行阻塞,並在它被喚醒的時候判斷此執行緒是否是被interrupt的。

正常情況下如果如果t1執行緒不unlock,那麼t2執行緒將一直阻塞在parkAndCheckInterrupt方法,當其被喚醒後會繼續自旋嘗試獲取鎖。

然後我們切換回t1執行緒,進入unlock方法,呼叫AQS的release方法,然後tryRelease裡面操作跟上面兩個例項相同不在贅述,唯一不同的是之前例項的等待佇列都為空,也就是head節點都是null,所以不會去喚醒阻塞節點,因為此時我們有t2執行緒所在的節點是被儲存到了佇列中所以,程式會進入到unparkSuccessor方法中,執行完這個方法後t2執行緒會從之前的WAIT狀態轉換為RUNNING狀態即被喚醒!

t2被喚醒後會去再次tryAcquire,成功後去執行臨界區的內容,然後正常釋放lock鎖。

結尾

上面利用ReentrantLock介紹了AQS獨佔鎖相關內容,除此之外,後面還會通過ReentrantReadWriteLock介紹共享鎖的實現、Condition的實現以及其它相關的JUC類中AQS的使用。

相關文章