JUC框架 CountDownLatch源碼解析 JDK8

前言

CountDownLatch是一種常用的JUC框架構件,它用在一組線程任務需要等到條件滿足後才能從同一起跑線開始執行的場景。比如當你去喫飯,你已經坐在桌上了但還是不能動筷子,因爲各個領導還沒來,只有領導都到齊了,大家才能開喫;再比如,參加了一場不能提前交卷的考試,學霸已經早早做完了但也不能提前走,只有當時間流逝直到考試結束時,大家才能離開考場。

CountDownLatch一般構造器給定一個大於0的數n,當調用了nCountDown後,條件就將滿足,所有阻塞在await()的線程將從同一起跑線開始執行。

JUC框架 系列文章目錄

實現核心

CountDownLatch向下依賴了AQS的共享鎖部分,它使用了一個內部類繼承了AQS。既然是使用的共享鎖,那麼肯定要實現tryAcquireSharedtryReleaseShared方法,因爲共享鎖的獲取和釋放流程都會依賴子類對這兩個方法的實現。

    private static final class Sync extends AbstractQueuedSynchronizer {
        private static final long serialVersionUID = 4982264981922014374L;

        Sync(int count) {
            setState(count);
        }

        int getCount() {
            return getState();
        }

        protected int tryAcquireShared(int acquires) {
            return (getState() == 0) ? 1 : -1;
        }

        protected boolean tryReleaseShared(int releases) {
            // Decrement count; signal when transition to zero
            for (;;) {
                int c = getState();
                if (c == 0)
                    return false;
                int nextc = c-1;
                if (compareAndSetState(c, nextc))
                    return nextc == 0;
            }
        }
    }

    private final Sync sync;

構造器

    public CountDownLatch(int count) {
        if (count < 0) throw new IllegalArgumentException("count < 0");
        this.sync = new Sync(count);
    }

構造器需要傳入一個大於等於0的數,這個數將作爲AQS的state的初始值。

核心方法

  1. countDown方法,每調用一次就將當前的計數器count減一,當count爲0時,喚醒所有阻塞在await方法處的線程。
  2. await方法。當count不爲0時,調用await的線程將阻塞。它有兩種版本:普通版本、帶超時的版本。兩個版本都響應超時。

CountDownLatch具體的講,依賴了AQS的共享鎖模式的sync queue

countDown()

//CountDownLatch
    public void countDown() {
        sync.releaseShared(1);
    }

countDown方法沒有參數,因爲每次調用只能將count減少1。

//AbstractQueuedSynchronizer
    public final boolean releaseShared(int arg) {
        if (tryReleaseShared(arg)) {  //返回值很重要,直接影響能否執行doReleaseShared
            doReleaseShared();
            return true;
        }
        return false;
    }

tryReleaseShared的返回值很重要,直接影響能否執行doReleaseShared。而doReleaseShared的邏輯就是“喚醒所有阻塞在await方法處的線程”。

//CountDownLatch
        protected boolean tryReleaseShared(int releases) {
            // Decrement count; signal when transition to zero
            for (;;) {
                int c = getState();
                if (c == 0)
                    return false;
                int nextc = c-1;
                if (compareAndSetState(c, nextc))
                    return nextc == 0;
            }
        }
  • 獲得當前AQS的state
    • 如果state是0,那麼不能再減了直接返回false,因爲state最多能減到0。
    • 如果state不是0,需要CAS設置state。
      • CAS操作成功了,返回nextc == 0
      • 當然CAS操作可能失敗,失敗了就需要再次循環執行CAS,因爲可能同時有多個線程在同時執行countDown
  • CAS操作成功返回的是nextc == 0nextc爲CAS設置成功後,state的新值。也就是說,只有那個將state從1設置爲0的線程,纔會返回true,其他調用countDown的線程都會返回false。

回到releaseShared的邏輯,只有tryReleaseShared(arg)返回了true,doReleaseShared()纔會執行,纔會去喚醒所有阻塞在await方法處的線程。關於共享鎖的流程之前已經講過,我們只需要知道doReleaseShared()會喚醒sync queue中的head後繼,而被喚醒的線程tryAcquireShared成功後在一定條件下也會去調用doReleaseShared()喚醒它的後繼,這樣可能會有多個線程同時執行doReleaseShared()。重點在於,喚醒線程的速度很快,幾乎可以算是同時進行的。

一直在說喚醒sync queue中的head後繼,那head後繼的代表線程到底阻塞在await()執行過程的哪裏了呢,帶着這一問題,我們進入下一章。

await()

首先它的效果和Condition接口的await()類似,一般調用之後線程就會陷入阻塞。而當count爲0時,線程從await()處被喚醒而繼續執行。從下面的函數聲明就可以看出,CountDownLatch的await()是響應中斷的。

public void await() throws InterruptedException {
    sync.acquireSharedInterruptibly(1);
}

public final void acquireSharedInterruptibly(int arg) throws InterruptedException {
    if (Thread.interrupted())  //上來先檢查一下,當前線程的中斷狀態
        throw new InterruptedException();
    if (tryAcquireShared(arg) < 0)  //如果Acquire失敗,就需要走循環阻塞的流程了
        doAcquireSharedInterruptibly(arg);
}

我們來看看CountDownLatch的AQS子類是怎麼實現tryAcquireShared的。

protected int tryAcquireShared(int acquires) {
    return (getState() == 0) ? 1 : -1;
}

發現有點欺騙觀衆的感覺,因爲傳入的參數acquires根本沒有使用,邏輯只是檢查AQS的state是否爲0。如果state爲0返回大於0的數,如果state不爲0返回小於0的數。

共享鎖的流程中我們提到過tryAcquireShared返回值的含義:

tryAcquireShared子類實現判斷 tryAcquireShared返回值 tryAcquireShared返回值含義 await流程
state爲0 > 0 獲取共享鎖成功,並且後續獲取也可能獲取成功 將返回,不阻塞
- = 0 獲取共享鎖成功,但後續獲取可能不會成功 將返回,不阻塞
state不爲0 < 0 獲取共享鎖失敗 將阻塞

所以,只要tryAcquireShared返回了 > 0的數,acquireSharedInterruptibly就直接返回了不會阻塞了,因爲此時state已經爲0了,說明已經調用了足夠次數的countDown了。如果tryAcquireShared返回了 < 0的數,acquireSharedInterruptibly就需要調用下面的doAcquireSharedInterruptibly,將當前線程包裝成node扔到suyc queue上去,走循環 搶鎖->阻塞 的流程了。

    private void doAcquireSharedInterruptibly(int arg)
        throws InterruptedException {  //不同之處1
        final Node node = addWaiter(Node.SHARED);
        boolean failed = true;
        try {
            /*boolean interrupted = false;*/    //不同之處2
            for (;;) {
                final Node p = node.predecessor();
                if (p == head) {
                    int r = tryAcquireShared(arg);
                    if (r >= 0) {
                        setHeadAndPropagate(node, r);
                        p.next = null;
                        /*if (interrupted)    //不同之處3
                            selfInterrupt();*/  
                        failed = false;
                        return;
                    }
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    throw new InterruptedException();  //不同之處4
            }
        } finally {
            if (failed)
                cancelAcquire(node);  //不同之處5
        }
    }

之前在共享鎖的流程中已經講過了doAcquireShared,而doAcquireSharedInterruptibly和它也很類似,所以我們看看不同之處就好。

  1. 函數聲明是拋出異常的,因爲現在的doAcquireSharedInterruptibly是響應中斷的版本。
  2. 此處在doAcquireSharedInterruptibly中被刪除掉了,因爲此函數在遇到中斷時就直接拋出異常,所以不需要局部變量來保存是否有過中斷的信息。
  3. 此處在doAcquireSharedInterruptibly中被刪除掉了,分析同上。被刪掉的此處在doAcquireShared中的作用是返回用戶代碼前設置一下中斷狀態。
  4. 線程阻塞在parkAndCheckInterrupt後,如果因爲中斷而被喚醒,parkAndCheckInterrupt會返回true,然後就直接拋出異常就好。
  5. 此處其實一模一樣。但是在doAcquireShared中此處永遠不可能執行到,在doAcquireSharedInterruptibly中如果因爲拋出異常而退出函數,則會執行此處。

回想之前提的問題,“head後繼的代表線程到底阻塞在await()執行過程的哪裏”,可以看到,線程在tryAcquireShared失敗後,一定會阻塞在parkAndCheckInterrupt這裏的。所有的調用CountDownLatch.await()阻塞的線程,都是阻塞在這裏的。

而當那個調用countDown從而將count從1變成0的線程執行完countDown後,會喚醒sync queue中的head後繼線程,已經說了線程是阻塞在parkAndCheckInterrupt這裏,所以head後繼線程會從parkAndCheckInterrupt處喚醒,然後繼續下一次循環,執行tryAcquireShared會返回>0的數(此時count已經爲0了),然後執行setHeadAndPropagate,在裏面然後又會執行doReleaseShared

現在好了,不僅調用countDown從而將count從1變成0的線程會執行doReleaseShared,調用await()的線程被喚醒後也會執行doReleaseShared,之後被喚醒的線程也會去執行doReleaseShared。這樣不斷喚醒,很快在sync queue上的所有線程都會被喚醒。之所以說它,是因爲每個嘗試獲取鎖(即tryAcquireShared動作)的線程,在獲取鎖完畢後並退出await()時就已經喚醒了自己的後繼,當然,這本來就是共享鎖獲取的流程。

await(long timeout, TimeUnit unit)

await(long timeout, TimeUnit unit)的版本提供了超時功能。

public boolean await(long timeout, TimeUnit unit) throws InterruptedException {
    return sync.tryAcquireSharedNanos(1, unit.toNanos(timeout));
}

public final boolean tryAcquireSharedNanos(int arg, long nanosTimeout) throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    return tryAcquireShared(arg) >= 0 || doAcquireSharedNanos(arg, nanosTimeout);
}

同樣的,如果tryAcquireShared返回了小於0的數,就只好調用doAcquireSharedNanos走循環 搶鎖->阻塞 的過程。但注意,上面兩個函數都是有返回值的。

await(long timeout, TimeUnit unit)返回值 返回值含義
true 在規定時間內,搶到了鎖 ==> 在規定時間內,count變成了0
false 在規定時間內,沒搶到鎖 ==> 在規定時間內,count沒變成0

很明顯,如果tryAcquireShared返回了大於等於0的數,就會直接返回true了;如果tryAcquireShared返回了小於0的數,具體返回的值就等於doAcquireSharedNanos(arg, nanosTimeout)的返回值了。

private boolean doAcquireSharedNanos(int arg, long nanosTimeout) throws InterruptedException {  //不同之處1
    if (nanosTimeout <= 0L)  //不同之處2
        return false;
    final long deadline = System.nanoTime() + nanosTimeout;  //不同之處3
    final Node node = addWaiter(Node.SHARED);
    boolean failed = true;
    try {
        for (;;) {
            final Node p = node.predecessor();
            if (p == head) {
                int r = tryAcquireShared(arg);
                if (r >= 0) {
                    setHeadAndPropagate(node, r);
                    p.next = null; // help GC
                    failed = false;
                    return true;
                }
            }
            nanosTimeout = deadline - System.nanoTime();  //不同之處4
            if (nanosTimeout <= 0L)  //不同之處5
                return false;
            if (shouldParkAfterFailedAcquire(p, node) &&
                nanosTimeout > spinForTimeoutThreshold)  //不同之處6
                LockSupport.parkNanos(this, nanosTimeout);  //不同之處7
            if (Thread.interrupted())  //不同之處8
                throw new InterruptedException();
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

直接看看doAcquireSharedNanos(int arg, long nanosTimeout)doAcquireSharedInterruptibly(int arg)的不同之處吧。

  1. 返回值是boolean的,含義解釋過了。
  2. 如果發現傳來的時間段是小於0的,也就說明用戶想要等待0的時間(反過來說,就是不需要等待),也就不用進入下面的循環了。進入doAcquireSharedNanos後還沒有嘗試搶過鎖,所以返回false。
  3. 計算出等待的截止時間。
  4. 計算出離截止時間還有多久。
  5. 如果發現當前時間已經超過了截止時間,則直接返回false,不用再去搶鎖了。
  6. 雖然沒超過截止時間,但離截止時間已經很近了,就不要再阻塞了,直接自旋就好了。因爲考慮到阻塞喚醒,時間太短反而無法控制。
  7. 超時版本需要調用LockSupport.parkNanos
  8. 因爲從上面的LockSupport.parkNanos處被中斷喚醒時,不會帶有信息(LockSupport.parkNanos沒有返回值),所以需要檢測一下中斷狀態。

簡單的說,doAcquireSharedNanos(int arg, long nanosTimeout)正常返回時,有兩種情況:因爲獲得鎖而返回true,因爲超時還沒獲得鎖而返回false。

對比兩個await方法返回時的情況

返回原因 等到了count變成0 count爲0的任何時候,(超時前)中斷來臨 超時還沒等到count爲0
await() 只要是正常返回的 拋出中斷異常 -
await(long timeout, TimeUnit unit) 正常返回的,且返回值爲true 拋出中斷異常 正常返回的,且返回值爲false

兩個await方法,我們都可以從返回的情況,就可以知道返回的原因。

分析兩種線程

現將await()await(long timeout, TimeUnit unit)都統稱爲await方法,那麼關於CountDownLatch有兩種線程:

  • 調用countDown的線程,肯定不會阻塞。
  • 調用await的線程,會阻塞。

對於CountDownLatch的使用者來說,是這樣的使用場景:需要當某個條件滿足後,才讓某些任務從同一個起跑線開始執行,而“滿足條件”則被量化爲一個數字。

  • 當更加接近這個條件時,讓第一類線程調用countDown,也可以調用多次。
  • 需要放到同一個起跑線的線程任務,則調用await。它們將幾乎同時被喚醒。

當然,這也不是絕對的,完全可以一個線程同時充當兩個角色,那麼它將先調用countDown,再調用await。如果每個線程都這樣使用,那麼使用CountDownLatch就相當於使用了一個一次性的CyclicBarrier。

總結

  • CountDownLatch的使用場景:當某個量化爲數字的條件被滿足後,幾個線程任務纔可以同時開始執行。
  • 調用CountDownLatch#await的線程將等待條件被滿足,條件滿足後,調用CountDownLatch#await的若干線程將從同一個時間點繼續執行。
  • 調用CountDownLatch#countDown,讓量化爲數字的條件減一。
  • 調用CountDownLatch#await的線程,和調用CountDownLatch#countDown的線程,是兩類線程,並無關係。
  • 喚醒阻塞在await的若干線程的過程很快,這是由於共享鎖的特性導致:共享鎖獲取成功時,也會喚醒sync queue的後繼節點線程,但獨佔鎖可不這樣。
  • CountDownLatch依賴了共享鎖模式節點的sync queue
  • CountDownLatch是一次性的,count爲0無法改變。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章