併發程式設計之 CountDown 原始碼分析

莫那·魯道發表於2018-04-30

前言

Doug Lea 大神在 JUC 包中為我們準備了大量的多執行緒工具,其中包括 CountDownLatch ,名為倒數計時門栓,好像不太好理解。不過,今天的文章之後,我們就徹底理解了。

如何使用?

在 JDK 的文件中,帶有 2 個例子,我們使用其中一個,測試程式碼如下:

class Driver2 {

  public static void main(String[] args) throws InterruptedException {
    CountDownLatch doneSignal = new CountDownLatch(10);
    Executor e = Executors.newFixedThreadPool(10);
    for (int i = 0; i < 10; ++i) {
      e.execute(new WorkerRunnable(doneSignal, i));
    }

    doneSignal.await();           // wait for all to finish
    System.err.println("work");
  }
}

class WorkerRunnable implements Runnable {

  private final CountDownLatch doneSignal;
  private final int i;

  WorkerRunnable(CountDownLatch doneSignal, int i) {
    this.doneSignal = doneSignal;
    this.i = i;
  }

  public void run() {
    doWork(i);
    doneSignal.countDown();
  }

  void doWork(int i) {
    System.out.println("work");
  }
}
複製程式碼

上面的程式碼中,我們建立了 1 個 CountDowmLatch 物件,在主執行緒和另外 10 個執行緒中使用,主執行緒呼叫了他的 await 方法,子執行緒呼叫了 countDown 方法。

最後輸出結果如下:

image.png

大部分時候,你會得到上面的結果,這是正常的情況,但也可能你會得到下面的結果:

image.png

這看起來不正常。因為我們需要的結果是:主執行緒最後列印。什麼原因導致的呢?其實是由於時間太快,控制檯列印的順序和實際順序不同,我們可以在後面加個納秒引數,就能夠看出來了。

image.png

從納秒數就能夠看出來,主執行緒是最後執行的。

通過一幅圖看看這個 demo 的整體執行順序。

image.png

圖中,主執行緒會先執行 await 方法,這個方法會掛起當前執行緒,相當於 wait 方法。而子執行緒會陸續執行任務,並執行 countDown 方法,countDown 方法每次執行都會將計數器減 1, 當計數器變成 0 的時候,就會喚醒主執行緒,主執行緒開始執行自己的任務。不知道這個圖畫的是否明顯,但樓主盡力了。。。。

好了,知道了如何使用,就來看看原始碼實現吧。

原始碼實現

首先看看這個類的結構:

image.png

該類是一個獨立的類,沒有繼承別的類,有一個內部類 Sync,這個類繼承了 AQS 抽象類,其實,在之前的文章中,我們說過,AQS 是 JUC 所有鎖的實現,定義了鎖的基本操作。這個內部類重寫了 tryAcquireShared 方法和 tryReleaseShared 方法。

然後呢?我們看看構造方法。

構造方法:
public CountDownLatch(int count) {
    if (count < 0) throw new IllegalArgumentException("count < 0");
    this.sync = new Sync(count);
}
複製程式碼

內部實現還是繼承了 AQS 的 Sync 類。

Sync 構造方法:

Sync(int count) {
    setState(count);
}
protected final void setState(int newState) {
    state = newState;
}
/**
 * The synchronization state.
 */
private volatile int state;
複製程式碼

設定了這個 State 變數,我們之前分析過 AQS 的原始碼,這個變數可以說是 AQS 實現的核心,通過控制這個變數,能夠實現共享共享鎖或者獨佔鎖。

那麼,如果讓我們來設計這個CountDownLatch ,我們該如何設計呢?

事實上,很簡單,我們只需要對 state 變數進行減 1 操作,直到這個變數變成 0,我們就喚醒主執行緒。

不知道 Doug Lea 是不是這麼設計的?我們去看看。

await 方法

主執行緒會呼叫這個方法,讓自己阻塞,直到被喚醒。

看看這個方法的實現:

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);
}
複製程式碼

await 方法呼叫的是 Sync 的 tryAcquireSharedNanos 方法,方法也貼在上面了。該方法會先呼叫 tryAcquireShared 方法,如果返回值不是大於等於 0 ,說明當前執行緒不能獲取鎖,那麼就呼叫 doAcquireSharedNanos 方法。這個方法內部會將當前執行緒掛起,直到 state 變成 0,才會被喚醒。

而 tryAcquireShared 方法是需要子類自己實現的。我們看看 CountDown 是如何實現的:

protected int tryAcquireShared(int acquires) {
    return (getState() == 0) ? 1 : -1;
}
複製程式碼

很簡單,就是獲取 state 變數,也就是構造方法中設定的引數。

doAcquireSharedNanos 方法的是如何將當前執行緒掛起的呢?

程式碼如下:

private void doAcquireSharedInterruptibly(int arg)
    throws InterruptedException {
    // 建立一個 node 物件,物件中有個屬性就是當前執行緒物件。並將這個 node 新增進佇列尾部。
    final Node node = addWaiter(Node.SHARED);
    // 中斷失敗標記
    boolean failed = true;
    try {
        for (;;) {
            // 找到這個 node 的上一個節點
            final Node p = node.predecessor();
            // 如果上一個節點是 head,說明他前面已經沒有執行緒阻擋他獲取鎖了。
            if (p == head) {
                // 獲取鎖的狀態
                int r = tryAcquireShared(arg);
                // 如果大於等於0,說明可以獲取鎖
                if (r >= 0) {
                    // 將包裝當前執行緒的 node 設定為 head.
                    setHeadAndPropagate(node, r);
                    // 設定他的 next 是 null,讓 GC 回收
                    p.next = null; // help GC
                    // 沒有發生錯誤,不必執行下面的取消操作
                    failed = false;
                    return;
                }
            }
            // 如果他的前面的節點的狀態時 -1,那麼當前執行緒就需要等待。
            // 呼叫 parkAndCheckInterrupt 等待,如果等待過程中被中斷了,丟擲異常
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                throw new InterruptedException();
        }
    } finally {
        if (failed)  
            // 如果發生了中斷異常,則取消獲取鎖。
            cancelAcquire(node);
    }
}
複製程式碼

上面的程式碼寫了很多註釋,總的來說,邏輯如下:

  1. 將當前執行緒包裝成一個 Node 物件,加入到 AQS 的佇列尾部。
  2. 如果他前面的 node 是 head ,便可以嘗試獲取鎖了。
  3. 如果不是,則阻塞等待,呼叫的是 LockSupport.park(this);

CountDown 的 await 方法就是通過 AQS 的鎖機制讓主執行緒阻塞等待。而鎖的實現就是通過構造器中設定的 state 變數來控制的。當 state 是 0 的時候,就可以獲取鎖。然後執行後面的邏輯。

知道了 await 方法,CountDown 方法應該能猜個大概了。

countDown 方法

程式碼如下:

public void countDown() {
    sync.releaseShared(1);
}
public final boolean releaseShared(int arg) {
    if (tryReleaseShared(arg)) {
        doReleaseShared();
        return true;
    }
    return false;
}
複製程式碼

呼叫了 Sync 的 releaseShared 方法,也就是父類 AQS 的方法,AQS 需要子類實現 tryReleaseShared 方法。看看 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;
    }
}
複製程式碼

該方法很簡單,就是將 state 變數減 1,只要減過之後, state 不是 0,就返回 fasle。

回到 releaseShared 方法中,當 tryReleaseShared 返回值是 true 時,也就是 state 是 0,就需要執行 doReleaseShared 方法 ,喚醒阻塞在 CountDown 上的執行緒了。

喚醒程式碼如下:

private void doReleaseShared() {

    for (;;) {
        Node h = head;
        if (h != null && h != tail) {
            int ws = h.waitStatus;
            if (ws == Node.SIGNAL) {
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    continue;            // loop to recheck cases
                unparkSuccessor(h);
            }
            else if (ws == 0 &&
                     !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                continue;                // loop on failed CAS
        }
        if (h == head)                   // loop if head changed
            break;
    }
}
複製程式碼

只要佇列中 head 節點不是 null,且和 tail 不相等,並且狀態是 -1,使用 CAS 將狀態修改成 0,如果成功,喚醒當前執行緒。當前執行緒就會在 doAcquireSharedInterruptibly 方法中甦醒,再次嘗試獲取鎖,只要他的上一個節點是 head,也就是沒有人和他爭搶鎖,並且 state 是 0,就能夠成功獲取到鎖,繼續執行下面的邏輯,不再繼續阻塞。

而我們 CountDownLatch 的主執行緒也就可以被喚醒從而繼續執行了。

總結

總的來說,CountDownLatch 還是比較簡單的。說白了就是通過共享鎖實現的。在我們的程式碼中,只有一個執行緒會阻塞,那就是我們的主執行緒, 其餘的執行緒就是在不停的釋放 state 變數,直到為 0。從 AQS 的角度來講,整個工作流程如下圖:

image.png

簡單的一個流程圖,CountDownLatch 就是通過使用 AQS 的機制來實現倒數計時門栓的。

good luck!!!!

相關文章