AbstractQueuedSynchronizer(AQS)深入剖析

weixin_34050427發表於2018-08-06

上一篇文章中我們對LockAbstractQueuedSynchronizer(AQS)有了初步的認識。在同步元件的實現中,AQS是核心部分,同步元件的實現者通過使用AQS提供的模板方法實現同步元件語義,AQS則實現了對同步狀態的管理,以及對阻塞執行緒進行排隊,等待通知等等一些底層的實現處理。AQS的核心也包括了這些方面:同步佇列,獨佔式鎖的獲取和釋放,共享鎖的獲取和釋放以及可中斷鎖,超時等待鎖獲取這些特性的實現,而這些實際上則是AQS提供出來的模板方法,歸納整理如下:

獨佔式鎖:

void acquire(int arg):獨佔式獲取同步狀態,如果獲取失敗則插入同步佇列進行等待;
void acquireInterruptibly(int arg):acquire方法相同,但在同步佇列中進行等待的時候可以檢測中斷;
boolean tryAcquireNanos(int arg, long nanosTimeout):acquireInterruptibly基礎上增加了超時等待功能,在超時時間內沒有獲得同步狀態返回false;
boolean release(int arg):釋放同步狀態,該方法會喚醒在同步佇列中的下一個節點

共享式鎖:

void acquireShared(int arg):共享式獲取同步狀態,與獨佔式的區別在於同一時刻有多個執行緒獲取同步狀態;
void acquireSharedInterruptibly(int arg):在acquireShared方法基礎上增加了能響應中斷的功能;
boolean tryAcquireSharedNanos(int arg, long nanosTimeout):在acquireSharedInterruptibly基礎上增加了超時等待的功能;
boolean releaseShared(int arg):共享式釋放同步狀態

要想掌握AQS的底層實現,其實也就是對這些模板方法的邏輯進行學習。在學習這些模板方法之前,我們得首先了解下AQS中的同步佇列是一種什麼樣的資料結構,因為同步佇列是AQS對同步狀態的管理的基石。

2. 同步佇列

AQS提供了一個基於FIFO佇列,可以用於構建鎖或者其他相關同步裝置的基礎框架。該同步器(以下簡稱同步器)利用了一個int來表示狀態,期望它能夠成為實現大部分同步需求的基礎。使用的方法是繼承,子類通過繼承同步器並需要實現它的方法來管理其狀態,管理的方式就是通過類似acquire和release的方式來操縱狀態。然而多執行緒環境中對狀態的操縱必須確保原子性,因此子類對於狀態的把握,需要使用這個同步器提供的以下三個方法對狀態進行操作:

  • java.util.concurrent.locks.AbstractQueuedSynchronizer.getState()
  • java.util.concurrent.locks.AbstractQueuedSynchronizer.setState(int)
  • java.util.concurrent.locks.AbstractQueuedSynchronizer.compareAndSetState(int, int)

子類推薦被定義為自定義同步裝置的內部類,同步器自身沒有實現任何同步介面,它僅僅是定義了若干acquire之類的方法來供使用。該同步器即可以作為排他模式也可以作為共享模式,當它被定義為一個排他模式時,其他執行緒對其的獲取就被阻止,而共享模式對於多個執行緒獲取都可以成功。

同步器是實現鎖的關鍵,利用同步器將鎖的語義實現,然後在鎖的實現中聚合同步器。可以這樣理解:鎖的API是面向使用者的,它定義了與鎖互動的公共行為,而每個鎖需要完成特定的操作也是透過這些行為來完成的(比如:可以允許兩個執行緒進行加鎖,排除兩個以上的執行緒),但是實現是依託給同步器來完成;同步器面向的是執行緒訪問和資源控制,它定義了執行緒對資源是否能夠獲取以及執行緒的排隊等操作。鎖和同步器很好的隔離了二者所需要關注的領域,嚴格意義上講,同步器可以適用於除了鎖以外的其他同步設施上(包括鎖)。
同步器的開始提到了其實現依賴於一個FIFO佇列,那麼佇列中的元素Node就是儲存著執行緒引用和執行緒狀態的容器,每個執行緒對同步器的訪問,都可以看做是佇列中的一個節點。Node的主要包含以下成員變數:

Node {
    int waitStatus;
    Node prev;
    Node next;
    Node nextWaiter;
    Thread thread;
}

以上五個成員變數主要負責儲存該節點的執行緒引用,同步等待佇列(以下簡稱sync佇列)的前驅和後繼節點,同時也包括了同步狀態。

屬性名稱 描述
int waitStatus 表示節點的狀態。其中包含的狀態有:1. CANCELLED,值為1,表示當前的執行緒被取消; 2. SIGNAL,值為-1,表示當前節點的後繼節點包含的執行緒需要執行,也就是unpark; 3. CONDITION,值為-2,表示當前節點在等待condition,也就是在condition佇列中; 4. PROPAGATE,值為-3,表示當前場景下後續的acquireShared能夠得以執行; 5. 值為0,表示當前節點在sync佇列中,等待著獲取鎖。
Node prev 前驅節點,比如當前節點被取消,那就需要前驅節點和後繼節點來完成連線。
Node next 後繼節點。
Node nextWaiter 儲存condition佇列中的後繼節點。
Thread thread 入佇列時的當前執行緒。

節點成為sync佇列和condition佇列構建的基礎,在同步器中就包含了sync佇列。同步器擁有三個成員變數:sync佇列的頭結點head、sync佇列的尾節點tail和狀態state。對於鎖的獲取,請求形成節點,將其掛載在尾部,而鎖資源的轉移(釋放再獲取)是從頭部開始向後進行。對於同步器維護的狀態state,多個執行緒對其的獲取將會產生一個鏈式的結構。


1900685-846b290e887ee730.png
image

現在我們知道了節點的資料結構型別,並且每個節點擁有其前驅和後繼節點,很顯然這是一個雙向佇列。同樣的我們可以用一段demo看一下。

public class LockDemo {
    private static ReentrantLock lock = new ReentrantLock();

    public static void main(String[] args) {
        for (int i = 0; i < 5; i++) {
            Thread thread = new Thread(() -> {
                lock.lock();
                try {
                    Thread.sleep(10000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    lock.unlock();
                }
            });
            thread.start();
        }
    }
}

例項程式碼中開啟了5個執行緒,先獲取鎖之後再睡眠10S中,實際上這裡讓執行緒睡眠是想模擬出當執行緒無法獲取鎖時進入同步佇列的情況。通過debug,當Thread-4(在本例中最後一個執行緒)獲取鎖失敗後進入同步時,AQS時現在的同步佇列如圖所示:

1900685-dc5be886668d5f2e
LockDemo debug png

Thread-0先獲得鎖後進行睡眠,其他執行緒(Thread-1,Thread-2,Thread-3,Thread-4)獲取鎖失敗進入同步佇列,同時也可以很清楚的看出來每個節點有兩個域:prev(前驅)和next(後繼),並且每個節點用來儲存獲取同步狀態失敗的執行緒引用以及等待狀態等資訊。另外AQS中有兩個重要的成員變數:

private transient volatile Node head;
private transient volatile Node tail;

也就是說AQS實際上通過頭尾指標來管理同步佇列,同時實現包括獲取鎖失敗的執行緒進行入隊,釋放鎖時對同步佇列中的執行緒進行通知等核心方法。其示意圖如下:

1900685-dfcdb3f0978f4cf2
png

通過對原始碼的理解以及做實驗的方式,現在我們可以清楚的知道這樣幾點:

  1. 節點的資料結構,即AQS的靜態內部類Node,節點的等待狀態等資訊
  2. 同步佇列是一個雙向佇列,AQS通過持有頭尾指標管理同步佇列

那麼,節點如何進行入隊和出隊是怎樣做的了?實際上這對應著鎖的獲取和釋放兩個操作:獲取鎖失敗進行入隊操作,獲取鎖成功進行出隊操作。

3. 獨佔鎖

3.1 獨佔鎖的獲取(acquire方法)

我們繼續通過看原始碼和debug的方式來看,還是以上面的demo為例,呼叫lock()方法是獲取獨佔式鎖,獲取失敗就將當前執行緒加入同步佇列,成功則執行緒執行。而lock()方法實際上會呼叫AQSacquire()方法,原始碼如下

public final void acquire(int arg) {
        //先看同步狀態是否獲取成功,如果成功則方法結束返回
        //若失敗則先呼叫addWaiter()方法再呼叫acquireQueued()方法
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
}

上述邏輯主要包括:

  1. 嘗試獲取(呼叫tryAcquire更改狀態,需要保證原子性);
    在tryAcquire方法中使用了同步器提供的對state操作的方法,利用compareAndSet保證只有一個執行緒能夠對狀態進行成功修改,而沒有成功修改的執行緒將進入sync佇列排隊。
  2. 如果獲取不到,將當前執行緒構造成節點Node並加入sync佇列;
    進入佇列的每個執行緒都是一個節點Node,從而形成了一個雙向佇列,類似CLH佇列,這樣做的目的是執行緒間的通訊會被限制在較小規模(也就是兩個節點左右)。
  3. 再次嘗試獲取,如果沒有獲取到那麼將當前執行緒從執行緒排程器上摘下,進入等待狀態。

總結: 使用LockSupport將當前執行緒unpark,關於LockSupport後續會詳細介紹。關鍵資訊請看註釋,acquire根據當前獲得同步狀態成功與否做了兩件事情:1. 成功,則方法結束返回,2. 失敗,則先呼叫addWaiter()然後在呼叫acquireQueued()方法。

獲取同步狀態失敗,入隊操作

當執行緒獲取獨佔式鎖失敗後就會將當前執行緒加入同步佇列,那麼加入佇列的方式是怎樣的了?我們接下來就應該去研究一下addWaiter()acquireQueued()addWaiter()原始碼如下:

private Node addWaiter(Node mode) {
        // 1\. 將當前執行緒構建成Node型別
        Node node = new Node(Thread.currentThread(), mode);
        // Try the fast path of enq; backup to full enq on failure
        // 2\. 當前尾節點是否為null?
        Node pred = tail;
        if (pred != null) {
            // 2.2 將當前節點尾插入的方式插入同步佇列中
            node.prev = pred;
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
        // 2.1\. 當前同步佇列尾節點為null,說明當前執行緒是第一個加入同步佇列進行等待的執行緒
        enq(node);
        return node;
}

上述邏輯主要包括:

  1. 使用當前執行緒構造Node;
    對於一個節點需要做的是將當節點前驅節點指向尾節點(current.prev = tail),尾節點指向它(tail = current),原有的尾節點的後繼節點指向它(t.next = current)而這些操作要求是原子的。上面的操作是利用尾節點的設定來保證的,也就是compareAndSetTail來完成的。
  2. 先行嘗試在隊尾新增;
    如果尾節點已經有了,然後做如下操作:
    (1)分配引用T指向尾節點;
    (2)將節點的前驅節點更新為尾節點(current.prev = tail);
    (3)如果尾節點是T,那麼將當尾節點設定為該節點(tail = current,原子更新);
    (4)T的後繼節點指向當前節點(T.next = current)。
    注意第3點是要求原子的。
    這樣可以以最短路徑O(1)的效果來完成執行緒入隊,是最大化減少開銷的一種方式。
  3. 如果隊尾新增失敗或者是第一個入隊的節點。
    如果是第1個節點,也就是sync佇列沒有初始化,那麼會進入到enq這個方法,進入的執行緒可能有多個,或者說在addWaiter中沒有成功入隊的執行緒都將進入enq這個方法。
    可以看到enq的邏輯是確保進入的Node都會有機會順序的新增到sync佇列中,而加入的步驟如下:
    (1)如果尾節點為空,那麼原子化的分配一個頭節點,並將尾節點指向頭節點,這一步是初始化;
    (2)然後是重複在addWaiter中做的工作,但是在一個while(true)的迴圈中,直到當前節點入隊為止。
    進入sync佇列之後,接下來就是要進行鎖的獲取,或者說是訪問控制了,只有一個執行緒能夠在同一時刻繼續的執行,而其他的進入等待狀態。而每個執行緒都是一個獨立的個體,它們自省的觀察,當條件滿足的時候(自己的前驅是頭結點並且原子性的獲取了狀態),那麼這個執行緒能夠繼續執行。
private Node enq(final Node node) {
        for (;;) {
            Node t = tail;
            if (t == null) { // Must initialize
                //1\. 構造頭結點
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
                // 2\. 尾插入,CAS操作失敗自旋嘗試
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
}

在上面的分析中我們可以看出在第1步中會先建立頭結點,說明同步佇列是帶頭結點的鏈式儲存結構。帶頭結點與不帶頭結點相比,會在入隊和出隊的操作中獲得更大的便捷性,因此同步佇列選擇了帶頭結點的鏈式儲存結構。那麼帶頭節點的佇列初始化時機是什麼?自然而然是在tail為null時,即當前執行緒是第一次插入同步佇列compareAndSetTail(t, node)方法會利用CAS操作設定尾節點,如果CAS操作失敗會在for (;;)for死迴圈中不斷嘗試,直至成功return返回為止。因此,對enq()方法可以做這樣的總結:

  1. 在當前執行緒是第一個加入同步佇列時,呼叫compareAndSetHead(new Node())方法,完成鏈式佇列的頭結點的初始化
  2. 自旋不斷嘗試CAS尾插入節點直至成功為止

現在我們已經很清楚獲取獨佔式鎖失敗的執行緒包裝成Node然後插入同步佇列的過程了?那麼緊接著會有下一個問題?在同步佇列中的節點(執行緒)會做什麼事情了來保證自己能夠有機會獲得獨佔式鎖了?帶著這樣的問題我們就來看看acquireQueued()方法,從方法名就可以很清楚,這個方法的作用就是排隊獲取鎖的過程,原始碼如下:

final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
            for (;;) {
                // 1\. 獲得當前節點的先驅節點
                final Node p = node.predecessor();
                // 2\. 當前節點能否獲取獨佔式鎖                 
                // 2.1 如果當前節點的先驅節點是頭結點並且成功獲取同步狀態,即可以獲得獨佔式鎖
                if (p == head && tryAcquire(arg)) {
                    //佇列頭指標用指向當前節點
                    setHead(node);
                    //釋放前驅節點
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
                // 2.2 獲取鎖失敗,執行緒進入等待狀態等待獲取獨佔式鎖
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
}

上述邏輯主要包括:

  1. 獲取當前節點的前驅節點;
    需要獲取當前節點的前驅節點,而頭結點所對應的含義是當前站有鎖且正在執行。
  2. 當前驅節點是頭結點並且能夠獲取狀態,代表該當前節點佔有鎖;
    如果滿足上述條件,那麼代表能夠佔有鎖,根據節點對鎖佔有的含義,設定頭結點為當前節點。
  3. 否則進入等待狀態。
    如果沒有輪到當前節點執行,那麼將當前執行緒從執行緒排程器上摘下,也就是進入等待狀態。

這裡針對acquire做一下總結:

  1. 狀態的維護;
    需要在鎖定時,需要維護一個狀態(int型別),而對狀態的操作是原子和非阻塞的,通過同步器提供的對狀態訪問的方法對狀態進行操縱,並且利用compareAndSet來確保原子性的修改。
  2. 狀態的獲取;
    一旦成功的修改了狀態,當前執行緒或者說節點,就被設定為頭節點。
  3. sync佇列的維護。
    在獲取資源未果的過程中條件不符合的情況下(不該自己,前驅節點不是頭節點或者沒有獲取到資源)進入睡眠狀態,停止執行緒排程器對當前節點執行緒的排程。
    這時引入的一個釋放的問題,也就是說使睡眠中的Node或者說執行緒獲得通知的關鍵,就是前驅節點的通知,而這一個過程就是釋放,釋放會通知它的後繼節點從睡眠中返回準備執行。整體示意圖為下圖:
1900685-7a50f0f260ef9734
png

獲取鎖成功,出隊操作

獲取鎖的節點出隊的邏輯是:

//佇列頭結點引用指向當前節點
setHead(node);
//釋放前驅節點
p.next = null; // help GC
failed = false;
return interrupted;

setHead()方法為:

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

將當前節點通過setHead()方法設定為佇列的頭結點,然後將之前的頭結點的next域設定為null並且pre域也為null,即與佇列斷開,無任何引用方便GC時能夠將記憶體進行回收。示意圖如下:

1900685-6da96e3807081bb8
png

那麼當獲取鎖失敗的時候會呼叫shouldParkAfterFailedAcquire()方法和parkAndCheckInterrupt()方法,看看他們做了什麼事情。shouldParkAfterFailedAcquire()方法原始碼為:

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus;
    if (ws == Node.SIGNAL)
        /*
         * 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.
         */
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

shouldParkAfterFailedAcquire()方法主要邏輯是使用compareAndSetWaitStatus(pred, ws, Node.SIGNAL)使用CAS將節點狀態由INITIAL設定成SIGNAL,表示當前執行緒阻塞。當compareAndSetWaitStatus設定失敗則說明shouldParkAfterFailedAcquire方法返回false,然後會在acquireQueued()方法中for (;;)死迴圈中會繼續重試,直至compareAndSetWaitStatus設定節點狀態位為SIGNALshouldParkAfterFailedAcquire返回true時才會執行方法parkAndCheckInterrupt()方法,該方法的原始碼為:

private final boolean parkAndCheckInterrupt() {
        //使得該執行緒阻塞
        LockSupport.park(this);
        return Thread.interrupted();
}

該方法的關鍵是會呼叫LookSupport.park()方法(關於LookSupport會在以後的文章進行討論),該方法是用來阻塞當前執行緒的。因此到這裡就應該清楚了,acquireQueued()在自旋過程中主要完成了兩件事情:

  1. 如果當前節點的前驅節點是頭節點,並且能夠獲得同步狀態的話,當前執行緒能夠獲得鎖該方法執行結束退出
  2. 獲取鎖失敗的話,先將節點狀態設定成SIGNAL,然後呼叫LookSupport.park方法使得當前執行緒阻塞

經過上面的分析,獨佔式鎖的獲取過程也就是acquire()方法的執行流程如下圖所示:

1900685-170d0656c82687da
acquirepng

3.2 獨佔鎖的釋放(release()方法)

獨佔鎖的釋放就相對來說比較容易理解了,廢話不多說先來看下原始碼:

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返回true)則會執行if塊中的程式碼,當head指向的頭結點不為null,並且該節點的狀態值不為0的話才會執行unparkSuccessor()方法。unparkSuccessor方法原始碼:

private void unparkSuccessor(Node node) {
    /*
     * 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;
    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.
     */

    //頭節點的後繼節點
    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)
        //後繼節點不為null時喚醒該執行緒
        LockSupport.unpark(s.thread);
}

原始碼的關鍵資訊請看註釋,首先獲取頭節點的後繼節點,當後繼節點的時候會呼叫LookSupport.unpark()方法,該方法會喚醒該節點的後繼節點所包裝的執行緒。因此,每一次鎖釋放後就會喚醒佇列中該節點的後繼節點所引用的執行緒,從而進一步可以佐證獲得鎖的過程是一個FIFO(先進先出)的過程。

到現在我們終於啃下了一塊硬骨頭了,通過學習原始碼的方式非常深刻的學習到了獨佔式鎖的獲取和釋放的過程以及同步佇列。可以做一下總結:

  1. 執行緒獲取鎖失敗,執行緒被封裝成Node進行入隊操作,核心方法在於addWaiter()和enq(),同時enq()完成對同步佇列的頭結點初始化工作以及CAS操作失敗的重試;
  2. 執行緒獲取鎖是一個自旋的過程,當且僅當 當前節點的前驅節點是頭結點並且成功獲得同步狀態時,節點出隊即該節點引用的執行緒獲得鎖,否則,當不滿足條件時就會呼叫LookSupport.park()方法使得執行緒阻塞
  3. 釋放鎖的時候會喚醒後繼節點;

總體來說:在獲取同步狀態時,AQS維護一個同步佇列,獲取同步狀態失敗的執行緒會加入到佇列中進行自旋;移除佇列(或停止自旋)的條件是前驅節點是頭結點並且成功獲得了同步狀態。在釋放同步狀態時,同步器會呼叫unparkSuccessor()方法喚醒後繼節點。

獨佔鎖特性學習

3.3 可中斷式獲取鎖(acquireInterruptibly方法)

我們知道lock相較於synchronized有一些更方便的特性,比如能響應中斷以及超時等待等特性,現在我們依舊採用通過學習原始碼的方式來看看能夠響應中斷是怎麼實現的。可響應中斷式鎖可呼叫方法lock.lockInterruptibly();而該方法其底層會呼叫AQSacquireInterruptibly方法,原始碼為:

public final void acquireInterruptibly(int arg)
        throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    if (!tryAcquire(arg))
        //執行緒獲取鎖失敗
        doAcquireInterruptibly(arg);
}

在獲取同步狀態失敗後就會呼叫doAcquireInterruptibly方法:

private void doAcquireInterruptibly(int arg)
    throws InterruptedException {
    //將節點插入到同步佇列中
    final Node node = addWaiter(Node.EXCLUSIVE);
    boolean failed = true;
    try {
        for (;;) {
            final Node p = node.predecessor();
            //獲取鎖出隊
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return;
            }
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                //執行緒中斷拋異常
                throw new InterruptedException();
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

關鍵資訊請看註釋,現在看這段程式碼就很輕鬆了吧:),與acquire方法邏輯幾乎一致,唯一的區別是當parkAndCheckInterrupt返回true時即執行緒阻塞時該執行緒被中斷,程式碼丟擲被中斷異常。

3.4 超時等待式獲取鎖(tryAcquireNanos()方法)

通過呼叫lock.tryLock(timeout,TimeUnit)方式達到超時等待獲取鎖的效果,該方法會在三種情況下才會返回:

  1. 在超時時間內,當前執行緒成功獲取了鎖;
  2. 當前執行緒在超時時間內被中斷;
  3. 超時時間結束,仍未獲得鎖返回false。

我們仍然通過採取閱讀原始碼的方式來學習底層具體是怎麼實現的,該方法會呼叫AQS的方法tryAcquireNanos(),原始碼為:

public final boolean tryAcquireNanos(int arg, long nanosTimeout)
        throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    return tryAcquire(arg) ||
        //實現超時等待的效果
        doAcquireNanos(arg, nanosTimeout);
}

很顯然這段原始碼最終是靠doAcquireNanos方法實現超時等待的效果,該方法原始碼如下:

private boolean doAcquireNanos(int arg, long nanosTimeout)
        throws InterruptedException {
    if (nanosTimeout <= 0L)
        return false;
    //1\. 根據超時時間和當前時間計算出截止時間
    final long deadline = System.nanoTime() + nanosTimeout;
    final Node node = addWaiter(Node.EXCLUSIVE);
    boolean failed = true;
    try {
        for (;;) {
            final Node p = node.predecessor();
            //2\. 當前執行緒獲得鎖出佇列
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return true;
            }
            // 3.1 重新計算超時時間
            nanosTimeout = deadline - System.nanoTime();
            // 3.2 已經超時返回false
            if (nanosTimeout <= 0L)
                return false;
            // 3.3 執行緒阻塞等待 
            if (shouldParkAfterFailedAcquire(p, node) &&
                nanosTimeout > spinForTimeoutThreshold)
                LockSupport.parkNanos(this, nanosTimeout);
            // 3.4 執行緒被中斷丟擲被中斷異常
            if (Thread.interrupted())
                throw new InterruptedException();
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

程式邏輯如圖所示:

1900685-172c7b895b5e9ad5
doAcquireNanos

程式邏輯同獨佔鎖可響應中斷式獲取基本一致,唯一的不同在於獲取鎖失敗後,對超時時間的處理上,在第1步會先計算出按照現在時間和超時時間計算出理論上的截止時間,比如當前時間是8h10min,超時時間是10min,那麼根據deadline = System.nanoTime() + nanosTimeout計算出剛好達到超時時間時的系統時間就是8h 10min+10min = 8h 20min。然後根據deadline - System.nanoTime()就可以判斷是否已經超時了,比如,當前系統時間是8h 30min很明顯已經超過了理論上的系統時間8h 20min,deadline - System.nanoTime()計算出來就是一個負數,自然而然會在3.2步中的If判斷之間返回false。如果還沒有超時即3.2步中的if判斷為true時就會繼續執行3.3步通過LockSupport.parkNanos使得當前執行緒阻塞,同時在3.4步增加了對中斷的檢測,若檢測出被中斷直接丟擲被中斷異常。

4. 共享鎖

4.1 執行過程概述

獲取鎖的過程:

  1. 當執行緒呼叫acquireShared()申請獲取鎖資源時,如果成功,則進入臨界區。
  2. 當獲取鎖失敗時,則建立一個共享型別的節點並進入一個FIFO等待佇列,然後被掛起等待喚醒。
  3. 當佇列中的等待執行緒被喚醒以後就重新嘗試獲取鎖資源,如果成功則喚醒後面還在等待的共享節點並把該喚醒事件傳遞下去,即會依次喚醒在該節點後面的所有共享節點,然後進入臨界區,否則繼續掛起等待。

釋放鎖過程:

  1. 當執行緒呼叫releaseShared()進行鎖資源釋放時,如果釋放成功,則喚醒佇列中等待的節點,如果有的話。

4.2 原始碼深入分析

基於上面所說的共享鎖執行流程,我們接下來看下原始碼實現邏輯:
首先來看下獲取鎖的方法acquireShared(),如下

   public final void acquireShared(int arg) {
        //嘗試獲取共享鎖,返回值小於0表示獲取失敗
        if (tryAcquireShared(arg) < 0)
            //執行獲取鎖失敗以後的方法
            doAcquireShared(arg);
    }

這裡tryAcquireShared()方法是留給使用者去實現具體的獲取鎖邏輯的。關於該方法的實現有兩點需要特別說明:

  1. 該方法必須自己檢查當前上下文是否支援獲取共享鎖,如果支援再進行獲取。

  2. 該方法返回值是個重點。其一、由上面的原始碼片段可以看出返回值小於0表示獲取鎖失敗,需要進入等待佇列。其二、如果返回值等於0表示當前執行緒獲取共享鎖成功,但它後續的執行緒是無法繼續獲取的,也就是不需要把它後面等待的節點喚醒。最後、如果返回值大於0,表示當前執行緒獲取共享鎖成功且它後續等待的節點也有可能繼續獲取共享鎖成功,也就是說此時需要把後續節點喚醒讓它們去嘗試獲取共享鎖。

有了上面的約定,我們再來看下doAcquireShared方法的實現:

//引數不多說,就是傳給acquireShared()的引數
private void doAcquireShared(int arg) {
    //新增等待節點的方法跟獨佔鎖一樣,唯一區別就是節點型別變為了共享型,不再贅述
    final Node node = addWaiter(Node.SHARED);
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            final Node p = node.predecessor();
            //表示前面的節點已經獲取到鎖,自己會嘗試獲取鎖
            if (p == head) {
                int r = tryAcquireShared(arg);
                //注意上面說的, 等於0表示不用喚醒後繼節點,大於0需要
                if (r >= 0) {
                    //這裡是重點,獲取到鎖以後的喚醒操作,後面詳細說
                    setHeadAndPropagate(node, r);
                    p.next = null;
                    //如果是因為中斷醒來則設定中斷標記位
                    if (interrupted)
                        selfInterrupt();
                    failed = false;
                    return;
                }
            }
            //掛起邏輯跟獨佔鎖一樣,不再贅述
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        //獲取失敗的取消邏輯跟獨佔鎖一樣,不再贅述
        if (failed)
            cancelAcquire(node);
    }
}

獨佔鎖模式獲取成功以後設定頭結點然後返回中斷狀態,結束流程。而共享鎖模式獲取成功以後,呼叫了setHeadAndPropagate方法,從方法名就可以看出除了設定新的頭結點以外還有一個傳遞動作,一起看下程式碼:

    //兩個入參,一個是當前成功獲取共享鎖的節點,一個就是tryAcquireShared方法的返回值,注意上面說的,它可能大於0也可能等於0
    private void setHeadAndPropagate(Node node, int propagate) {
        Node h = head; //記錄當前頭節點
        //設定新的頭節點,即把當前獲取到鎖的節點設定為頭節點
        //注:這裡是獲取到鎖之後的操作,不需要併發控制
        setHead(node);
        //這裡意思有兩種情況是需要執行喚醒操作
        //1.propagate > 0 表示呼叫方指明瞭後繼節點需要被喚醒
        //2.頭節點後面的節點需要被喚醒(waitStatus<0),不論是老的頭結點還是新的頭結點
        if (propagate > 0 || h == null || h.waitStatus < 0 ||
            (h = head) == null || h.waitStatus < 0) {
            Node s = node.next;
            //如果當前節點的後繼節點是共享型別或者沒有後繼節點,則進行喚醒
            //這裡可以理解為除非明確指明不需要喚醒(後繼等待節點是獨佔型別),否則都要喚醒
            if (s == null || s.isShared())
                //後面詳細說
                doReleaseShared();
        }
    }

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

最終的喚醒操作也很複雜,專門拿出來分析一下:
注:這個喚醒操作在releaseShare()方法裡也會呼叫。

private void doReleaseShared() {
        for (;;) {
            //喚醒操作由頭結點開始,注意這裡的頭節點已經是上面新設定的頭結點了
            //其實就是喚醒上面新獲取到共享鎖的節點的後繼節點
            Node h = head;
            if (h != null && h != tail) {
                int ws = h.waitStatus;
                //表示後繼節點需要被喚醒
                if (ws == Node.SIGNAL) {
                    //這裡需要控制併發,因為入口有setHeadAndPropagate跟release兩個,避免兩次unpark
                    if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                        continue;      
                    //執行喚醒操作      
                    unparkSuccessor(h);
                }
                //如果後繼節點暫時不需要喚醒,則把當前節點狀態設定為PROPAGATE確保以後可以傳遞下去
                else if (ws == 0 &&
                         !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                    continue;                
            }
            //如果頭結點沒有發生變化,表示設定完成,退出迴圈
            //如果頭結點發生變化,比如說其他執行緒獲取到了鎖,為了使自己的喚醒動作可以傳遞,必須進行重試
            if (h == head)                   
                break;
        }
    }

接下來看下釋放共享鎖的過程:

public final boolean releaseShared(int arg) {
        //嘗試釋放共享鎖
        if (tryReleaseShared(arg)) {
            //喚醒過程,詳情見上面分析
            doReleaseShared();
            return true;
        }
        return false;
    }

注:上面的setHeadAndPropagate()方法表示等待佇列中的執行緒成功獲取到共享鎖,這時候它需要喚醒它後面的共享節點(如果有),但是當通過releaseShared()方法去釋放一個共享鎖的時候,接下來等待獨佔鎖跟共享鎖的執行緒都可以被喚醒進行嘗試獲取。

4.3 總結

跟獨佔鎖相比,共享鎖的主要特徵在於當一個在等待佇列中的共享節點成功獲取到鎖以後(它獲取到的是共享鎖),既然是共享,那它必須要依次喚醒後面所有可以跟它一起共享當前鎖資源的節點,毫無疑問,這些節點必須也是在等待共享鎖(這是大前提,如果等待的是獨佔鎖,那前面已經有一個共享節點獲取鎖了,它肯定是獲取不到的)。當共享鎖被釋放的時候,可以用讀寫鎖為例進行思考,當一個讀鎖被釋放,此時不論是讀鎖還是寫鎖都是可以競爭資源的。

5. 總結:

如果獲取共享鎖失敗後,將請求共享鎖的執行緒封裝成Node物件放入AQS的佇列中,並掛起Node物件對應的執行緒,實現請求鎖執行緒的等待操作。待共享鎖可以被獲取後,從頭節點開始,依次喚醒頭節點及其以後的所有共享型別的節點。實現共享狀態的傳播。這裡有幾點值得注意:
1. 與AQS的獨佔功能一樣,共享鎖是否可以被獲取的判斷為空方法,交由子類去實現。
2. 與AQS的獨佔功能不同,當鎖被頭節點獲取後,獨佔功能是隻有頭節點獲取鎖,其餘節點的執行緒繼續沉睡,等待鎖被釋放後,才會喚醒下一個節點的執行緒,而共享功能是隻要頭節點獲取鎖成功,就在喚醒自身節點對應的執行緒的同時,繼續喚醒AQS佇列中的下一個節點的執行緒,每個節點在喚醒自身的同時還會喚醒下一個節點對應的執行緒,以實現共享狀態的“向後傳播”,從而實現共享功能。

以上的分析都是從AQS子類的角度去看待AQS的部分功能的,而如果直接看待AQS,或許可以這麼去解讀:
首先,AQS並不關心“是什麼鎖”,對於AQS來說它只是實現了一系列的用於判斷“資源”是否可以訪問的API,並且封裝了在“訪問資源”受限時將請求訪問的執行緒的加入佇列、掛起、喚醒等操作,AQS只關心“資源不可以訪問時,怎麼處理?”、“資源是可以被同時訪問,還是在同一時間只能被一個執行緒訪問?”、“如果有執行緒等不及資源了,怎麼從AQS的佇列中退出?”等一系列圍繞資源訪問的問題,而至於“資源是否可以被訪問?”這個問題則交給AQS的子類去實現。
AQS的子類是實現獨佔功能時,例如ReentrantLock,“資源是否可以被訪問”被定義為只要AQS的state變數不為0,並且持有鎖的執行緒不是當前執行緒,則代表資源不能訪問。
AQS的子類是實現共享功能時,例如:CountDownLatch,“資源是否可以被訪問”被定義為只要AQSstate變數不為0,說明資源不能訪問。這是典型的將規則和操作分開的設計思路:規則子類定義,操作邏輯因為具有公用性,放在父類中去封裝。當然,正式因為AQS只是關心“資源在什麼條件下可被訪問”,所以子類還可以同時使用AQS的共享功能和獨佔功能的API以實現更為複雜的功能。
比如:ReentrantReadWriteLock,我們知道ReentrantReadWriteLock的中也有一個叫Sync的內部類繼承了AQS,而AQS的佇列可以同時存放共享鎖和獨佔鎖,對於ReentrantReadWriteLock來說分別代表讀鎖和寫鎖,當佇列中的頭節點為讀鎖時,代表讀操作可以執行,而寫操作不能執行,因此請求寫操作的執行緒會被掛起,當讀操作依次推出後,寫鎖成為頭節點,請求寫操作的執行緒被喚醒,可以執行寫操作,而此時的讀請求將被封裝成Node放入AQS的佇列中。如此往復,實現讀寫鎖的讀寫交替進行。

參考文獻

《java併發程式設計的藝術》

相關文章