一網打盡非同步神器CompletableFuture

2022-07-05 15:01:56

最近一直暢遊在RocketMQ的原始碼中,發現在RocketMQ中很多地方都使用到了CompletableFuture,所以今天就跟大家來聊一聊JDK1.8提供的非同步神器CompletableFuture,並且最後會結合RocketMQ原始碼分析一下CompletableFuture的使用。

Future介面以及它的侷限性

我們都知道,Java中建立執行緒的方式主要有兩種方式,繼承Thread或者實現Runnable介面。但是這兩種都是有一個共同的缺點,那就是都無法獲取到執行緒執行的結果,也就是沒有返回值。於是在JDK1.5 以後為了解決這種沒有返回值的問題,提供了Callable和Future介面以及Future對應的實現類FutureTask,通過FutureTask的就可以獲取到非同步執行的結果。

於是乎,我們想要開啟非同步執行緒,執行任務,獲取結果,就可以這麼實現。

 FutureTask<String> futureTask = new FutureTask<>(() -> "三友");
 new Thread(futureTask).start();
 System.out.println(futureTask.get());

或者使用執行緒池的方式

ExecutorService executorService = Executors.newSingleThreadExecutor();
Future<String> future = executorService.submit(() -> "三友");
System.out.println(future.get());
executorService.shutdown();

執行緒池底層也是將提交的Callable的實現先封裝成FutureTask,然後通過execute方法來提交任務,執行非同步邏輯。

Future介面的侷限性

雖然通過Future介面的get方法可以獲取任務非同步執行的結果,但是get方法會阻塞主執行緒,也就是非同步任務沒有完成,主執行緒會一直阻塞,直到任務結束。

Future也提供了isDone方法來檢視非同步執行緒任務執行是否完成,如果完成,就可以獲取任務的執行結果,程式碼如下。

ExecutorService executorService = Executors.newSingleThreadExecutor();
Future<String> future = executorService.submit(() -> "三友");
while (!future.isDone()) {
  //任務有沒有完成,沒有就繼續迴圈判斷
}
System.out.println(future.get());
executorService.shutdown();

但是這種輪詢檢視非同步執行緒任務執行狀態,也是非常消耗cpu資源。

同時對於一些複雜的非同步操作任務的處理,可能需要各種同步元件來一起完成。

所以,通過上面的介紹可以看出,Future在使用的過程中還是有很強的侷限性,所以為了解決這種侷限性,在JDK1.8的時候,Doug Lea 大神為我們提供了一種更為強大的類CompletableFuture。

什麼是CompletableFuture?

CompletableFuture在JDK1.8提供了一種更加強大的非同步程式設計的api。它實現了Future介面,也就是Future的功能特性CompletableFuture也有;除此之外,它也實現了CompletionStage介面,CompletionStage介面定義了任務編排的方法,執行某一階段,可以向下執行後續階段。

CompletableFuture相比於Future最大的改進就是提供了類似觀察者模式的回撥監聽的功能,也就是當上一階段任務執行結束之後,可以回撥你指定的下一階段任務,而不需要阻塞獲取結果之後來處理結果。

CompletableFuture常見api詳解

CompletableFuture的方法api多,但主要可以分為以下幾類。

1、範例化CompletableFuture

構造方法建立

CompletableFuture<String> completableFuture = new CompletableFuture<>();
System.out.println(completableFuture.get());

此時如果有其它執行緒執行如下程式碼,就能執行列印出 三友

completableFuture.complete("三友")

靜態方法建立

除了使用構造方法構造,CompletableFuture還提供了靜態方法來建立

public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier);
public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier, Executor executor);

public static CompletableFuture<Void> runAsync(Runnable runnable);
public static CompletableFuture<Void> runAsync(Runnable runnable, Executor executor);

supply 和 run 的主要區別就是 supply 可以有返回值,run 沒有返回值。至於另一個引數Executor 就是用來執行非同步任務的執行緒池,如果不傳Executor 的話,預設是ForkJoinPool這個執行緒池的實現。

一旦通過靜態方法來構造,會立馬開啟非同步執行緒執行Supplier或者Runnable提交的任務。

CompletableFuture<String> completableFuture = CompletableFuture.supplyAsync(() -> "三友");
System.out.println(completableFuture.get());

一旦任務執行完成,就可以列印返回值,這裡的使用方法跟Future是一樣的。

所以對比兩個兩種範例化的方法,使用靜態方法的和使用構造方法主要區別就是,使用構造方法需要其它執行緒主動呼叫complete來表示任務執行完成,因為很簡單,因為在構造的時候沒有執行非同步的任務,所以需要其它執行緒主動呼叫complete來表示任務執行完成。

2、獲取任務執行結果

public T get();
public T get(long timeout, TimeUnit unit);
public T getNow(T valueIfAbsent);
public T join();

get()和get(long timeout, TimeUnit unit)是實現了Future介面的功能,兩者主要區別就是get()會一直阻塞直到獲取到結果,get(long timeout, TimeUnit unit)值可以指定超時時間,當到了指定的時間還未獲取到任務,就會丟擲TimeoutException異常。

getNow(T valueIfAbsent):就是獲取任務的執行結果,但不會產生阻塞。如果任務還沒執行完成,那麼就會返回你傳入的 valueIfAbsent 引數值,如果執行完成了,就會返回任務執行的結果。

join():跟get()的主要區別就是,get()會丟擲檢查時異常,join()不會。

3、主動觸發任務完成

public boolean complete(T value);
public boolean completeExceptionally(Throwable ex);

complete:主動觸發當前非同步任務的完成。呼叫此方法時如果你的任務已經完成,那麼方法就會返回false;如果任務沒完成,就會返回true,並且其它執行緒獲取到的任務的結果就是complete的引數值。

completeExceptionally:跟complete的作用差不多,complete是正常結束任務,返回結果,而completeExceptionally就是觸發任務執行的異常。

4、對任務執行結果進行下一步處理

只能接收任務正常執行後的回撥

public <U> CompletionStage<U> thenApply(Function<? super T,? extends U> fn);
public CompletableFuture<Void> thenRun(Runnable action);
public CompletionStage<Void> thenAccept(Consumer<? super T> action);

這類回撥的特點就是,當任務正常執行完成,沒有異常的時候就會回撥。

thenApply:可以拿到上一步任務執行的結果進行處理,並且返回處理的結果 thenRun:拿不到上一步任務執行的結果,但會執行Runnable介面的實現 thenAccept:可以拿到上一步任務執行的結果進行處理,但不需要返回處理的結果

thenApply範例:

CompletableFuture<String> completableFuture = CompletableFuture.supplyAsync(() -> 10)
                .thenApply(v -> ("上一步的執行的結果為:" + v));
System.out.println(completableFuture.join());

執行結果:

上一步的執行的結果為:10

thenRun範例:

CompletableFuture<Void> completableFuture = CompletableFuture.supplyAsync(() -> 10)
      .thenRun(() -> System.out.println("上一步執行完成"));

執行結果:

上一步執行完成

thenAccept範例:

CompletableFuture<Void> completableFuture = CompletableFuture.supplyAsync(() -> 10)
      .thenAccept(v -> System.out.println("上一步執行完成,結果為:" + v));

執行結果:

上一步執行完成,結果為:10

thenApply有異常範例:

CompletableFuture<String> completableFuture = CompletableFuture.supplyAsync(() -> {
    //模擬異常
    int i = 1 / 0;
    return 10;
}).thenApply(v -> ("上一步的執行的結果為:" + v));
System.out.println(completableFuture.join());

執行結果:

Exception in thread "main" java.util.concurrent.CompletionException: java.lang.ArithmeticException: / by zero
 at java.util.concurrent.CompletableFuture.encodeThrowable(CompletableFuture.java:273)
 at java.util.concurrent.CompletableFuture.completeThrowable(CompletableFuture.java:280)
 at java.util.concurrent.CompletableFuture$AsyncSupply.run(CompletableFuture.java:1606)

當有異常時是不會回撥的

只能接收任務處理異常後的回撥

public CompletionStage<T> exceptionally(Function<Throwable, ? extends T> fn);

當上面的任務執行過程中出現異常的時候,會回撥exceptionally方法指定的回撥,但是如果沒有出現異常,是不會回撥的。

exceptionally能夠將異常給吞了,並且fn的返回值會返回回去。

其實這個exceptionally方法有點像降級的味道。當出現異常的時候,走到這個回撥,可以返回一個預設值回去。

沒有異常情況下:

CompletableFuture<Integer> completableFuture = CompletableFuture.supplyAsync(() -> {
    return 100;
}).exceptionally(e -> {
    System.out.println("出現異常了,返回預設值");
    return 110;
});
System.out.println(completableFuture.join());

執行結果:

100

有異常情況下:

CompletableFuture<Integer> completableFuture = CompletableFuture.supplyAsync(() -> {
    int i = 1 / 0;
    return 100;
}).exceptionally(e -> {
    System.out.println("出現異常了,返回預設值");
    return 110;
});
System.out.println(completableFuture.join());

執行結果:

出現異常了,返回預設值
110

能同時接收任務執行正常和異常的回撥

public <U> CompletionStage<U> handle(BiFunction<? super T, Throwable, ? extends U> fn);
public CompletionStage<T> whenComplete(BiConsumer<? super T, ? super Throwable> actin);

不論前面的任務執行成功還是失敗都會回撥的這類方法指定的回撥方法。

handle : 跟exceptionally有點像,但是exceptionally是出現異常才會回撥,兩者都有返回值,都能吞了異常,但是handle正常情況下也能回撥。

whenComplete:能接受正常或者異常的回撥,並且不影響上個階段的返回值,也就是主執行緒能獲取到上個階段的返回值;當出現異常時,whenComplete並不能吞了這個異常,也就是說主執行緒在獲取執行異常任務的結果時,會丟擲異常。

這裡演示一下whenComplete處理異常範例情況,handle跟exceptionally對異常的處理差不多。

whenComplete處理異常範例:

CompletableFuture<Integer> completableFuture = CompletableFuture.supplyAsync(() -> {
      int i = 1 / 0;
      return 10;
}).whenComplete((r, e) -> {
      System.out.println("whenComplete被呼叫了");
});
System.out.println(completableFuture.join());

執行結果:

whenComplete被呼叫了
Exception in thread "main" java.util.concurrent.CompletionException: java.lang.ArithmeticException: / by zero
 at java.util.concurrent.CompletableFuture.encodeThrowable(CompletableFuture.java:273)
 at java.util.concurrent.CompletableFuture.completeThrowable(CompletableFuture.java:280)
 at java.util.concurrent.CompletableFuture$AsyncSupply.run(CompletableFuture.java:1606)

5、對任務結果進行合併

public <U,V> CompletionStage<V> thenCombine
        (CompletionStage<? extends U> other,
         BiFunction<? super T,? super U,? extends V> fn);

這個方法的意思是,當前任務和other任務都執行結束後,拿到這兩個任務的執行結果,回撥 BiFunction ,然後返回新的結果。

thenCombine的例子請往下繼續看。

6、以Async結尾的方法

上面說的一些方法,比如說thenAccept方法,他有兩個對應的Async結尾的方法,如下:

public CompletionStage<Void> thenAcceptAsync(Consumer<? super T> action,Executor executor);
public CompletionStage<Void> thenAcceptAsync(Consumer<? super T> action);

thenAcceptAsync跟thenAccept的主要區別就是thenAcceptAsync會重新開一個執行緒來執行下一階段的任務,而thenAccept還是用上一階段任務執行的執行緒執行。

兩個thenAcceptAsync主要區別就是一個使用預設的執行緒池來執行任務,也就是ForkJoinPool,一個是使用方法引數傳入的執行緒池來執行任務。

當然除了thenAccept方法之外,上述提到的方法還有很多帶有Async結尾的對應的方法,他們的主要區別就是執行任務是否開啟非同步執行緒來執行的區別。

當然,還有一些其它的api,可以自行檢視

CompletableFuture在RocketMQ中的使用

CompletableFuture在RocketMQ中的使用場景比較多,這裡我舉一個訊息儲存的場景。

在RocketMQ中,Broker接收到生產者產生的訊息的時候,會將訊息持久化到磁碟和同步到從節點中。持久化到磁碟和訊息同步到從節點是兩個獨立的任務,互不干擾,可以相互獨立執行。當訊息持久化到磁碟和同步到從節點中任務完成之後,需要統計整個儲存訊息消耗的時間,所以統計整個儲存訊息消耗的時間是依賴前面兩個任務的完成。

 

 

實現程式碼如下

訊息儲存刷盤任務和主從複製任務:

PutMessageResult putMessageResult = new PutMessageResult(PutMessageStatus.PUT_OK, result);
// 提交刷盤的請求
CompletableFuture<PutMessageStatus> flushResultFuture = submitFlushRequest(result, msg);
//提交主從複製的請求
CompletableFuture<PutMessageStatus> replicaResultFuture = submitReplicaRequest(result, msg);

//刷盤 和 主從複製 兩個非同步任務通過thenCombine聯合
return flushResultFuture.thenCombine(replicaResultFuture, (flushStatus, replicaStatus) -> {
    // 當兩個刷盤和主從複製任務都完成的時候,就會回撥
    // 如果刷盤沒有成功,那麼就將訊息儲存的狀態設定為失敗
    if (flushStatus != PutMessageStatus.PUT_OK) {
        putMessageResult.setPutMessageStatus(flushStatus);
    }
    // 如果主從複製沒有成功,那麼就將訊息儲存的狀態設定為失敗
    if (replicaStatus != PutMessageStatus.PUT_OK) {
        putMessageResult.setPutMessageStatus(replicaStatus);
    }
    // 最終返回訊息儲存的結果
    return putMessageResult;
});

對上面兩個合併的任務執行結果通過thenAccept方法進行監聽,統計訊息儲存的耗時:

//訊息儲存的開始時間
long beginTime = this.getSystemClock().now();
// 儲存訊息,然後返回 CompletableFuture,也就是上面一段程式碼得返回值‍
CompletableFuture<PutMessageResult> putResultFuture = this.commitLog.asyncPutMessage(msg);

//監聽訊息儲存的結果
putResultFuture.thenAccept((result) -> {
    // 訊息儲存完成之後會回撥
    long elapsedTime = this.getSystemClock().now() - beginTime;
    if (elapsedTime > 500) {
        log.warn("putMessage not in lock elapsed time(ms)={}, bodyLength={}", elapsedTime, msg.getBody().length);
    }
    this.storeStatsService.setPutMessageEntireTimeMax(elapsedTime);

    if (null == result || !result.isOk()) {
        this.storeStatsService.getPutMessageFailedTimes().add(1);
    }
});

CompletableFuture的優點

1、非同步函數語言程式設計,實現優雅,易於維護;

2、它提供了異常管理的機制,讓你有機會丟擲、管理非同步任務執行中發生的異常,監聽這些異常的發生;

3、擁有對任務編排的能力。藉助這項能力,可以輕鬆地組織不同任務的執行順序、規則以及方式。

 

參考:

  • [1]https://zhuanlan.zhihu.com/p/344431341

     

如果覺得這篇文章對你有所幫助,還請幫忙點贊、在看、轉發給更多的人,非常感謝!

 

往期熱門文章推薦

掃碼或者搜尋關注公眾號 三友的java日記 ,及時乾貨不錯過,公眾號致力於通過畫圖加上通俗易懂的語言講解技術,讓技術更加容易學習。