CompletableFuture學習總結

遇见你真好。發表於2024-05-09

簡介

CompletableFuture結合了Future的優點,提供了非常強大的Future的擴充套件功能,可以幫助我們簡化非同步程式設計的複雜性,提供了函數語言程式設計的能力,可以透過回撥的方式處理計算結果,並且提供了轉換和組合CompletableFuture的方法。

CompletableFuture被設計在Java中進行非同步程式設計。非同步程式設計意味著在主執行緒之外建立一個獨立的執行緒,與主執行緒分隔開,並在上面執行一個非阻塞的任務,然後通知主執行緒進展,成功或者失敗。

CompletableFuture是由Java8引入的,在Java8之前我們一般透過Future實現非同步。
Future用於表示非同步計算的結果,只能透過阻塞或者輪詢的方式獲取結果,而且不支援設定回撥方法,Java8之前若要設定回撥一般會使用guava的ListenableFuture。 CompletableFuture對Future進行了擴充套件,可以透過設定回撥的方式處理計算結果,同時也支援組合操作,支援進一步的編排,同時一定程度解決了回撥地獄的問題。

核心概念

CompletableFuture 是一個非常強大的併發工具類,它實現了 FutureCompletionStage 介面,用於表示某個非同步計算的結果,與傳統的 Future 不同,CompletableFuture 提供了函數語言程式設計的方法,可以更容易地組織非同步程式碼,處理回撥和組合多個非同步操作。

假設,有一個電商網站,使用者瀏覽產品詳情頁時,需要展示產品的基本資訊、價格、庫存、使用者評價等多個方面的資料,這些資料可能來自不同的資料來源或服務,比如:

  1. 產品基本資訊可能來自一個主資料庫。
  2. 價格庫存 可能需要實時從另一個庫存服務獲取。
  3. 使用者評價可能儲存在另一個專門用於使用者反饋的系統中。

為了提升使用者體驗,希望這些資料的獲取能夠並行進行,而不是一個接一個地序列獲取,這就是 CompletableFuture 的經典場景。

CompletableFuture 類在主要用來解決非同步程式設計和併發執行的問題,在傳統的同步程式設計模型中,程式碼的執行通常是阻塞的,即一行程式碼執行完成後,下一行程式碼才能開始執行,這種模型在處理耗時操作時,如 I/O 操作、資料庫訪問或網路請求,會導致執行緒長時間閒置,等待操作完成,從而降低系統的吞吐量和響應能力。

因此,CompletableFuture 類提供了一種非阻塞的、基於回撥的程式設計方式,可以在等待某個長時間執行的任務完成時,同時執行其他任務,這樣,就可以更充分地利用系統資源,提高程式的併發性和響應速度。

使用CompletableFuture通常用於解決以下類似場景的問題:

  1. 發起非同步請求:當使用者請求一個產品詳情頁時,後端服務可以同時發起對三個資料來源的非同步請求,這可以透過建立三個 CompletableFuture 例項來實現,每個例項負責一個資料來源的請求。
  2. 處理非同步結果:一旦這些非同步請求發出,它們就可以獨立地執行,主執行緒可以繼續處理其他任務,當某個 CompletableFuture 完成時,它會包含一個結果(或者是執行過程中的異常)。
  3. 組合非同步結果:使用 CompletableFuture 的組合方法(如 thenCombinethenAcceptBothallOf),可以等待所有非同步操作完成,並將它們的結果組合在一起,比如,可以等待產品基本資訊、價格和庫存以及使用者評價都返回後,再將這些資料整合到一個響應物件中,返回給前端。
  4. 異常處理:如果在獲取某個資料來源時發生異常,CompletableFuture 允許以非同步的方式處理這些異常,比如透過 exceptionally 方法提供一個預設的備選結果或執行一些清理操作。
  5. 最終響應:一旦所有資料來源的資料都成功獲取並組合在一起,或者某個資料來源發生異常並得到了妥善處理,服務就可以將最終的產品詳情頁響應傳送給前端使用者。

使用CompletableFuture 可以高效的併發資料獲取,提升系統的響應速度和整體效能。

核心API

CompletableFuture 列用於表示某個非同步計算的結果,它提供了函數語言程式設計的方法來處理非同步計算,允許以非阻塞的方式編寫併發程式碼,並且可以連結多個非同步操作,以下是一些常用方法的含義:

1、靜態工廠方法

  • CompletableFuture.supplyAsync(Supplier<? extends U> supplier): 非同步執行給定的 Supplier,並返回一個表示結果的新 CompletableFuture
  • CompletableFuture.supplyAsync(Supplier<? extends U> supplier, Executor executor): 使用指定的執行器非同步執行給定的 Supplier
  • CompletableFuture.runAsync(Runnable runnable): 非同步執行給定的 Runnable,並返回一個表示其完成的新 CompletableFuture
  • CompletableFuture.runAsync(Runnable runnable, Executor executor): 使用指定的執行器非同步執行給定的 Runnable

2、完成時的處理

  • thenApply(Function<? super T,? extends U> fn): 當此 CompletableFuture 完成時,對其結果應用給定的函式。
  • thenAccept(Consumer<? super T> action): 當此 CompletableFuture 完成時,執行給定的操作。
  • thenRun(Runnable action): 當此 CompletableFuture 完成時,執行給定的無引數操作。

3、異常處理

  • exceptionally(Function<Throwable,? extends T> fn): 當此 CompletableFuture 異常完成時,對其異常應用給定的函式。

4、組合多個 CompletableFuture

  • thenCombine(CompletableFuture<? extends U> other, BiFunction<? super T,? super U,? extends V> fn): 當此 CompletableFuture 和另一個都完成時,使用給定的函式組合它們的結果。
  • thenAcceptBoth(CompletableFuture<? extends U> other, BiConsumer<? super T,? super U> action): 當此 CompletableFuture 和另一個都完成時,對它們的結果執行給定的操作。
  • runAfterBoth(CompletableFuture<?> other, Runnable action): 當此 CompletableFuture 和另一個都完成時,執行給定的操作。
  • applyToEither(CompletableFuture<? extends T> other, Function<? super T, U> fn): 當此 CompletableFuture 或另一個完成時(哪個先完成),對其結果應用給定的函式。
  • acceptEither(CompletableFuture<? extends T> other, Consumer<? super T> action): 當此 CompletableFuture 或另一個完成時(哪個先完成),對其結果執行給定的操作。
  • runAfterEither(CompletableFuture<?> other, Runnable action): 當此 CompletableFuture 或另一個完成時(哪個先完成),執行給定的操作。

5、等待和獲取結果

  • get(): 等待計算完成,然後獲取其結果。
  • get(long timeout, TimeUnit unit): 等待計算在給定的時間內完成,並獲取其結果。
  • join(): 類似於 get(),但是會在計算未完成時丟擲未檢查的異常。
  • complete(T value): 如果尚未完成,則設定此 CompletableFuture 的結果。
  • completeExceptionally(Throwable ex): 如果尚未完成,則使此 CompletableFuture 異常完成。

6、取消

  • cancel(boolean mayInterruptIfRunning): 嘗試取消此 CompletableFuture
  • isCancelled(): 如果此 CompletableFuture 被取消,則返回 true

7、查詢

  • isDone(): 如果此 CompletableFuture 完成(無論是正常完成還是異常完成),則返回 true

封裝計算邏輯的CompletableFuture

上面的程式碼允許我們選擇任何併發執行的機制,但是如果我們想跳過這個樣板檔案,簡單地非同步執行一些程式碼呢?

靜態方法runAsync和supplyAsync允許我們相應地使用Runnable和Supplier函式型別建立一個可完成的未來例項。

Runnable和Supplier都是函式介面,由於新的java8特性,它們允許將例項作為lambda表示式傳遞。

Runnable介面與執行緒中使用的舊介面相同,不允許返回值。

Supplier介面是一個通用函式介面,它有一個方法,該方法沒有引數,並且返回一個引數化型別的值。

這允許我們提供一個供應商例項作為lambda表示式來執行計算並返回結果。簡單到:

CompletableFuture<String> future
= CompletableFuture.supplyAsync(() -> "Hello");

// ...

assertEquals("Hello", future.get());

非同步計算的處理結果

處理計算結果的最通用的方法是將其提供給函式。thenApply方法正是這樣做的;它接受一個函式例項,用它來處理結果,並返回一個包含函式返回值的Future:

CompletableFuture<String> completableFuture
= CompletableFuture.supplyAsync(() -> "Hello");

CompletableFuture<String> future = completableFuture
.thenApply(s -> s + " World");

assertEquals("Hello World", future.get());

如果我們不需要在Future中返回值,我們可以使用Consumer函式介面的例項。它的單個方法接受一個引數並返回void。

在可完成的將來,有一種方法可以解決這個用例。thenAccept方法接收使用者並將計算結果傳遞給它。最後一個future.get()呼叫返回Void型別的例項:

CompletableFuture<String> completableFuture
= CompletableFuture.supplyAsync(() -> "Hello");

CompletableFuture<Void> future = completableFuture
.thenAccept(s -> System.out.println("Computation returned: " + s));

future.get();

最後,如果我們既不需要計算的值,也不想返回值,那麼我們可以將一個可執行的lambda傳遞給thenRun方法。在下面的示例中,我們只需在呼叫future.get()後在控制檯中列印一行:

CompletableFuture<String> completableFuture 
= CompletableFuture.supplyAsync(() -> "Hello");

CompletableFuture<Void> future = completableFuture
.thenRun(() -> System.out.println("Computation finished."));

future.get();

組合CompletableFuture

CompletableFuture API最好的部分是能夠在一系列計算步驟中組合CompletableFuture例項。

這種連結的結果本身就是一個完整的Future,允許進一步的連結和組合。這種方法在函式語言中普遍存在,通常被稱為享元模式。

在下面的示例中,我們使用thenCompose方法按順序連結兩個Future。

請注意,此方法接受一個返回CompletableFuture例項的函式。此函式的引數是上一計算步驟的結果。這允許我們在下一個CompletableFuture的lambda中使用此值:

CompletableFuture<String> completableFuture 
= CompletableFuture.supplyAsync(() -> "Hello")
.thenCompose(s -> CompletableFuture.supplyAsync(() -> s + " World"));

assertEquals("Hello World", completableFuture.get());

thenCompose方法與thenApply一起實現了享元模式的基本構建塊。它們與流的map和flatMap方法以及java8中的可選類密切相關。

兩個方法都接收一個函式並將其應用於計算結果,但是thencomose(flatMap)方法接收一個返回另一個相同型別物件的函式。這種功能結構允許將這些類的例項組合為構建塊。

如果我們想執行兩個獨立的未來,並對它們的結果進行處理,我們可以使用thenCombine方法,該方法接受一個未來和一個具有兩個引數的函式來處理這兩個結果:

CompletableFuture<String> completableFuture 
= CompletableFuture.supplyAsync(() -> "Hello")
.thenCombine(CompletableFuture.supplyAsync(
() -> " World"), (s1, s2) -> s1 + s2));

assertEquals("Hello World", completableFuture.get());

一個簡單的例子是,當我們想處理兩個CompletableFuture的結果時,但不需要將任何結果值傳遞給CompletableFuture的鏈。thenAcceptBoth方法可以幫助:

CompletableFuture future = CompletableFuture.supplyAsync(() -> "Hello")
.thenAcceptBoth(CompletableFuture.supplyAsync(() -> " World"),
(s1, s2) -> System.out.println(s1 + s2));

thenApply()和thenCompose()方法之間的區別

在前面的部分中,我們展示了有關thenApply()和thenCompose()的示例。兩個api都有助於連結不同的CompletableFuture呼叫,但這兩個函式的用法不同。

thenApply()

我們可以使用此方法處理上一次呼叫的結果。但是,需要記住的一點是,返回型別將由所有呼叫組合而成。

因此,當我們要轉換CompletableFuture呼叫的結果時,此方法非常有用:

CompletableFuture<Integer> finalResult = compute().thenApply(s-> s + 1);

thenCompose()

thenCompose()方法與thenApply()類似,因為兩者都返回一個新的完成階段。但是,thencose()使用前一階段作為引數。它將展平並直接返回一個帶有結果的CompletableFuture,而不是我們在thenApply()中觀察到的巢狀CompletableFuture:

CompletableFuture<Integer> computeAnother(Integer i){
return CompletableFuture.supplyAsync(() -> 10 + i);
}
CompletableFuture<Integer> finalResult = compute().thenCompose(this::computeAnother);

因此,如果要連結可完成的CompletableFuture方法,那麼最好使用thenCompose()。

另外,請注意,這兩個方法之間的差異類似於map()和flatMap()之間的差異。

並行執行多個CompletableFuture

當我們需要並行執行多個期貨時,我們通常希望等待所有Supplier執行,然後處理它們的組合結果。

CompletableFuture.allOf靜態方法允許等待的所有Supplier的完成:

CompletableFuture<String> future1  
= CompletableFuture.supplyAsync(() -> "Hello");
CompletableFuture<String> future2
= CompletableFuture.supplyAsync(() -> "Beautiful");
CompletableFuture<String> future3
= CompletableFuture.supplyAsync(() -> "World");

CompletableFuture<Void> combinedFuture
= CompletableFuture.allOf(future1, future2, future3);

// ...

combinedFuture.get();

assertTrue(future1.isDone());
assertTrue(future2.isDone());
assertTrue(future3.isDone());

注意CompletableFuture.allOf()的返回型別是CompletableFuture。這種方法的侷限性在於它不能返回所有Supplier的組合結果。相反,我們必須從未來手動獲取結果。幸運的是,CompletableFuture.join()方法和Java 8 Streams API使它變得簡單:

String combined = Stream.of(future1, future2, future3)
.map(CompletableFuture::join)
.collect(Collectors.joining(" "));

assertEquals("Hello Beautiful World", combined);

join()方法類似於get方法,但是如果Future不能正常完成,它會丟擲一個未檢查的異常。這樣就可以將其用作Stream.map()方法中的方法引用。

具體使用簡單demo案例

/**
 * 獲取統計指標資訊
 * @return  統計指標值
 */
@GetMapping("/statistics")
public ResultVO statistics(){
    ResultVO resultVO = new ResultVO();
    try {
        // 獲取企業所需要統計的數量
        CompletableFuture<Integer> unitNum = CompletableFuture.supplyAsync(() ->  {
            log.info("執行[獲取企業所需要統計的數量]任務:"+"執行緒id:"+Thread.currentThread().getId()+"執行緒名稱:"+
        Thread.currentThread().getName());
            return enterpriseService.list().size();
        });
        // 獲取供需所需要統計的數量
        CompletableFuture<Integer> supplyDemandNum = CompletableFuture.supplyAsync(() -> {
            log.info("執行[獲取供需所需要統計的數量]任務:"+"執行緒id:"+Thread.currentThread().getId()+"執行緒名稱:"+
            Thread.currentThread().getName());
            return supplyDemandService.list().size();
        });
        // 等待所有非同步任務執行完成
        CompletableFuture.allOf(unitNum,supplyDemandNum).join();

        resultVO.setUnitNum(unitNum.get());
        resultVO.setSupplyDemandNum(supplyDemandNum.get());
    } catch (Exception e) {
        log.error("任務執行異常!");
        throw new RuntimeException("獲取統計指標異常!");
    }
    return resultVO;
}

返回結果:

{
"unitNum": 109,
"supplyDemandNum": 2
}