并發編程之CountDownLatch原理與應用

點贊再看,養成習慣,搜一搜【一角錢技術】關注更多原創技術文章。本文 GitHub org_hejianhui/JavaStudy 已收錄,有我的系列文章。

前言

控制并發流程的工具類,作用就是幫助我們程序員更容易的讓線程之間合作,讓線程之間相互配合來滿足業務邏輯。比如讓線程A等待線程B執行完畢后再執行等合作策略。

控制并發流程的工具類主要有:

作用 說明
Semaphore 信號量,可以通過控制“許可證”的數量,來保證線程之間的配合 線程只有拿到“許可證”后才能繼續運行,相比于其它的同步器,更靈活
CyclicBarrier 線程會等待,直到足夠多線程達到了事先規定的數目。一旦達到觸發條件,就可以進行下一步的動作 適用于線程之間相互等待處理結果的就緒場景
Phaser 和CyclicBarrier類似,但是計數可變 Java7加入的
CountDownLatch 和CyclicBarrier類似,數量遞減到0時,觸發動作 不可重復使用
Exchanger 讓兩個線程在合適時交換對象 適用場景:當兩個線程工作在同一個類的不同實例上時,用于交換數據
Condition 可以控制線程的“等待”和“喚醒” 是Object.wait() 的升級版

簡介

背景

  • CountDownLatch是在Java1.5被引入,跟它一起被引入的工具類還有CyclicBarrier、Semaphore、ConcurrenthashMap和BlockingQueue。
  • 在java.util.cucurrent包下。

概念

  • CountDownLatch是一個同步計數器,他允許一個或者多個線程在另外一組線程執行完成之前一直等待,基于AQS共享模式實現的。
  • 是通過一個計數器來實現的,計數器的初始值是線程的數量。每當一個線程執行完畢后,計數器的值就-1,當計數器的值為0時,表示所有線程都執行完畢,然后在閉鎖上等待的線程就可以恢復工作來。

關于 AQS,可以查看《并發編程之抽象隊列同步器AQS應用ReentrantLock》

應用場景

Zookeeper分布式鎖,Jmeter模擬高并發等

場景1 讓多個線程等待:模擬并發,讓并發線程一起執行

為了模擬高并發,讓一組線程在指定時刻(秒殺時間)執行搶購,這些線程在準備就緒后,進行等待(CountDownLatch.await()),直到秒殺時刻的到來,然后一擁而上。這也是本地測試接口并發的一個簡易實現。

在這個場景中,CountDownLatch充當的是一個發令槍的角色;就像田徑賽跑時,運動員會在起跑線做準備動作,等到發令槍一聲響,運動員就會奮力奔跑。和上面的秒殺場景類似。

代碼實現如下

package com.niuh.tools;

import java.util.concurrent.CountDownLatch;

/**
 * <p>
 * CountDownLatch示例
 * 場景1 讓多個線程等待:模擬并發,讓并發線程一起執行
 * </p>
 */
public class CountDownLatchRunner1 {

    public static void main(String[] args) throws InterruptedException {
        CountDownLatch countDownLatch = new CountDownLatch(1);
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                try {
                    //準備完畢……運動員都阻塞在這,等待號令
                    countDownLatch.await();
                    String parter = "【" + Thread.currentThread().getName() + "】";
                    System.out.println(parter + "開始執行……");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();
        }

        Thread.sleep(2000);// 裁判準備發令
        countDownLatch.countDown();// 發令槍:執行發令
    }
}

運行結果

【Thread-2】開始執行……
【Thread-4】開始執行……
【Thread-3】開始執行……
【Thread-0】開始執行……
【Thread-1】開始執行……

我們通過CountDownLatch.await(),讓多個參與者線程啟動后阻塞等待,然后在主線程 調用CountDownLatch.countdown(1) 將計數減為0,讓所有線程一起往下執行;以此實現了多個線程在同一時刻并發執行,來模擬并發請求的目的。

場景2 讓單個線程等待:多個線程(任務)完成后,進行匯總合并

很多時候,我們的并發任務,存在前后依賴關系;比如數據詳情頁需要同時調用多個接口獲取數據,并發請求獲取到數據后、需要進行結果合并;或者多個數據操作完成后,需要數據check;這其實都是:在多個線程(任務)完成后,進行匯總合并的場景。

代碼實現如下

package com.niuh.tools;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ThreadLocalRandom;

/**
 * <p>
 * CountDownLatch示例
 * 場景2 讓單個線程等待:多個線程(任務)完成后,進行匯總合并
 * </p>
 */
public class CountDownLatchRunner2 {

    public static void main(String[] args) throws InterruptedException {
        CountDownLatch countDownLatch = new CountDownLatch(5);
        for (int i = 0; i < 5; i++) {
            final int index = i;
            new Thread(() -> {
                try {
                    Thread.sleep(1000 + ThreadLocalRandom.current().nextInt(1000));
                    System.out.println("finish" + index + Thread.currentThread().getName());
                    countDownLatch.countDown();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();
        }

        countDownLatch.await();// 主線程在阻塞,當計數器==0,就喚醒主線程往下執行。
        System.out.println("主線程:在所有任務運行完成后,進行結果匯總");
    }
}

運行結果

finish3Thread-3
finish0Thread-0
finish1Thread-1
finish4Thread-4
finish2Thread-2
主線程:在所有任務運行完成后,進行結果匯總

在每個線程(任務) 完成的最后一行加上CountDownLatch.countDown(),讓計數器-1;當所有線程完成-1,計數器減到0后,主線程往下執行匯總任務。

源碼分析

本文基于JDK1.8

CountDownLatch 類圖



從圖中可以看出CountDownLatch是基于Sync類實現的,而Sync繼承AQS,使用的是AQS共享模式。

其內部主要變量和方法如下:


在我們方法中調用 awit()countDown() 的時候,發生了幾個關鍵的調用關系,如下圖所示:

其與AQS交互原理如下:


構造函數

CountDownLatch類中只提供了一個構造器,參數count為計數器的大小

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

這里需要注意,設置state的數量只有在初始化CountDownLatch的時候,如果該state被減成了0,就無法繼續使用這個CountDownLatch了,需要重新new一個,這就是這個類不可重用的原因,有另一個類也實現了類似的功能,但是可以重用,就是CyclicBarrier。

內部同步器

private static final class Sync extends AbstractQueuedSynchronizer {
    private static final long serialVersionUID = 4982264981922014374L;
   //初始化,設置資源個數
    Sync(int count) {
        setState(count);
    }
    //獲取共享資源個數
    int getCount() {
        return getState();
    } 
    //嘗試獲取共享鎖,只有當共享資源個數為0的時候,才會返回1,否則為-1
    protected int tryAcquireShared(int acquires) {
        return (getState() == 0) ? 1 : -1;
    }
    //釋放共享資源,通過CAS每次對state減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;
        }
    }
}

主要方法

類中有三個方法是最重要的

// 調用await()方法的線程會被掛起,它會等待直到count值為0才繼續執行
public void await() throws InterruptedException {
    sync.acquireSharedInterruptibly(1);
}

//和await()方法類似,只不過等待一定的時間后count值還沒變為0的化就會繼續執行
public boolean await(long timeout, TimeUnit unit)
    throws InterruptedException {
    return sync.tryAcquireSharedNanos(1, unit.toNanos(timeout));
}
//將count值減1
public void countDown() {
    sync.releaseShared(1);
}

await()方法

// 調用await()方法的線程會被掛起,它會等待直到count值為0才繼續執行
public void await() throws InterruptedException {
    sync.acquireSharedInterruptibly(1);
}

進入AbstractQueuedSynchronizer #acquireSharedInterruptibly()方法.

public final void acquireSharedInterruptibly(int arg)
        throws InterruptedException {
    //等待過程不可中斷
    if (Thread.interrupted())
        throw new InterruptedException();
    //這里的tryAcquireShared在AbstractQueuedSynchronizer中沒有實現,在上面介紹的Sync中實現的
    if (tryAcquireShared(arg) < 0)
        doAcquireSharedInterruptibly(arg);
}

在上面介紹Sync類的時候#tryAcquireShared(),當AQS的state = 0的時候才會返回1,否則一直返回-1,如果返回-1,要執行#doAcquireSharedInterruptibly(),進入該方法

private void doAcquireSharedInterruptibly(int arg)
    throws InterruptedException {
    //這里就把主線程加入隊列,隊列中有兩個節點,第一個是虛擬節點,第二個就是主線程節點
    final Node node = addWaiter(Node.SHARED);
    boolean failed = true;
    try {
        for (;;) {
            //總共只有兩個節點,主線程前一個就是首節點
            final Node p = node.predecessor();
            if (p == head) {
                //這里又執行到CountDownLatch中Sync類中實現的方法,判斷state是否為0
                int r = tryAcquireShared(arg);
                if (r >= 0) {
                    setHeadAndPropagate(node, r);
                    p.next = null; // help GC
                    failed = false;
                    return;
                }
            }
            //如果state不為0,這里會把主線程掛起阻塞
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                throw new InterruptedException();
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

這里使用AQS很神奇,在阻塞隊列中就只加入了一個主線程,但是呢,只要其他線程沒有執行完,那state就不為0,那主線程就在這里阻塞著,那問題了,誰來喚醒這個主線程呢?就是 countDown() 這個方法。

await(long timeout, TimeUnit unit)方法

該方法就是指定等待時間,如果在規定的等待時間中沒有完成,就直接返回false,在主線程中可以根據這個狀態進行后續的處理。

//和await()方法類似,只不過等待一定的時間后count值還沒變為0的化就會繼續執行
public boolean await(long timeout, TimeUnit unit)
    throws InterruptedException {
    return sync.tryAcquireSharedNanos(1, unit.toNanos(timeout));
}

countDown() 方法

//將count值減1
public void countDown() {
    sync.releaseShared(1);
}

進入AbstractQueuedSynchronizer #releaseShared方法

public final boolean releaseShared(int arg) {
    //該方法同樣在AbstractQueuedSynchronizer中沒有實現,在CountDownLatch中實現
    if (tryReleaseShared(arg)) {
        //喚醒主線程
        doReleaseShared();
        return true;
    }
    return false;
}

在分析Sync類的時候,介紹了tryReleaseShared(),該方法會把AQS的state減1,如果減1操作成功,執行喚醒主線程操作,進入AbstractQueuedSynchronizer#tryReleaseShared()方法

private void doReleaseShared() {
    for (;;) {
        Node h = head;
        if (h != null && h != tail) {
            int ws = h.waitStatus;
            //首節點狀態為SIGNAL = -1
            if (ws == Node.SIGNAL) {
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    continue;            // loop to recheck cases
           
                //喚醒主線程,也就是隊列中的第二個節點,如果線程沒有執行完成,主線程被喚醒之后,發現state依然不為零,會再次阻塞
                unparkSuccessor(h);
            }
            else if (ws == 0 &&
                     !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                continue;                // loop on failed CAS
        }
    if (h == head)                   // loop if head changed
            break;
    }
}

總結

CountDownLatch 和 Semaphore 一樣都是共享模式下資源問題,這些源碼實現AQS的模版方法,然后使用CAS+循環重試實現自己的功能。在RT多個資源調用,或者執行某種操作依賴其他操作完成下可以發揮這個計數器的作用。

CountDownLatch就只在隊列中放入一個主線程,然后不停的喚醒,喚醒之后發現state還是不為0,就繼續等待。每個子線程執行完都會對state進行減1操作,當所有子線程都執行完了,那state也就為0,這時候主線程被喚醒之后才可以繼續執行。而這也正是CountDownLatch不可重用的原因,如果想要重用,需要重新new一個,因為只有在new的時候才可以設置資源的數量。

CountDownLatch與Thread.join

CountDownLatch的作用就是允許一個或多個線程等待其他線程完成操作,看起來有點類似join() 方法,但其提供了比 join() 更加靈活的API。

CountDownLatch可以手動控制在n個線程里調用n次countDown()方法使計數器進行減一操作,也可以在一個線程里調用n次執行減一操作。

而 join() 的實現原理是不停檢查join線程是否存活,如果 join 線程存活則讓當前線程永遠等待。所以兩者之間相對來說還是CountDownLatch使用起來較為靈活。

CountDownLatch與CyclicBarrier

CountDownLatch和CyclicBarrier都能夠實現線程之間的等待,只不過它們側重點不同:

  • CountDownLatch一般用于一個或多個線程,等待其他線程執行完任務后,再才執行;
  • CyclicBarrier一般用于一組線程互相等待至某個狀態,然后這一組線程再同時執行;
  • CountDownLatch 是一次性的,CyclicBarrier 是可循環利用的;
  • CountDownLathch是一個計數器,線程完成一個記錄一個,計數器遞減,只能用一次。如下圖:
  • CyclicBarrier的計數器更像一個閥門,需要所有線程都到達,然后繼續執行,計數器遞減,提供reset功能,可以多次使用。如下圖:

PS:以上代碼提交在 Githubhttps://github.com/Niuh-Study/niuh-juc-final.git

文章持續更新,可以搜一搜「 一角錢技術 」第一時間閱讀, 本文 GitHub org_hejianhui/JavaStudy 已經收錄,歡迎 Star。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容