多執行緒系列(十五) -常用併發工具類詳解

程序员志哥發表於2024-03-07

一、摘要

在前幾篇文章中,我們講到了執行緒、執行緒池、BlockingQueue 等核心元件,其實 JDK 給開發者還提供了比synchronized更加高階的執行緒同步元件,比如 CountDownLatch、CyclicBarrier、Semaphore、Exchanger 等併發工具類。

下面我們一起來了解一下這些常用的併發工具類!

二、常用併發工具類

2.1、CountDownLatch

CountDownLatch是 JDK5 之後加入的一種併發流程控制工具類,它允許一個或多個執行緒一直等待,直到其他執行緒執行完成後再執行。

它的工作原理主要是透過一個計數器來實現,初始化的時候需要指定執行緒的數量;每當一個執行緒完成了自己的任務,計數器的值就相應得減 1;當計數器到達 0 時,表示所有的執行緒都已經執行完畢,處於等待的執行緒就可以恢復繼續執行任務。

根據CountDownLatch的工作原理,它的應用場景一般可以劃分為兩種:

  • 場景一:某個執行緒需要在其他 n 個執行緒執行完畢後,再繼續執行
  • 場景二:多個工作執行緒等待某個執行緒的命令,同時執行同一個任務

下面我們先來看下兩個簡單的示例。

示例1:某個執行緒等待 n 個工作執行緒

比如某項任務,先採用多執行緒去執行,最後需要在主執行緒中進行彙總處理,這個時候CountDownLatch就可以發揮作用了,具體應用如下!

public class CountDownLatchTest {

    public static void main(String[] args) throws InterruptedException {
        // 採用 10 個工作執行緒去執行任務
        final int threadCount = 10;
        CountDownLatch countDownLatch = new CountDownLatch(threadCount);
        for (int i = 0; i < threadCount; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    // 執行具體任務
                    System.out.println("thread name:" +  Thread.currentThread().getName() + ",執行完畢!");
                    // 計數器減 1
                    countDownLatch.countDown();
                }
            }).start();
        }

        // 阻塞等待 10 個工作執行緒執行完畢
        countDownLatch.await();
        System.out.println("所有任務執行緒已執行完畢,準備進行結果彙總");
    }
}

執行結果如下:

thread name:Thread-0,執行完畢!
thread name:Thread-2,執行完畢!
thread name:Thread-1,執行完畢!
thread name:Thread-3,執行完畢!
thread name:Thread-4,執行完畢!
thread name:Thread-5,執行完畢!
thread name:Thread-6,執行完畢!
thread name:Thread-7,執行完畢!
thread name:Thread-8,執行完畢!
thread name:Thread-9,執行完畢!
所有任務執行緒執行完畢,準備進行結果彙總
示例2:n 個工作執行緒等待某個執行緒

比如田徑賽跑,10 個同學準備開跑,但是需要等工作人員發出槍聲才允許開跑,使用CountDownLatch可以實現這一功能,具體應用如下!

public class CountDownLatchTest {

    public static void main(String[] args) throws InterruptedException {
        // 使用一個計數器
        CountDownLatch countDownLatch = new CountDownLatch(1);
        final int threadCount = 10;
        // 採用 10 個工作執行緒去執行任務
        for (int i = 0; i < threadCount; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        // 阻塞等待計數器為 0
                        countDownLatch.await();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    // 發起某個服務請求,省略
                    System.out.println("thread name:" +  Thread.currentThread().getName() + ",開始執行!");

                }
            }).start();
        }

        Thread.sleep(1000);
        System.out.println("thread name:" +  Thread.currentThread().getName() + " 準備開始!");
        // 將計數器減 1,執行完成後為 0
        countDownLatch.countDown();
    }
}

執行結果如下:

thread name:main 準備開始!
thread name:Thread-0,開始執行!
thread name:Thread-1,開始執行!
thread name:Thread-2,開始執行!
thread name:Thread-3,開始執行!
thread name:Thread-5,開始執行!
thread name:Thread-6,開始執行!
thread name:Thread-8,開始執行!
thread name:Thread-7,開始執行!
thread name:Thread-4,開始執行!
thread name:Thread-9,開始執行!

從上面的示例可以很清晰的看到,CountDownLatch類似於一個倒計數器,當計數器為 0 的時候,呼叫await()方法的執行緒會被解除等待狀態,然後繼續執行。

CountDownLatch類的主要方法,有以下幾個:

  • public CountDownLatch(int count):核心構造方法,初始化的時候需要指定執行緒數
  • countDown():每呼叫一次,計數器值 -1,直到 count 被減為 0,表示所有執行緒全部執行完畢
  • await():等待計數器變為 0,即等待所有非同步執行緒執行完畢,否則一直阻塞
  • await(long timeout, TimeUnit unit):支援指定時間內的等待,避免永久阻塞,await()的一個過載方法

從以上的分析可以得出,當計數器為 1 的時候,即由一個執行緒來通知其他執行緒,效果等同於物件的wait()notifyAll();當計時器大於 1 的時候,可以實現多個工作執行緒完成任務後通知一個或者多個等待執行緒繼續工作,CountDownLatch可以看成是一種進階版的等待/通知機制,在實際中應用比較多見。

2.2、CyclicBarrier

CyclicBarrier從字面上很容易理解,表示可迴圈使用的屏障,它真正的作用是讓一組執行緒到達一個屏障時被阻塞,直到滿足要求的執行緒數都到達屏障時,屏障才會解除,此時所有被屏障阻塞的執行緒就可以繼續執行。

下面我們還是先看一個簡單的示例,以便於更好的理解這個工具類。

public class CyclicBarrierTest {

    public static void main(String[] args) {
        // 設定參與執行緒的個數為 5
        int threadCount = 5;
        CyclicBarrier cyclicBarrier = new CyclicBarrier(threadCount, new Runnable() {
            @Override
            public void run() {
                System.out.println("所有的執行緒都已經準備就緒...");
            }
        });
        for (int i = 0; i < threadCount; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    System.out.println("thread name:" +  Thread.currentThread().getName() + ",已達到屏障!");
                    try {
                        cyclicBarrier.await();
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                    System.out.println("thread name:" +  Thread.currentThread().getName() + ",阻塞解除,繼續執行!");
                }
            }).start();
        }
    }
}

輸出結果:

thread name:Thread-0,已達到屏障!
thread name:Thread-1,已達到屏障!
thread name:Thread-2,已達到屏障!
thread name:Thread-3,已達到屏障!
thread name:Thread-4,已達到屏障!
所有的執行緒都已經準備就緒...
thread name:Thread-4,阻塞解除,繼續執行!
thread name:Thread-0,阻塞解除,繼續執行!
thread name:Thread-3,阻塞解除,繼續執行!
thread name:Thread-1,阻塞解除,繼續執行!
thread name:Thread-2,阻塞解除,繼續執行!

從上面的示例可以很清晰的看到,CyclicBarrier中設定的執行緒數相當於一個屏障,當所有的執行緒數達到時,此時屏障就會解除,執行緒繼續執行剩下的邏輯。

CyclicBarrier類的主要方法,有以下幾個:

  • public CyclicBarrier(int parties):構造方法,parties參數列示參與執行緒的個數
  • public CyclicBarrier(int parties, Runnable barrierAction):核心構造方法,barrierAction參數列示執行緒到達屏障時的回撥方法
  • public void await():核心方法,每個執行緒呼叫await()方法告訴CyclicBarrier我已經到達了屏障,然後當前執行緒被阻塞,直到屏障解除,繼續執行剩下的邏輯

從以上的示例中,可以看到CyclicBarrierCountDownLatch有很多的相似之處,都能夠實現執行緒之間的等待,但是它們的側重點不同:

  • CountDownLatch一般用於一個或多個執行緒,等待其他的執行緒執行完任務後再執行
  • CyclicBarrier一般用於一組執行緒等待至某個狀態,當狀態解除之後,這一組執行緒再繼續執行
  • CyclicBarrier中的計數器可以反覆使用,而CountDownLatch用完之後只能重新初始化

2.3、Semaphore

Semaphore通常我們把它稱之為訊號計數器,它可以保證同一時刻最多有 N 個執行緒能訪問某個資源,比如同一時刻最多允許 10 個使用者訪問某個服務,同一時刻最多建立 100 個資料庫連線等等。

Semaphore可以用於控制併發的執行緒數,實際應用場景非常的廣,比如流量控制、服務限流等等。

下面我們看一個簡單的示例。

public class SemaphoreTest {

    public static void main(String[] args) {
        SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

        // 同一時刻僅允許最多3個執行緒獲取許可
        final Semaphore semaphore = new Semaphore(3);
        // 初始化 5 個執行緒生成
        for (int i = 0; i < 5; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        // 如果超過了許可數量,其他執行緒將在此等待
                        semaphore.acquire();
                        System.out.println(format.format(new Date()) +  " thread name:" +  Thread.currentThread().getName() + " 獲取許可,開始執行任務");
                        // 假設執行某項任務的耗時
                        Thread.sleep(2000);
                    } catch (Exception e) {
                        e.printStackTrace();
                    } finally {
                        // 使用完後釋放許可
                        semaphore.release();
                    }
                }
            }).start();
        }
    }
}

輸出結果:

2023-11-22 17:32:01 thread name:Thread-0 獲取許可,開始執行任務
2023-11-22 17:32:01 thread name:Thread-1 獲取許可,開始執行任務
2023-11-22 17:32:01 thread name:Thread-2 獲取許可,開始執行任務
2023-11-22 17:32:03 thread name:Thread-4 獲取許可,開始執行任務
2023-11-22 17:32:03 thread name:Thread-3 獲取許可,開始執行任務

從上面的示例可以很清晰的看到,同一時刻前 3 個執行緒獲得了許可優先執行, 2 秒過後許可被釋放,剩下的 2 個執行緒獲取釋放的許可繼續執行。

Semaphore類的主要方法,有以下幾個:

  • public Semaphore(int permits):構造方法,permits參數列示同一時間能訪問某個資源的執行緒數量
  • acquire():獲取一個許可,在獲取到許可之前或者被其他執行緒呼叫中斷之前,執行緒將一直處於阻塞狀態
  • tryAcquire(long timeout, TimeUnit unit):表示在指定時間內嘗試獲取一個許可,如果獲取成功,返回true;反之false
  • release():釋放一個許可,同時喚醒一個獲取許可不成功的阻塞執行緒。

透過permits引數的設定,可以實現限制多個執行緒同時訪問服務的效果,當permits引數為 1 的時候,表示同一時刻只有一個執行緒能訪問服務,相當於一個互斥鎖,效果等同於synchronized

使用Semaphore的時候,通常需要先呼叫acquire()或者tryAcquire()獲取許可,然後透過try ... finally模組在finally中釋放許可。

例如如下方式,嘗試在 3 秒內獲取許可,如果沒有獲取就退出,防止程式一直阻塞。

// 嘗試 3 秒內獲取許可
if(semaphore.tryAcquire(3, TimeUnit.SECONDS)){
    try {
       // ...業務邏輯
    }  finally {
        // 釋放許可
        semaphore.release();
    }
}

2.4、Exchanger

Exchanger從字面上很容易理解表示交換,它主要用途在兩個執行緒之間進行資料交換,注意也只能在兩個執行緒之間進行資料交換。

Exchanger提供了一個exchange()同步交換方法,當兩個執行緒呼叫exchange()方法時,無論呼叫時間先後,會互相等待執行緒到達exchange()方法同步點,此時兩個執行緒進行交換資料,將本執行緒產出資料傳遞給對方。

簡單的示例如下。

public class ExchangerTest {

    public static void main(String[] args) {
        // 交換同步器
        Exchanger<String> exchanger = new Exchanger<>();

        // 執行緒1
        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    String value = "A";
                    System.out.println("thread name:" +  Thread.currentThread().getName() + " 原資料:" + value);
                    String newValue = exchanger.exchange(value);
                    System.out.println("thread name:" +  Thread.currentThread().getName() + " 交換後的資料:" + newValue);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();

        // 執行緒2
        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    String value = "B";
                    System.out.println("thread name:" +  Thread.currentThread().getName() + " 原資料:" + value);
                    String newValue = exchanger.exchange(value);
                    System.out.println("thread name:" +  Thread.currentThread().getName() + " 交換後的資料:" + newValue);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }
}

輸出結果:

thread name:Thread-0 原資料:A
thread name:Thread-1 原資料:B
thread name:Thread-0 交換後的資料:B
thread name:Thread-1 交換後的資料:A

從上面的示例可以很清晰的看到,當執行緒Thread-0Thread-1都到達了exchange()方法的同步點時,進行了資料交換。

Exchanger類的主要方法,有以下幾個:

  • exchange(V x):等待另一個執行緒到達此交換點,然後將給定的物件傳送給該執行緒,並接收該執行緒的物件,除非當前執行緒被中斷,否則一直阻塞等待
  • exchange(V x, long timeout, TimeUnit unit):表示在指定的時間內等待另一個執行緒到達此交換點,如果超時會自動退出並拋超時異常

如果多個執行緒呼叫exchange()方法,資料交換可能會出現混亂,因此實際上Exchanger應用並不多見。

三、小結

本文主要圍繞 Java 多執行緒中常見的併發工具類進行了簡單的用例介紹,這些工具類都可以實現執行緒同步的效果,底層原理實現主要是基於 AQS 佇列式同步器來實現,關於 AQS 我們會在後期的文章中再次介紹。

本文篇幅稍有所長,內容難免有所遺漏,歡迎大家留言指出!

四、參考

1.https://www.cnblogs.com/xrq730/p/4869671.html

2.https://zhuanlan.zhihu.com/p/97055716

相關文章