有了 CompletableFuture,使得異步編程沒有那麼難了

本文導讀:

  • 業務需求場景介紹
  • 技術設計方案思考
  • Future 設計模式實戰
  • CompletableFuture 模式實戰
  • CompletableFuture 生產建議
  • CompletableFuture 性能測試
  • CompletableFuture 使用擴展

1、業務需求場景介紹


不變的東西就是一直在變化中。

想必,大家在閒暇時刻,會經常看視頻,經常用的幾個 APP,比如優酷、愛奇藝、騰訊等。

這些視頻 APP 不僅僅可以在手機上播放,還能夠支持在電視上播放。

在電視終端上播放的 APP 是獨立發佈的版本,跟手機端的 APP 是不一樣的。

當我們看一部電影時,點擊進入某一部電影,就進入到了專輯詳情頁頁面,此時,播放器會自動播放視頻。用戶在手機上看到的專輯詳情頁,與電視上看到的專輯詳情頁,頁面樣式設計上是不同的。

我們來直觀的看一下效果。

手機上的騰訊視頻專輯詳情頁:

有了 CompletableFuture,使得異步編程沒有那麼難了

上半部分截圖,下面還有為你推薦、明星演員、周邊推薦、評論等功能。

相應的,在電視端的專輯詳情頁展示方式是不一樣的。假設產品經理提出一個需求,要求對詳情頁做個改版。

樣式要求如下圖所示:

有了 CompletableFuture,使得異步編程沒有那麼難了

兩個終端的樣式對比,在電視端專輯詳情頁中,包含了很多板塊,每個板塊橫向展示多個內容。

產品的設計上要求是,有的板塊內容來源於推薦、有的板塊來源於搜索、有的板塊來源CMS(內容管理系統)。簡單理解為,每個板塊內容來源不同,來源於推薦、搜索等接口的內容要求是近實時的請求。

2、技術設計方案思考


考慮到產品提的這個需求,其實實現起來並不難。

主要分為了靜態數據部分和動態數據部分,對於不經常變化的數據可以通過靜態接口獲取,對於近乎實時的數據可以通過動態接口獲取。

靜態接口設計:

專輯本身的屬性以及專輯下的視頻數據,一般是不經常變化的。

在需求場景介紹中,我截圖的是電影頻道。如果是電視劇頻道,會展示劇集列表(專輯下的所有視頻,如第 1 集、第 2 集...),而視頻的更新一般是不太頻繁的,所以在專輯詳情頁劇集列表數據就可以從靜態接口獲取。

靜態接口數據生成流程:

有了 CompletableFuture,使得異步編程沒有那麼難了

另外一部分,就是需要動態接口來實現,調用第三方接口獲取數據,比如推薦、搜索數據。

同時,要求板塊與板塊之間的內容不允許重複。

動態接口設計:

方案一:

串行調用,即按照每個板塊的展示先後順序,調用相應的第三方接口獲取數據。

方案二:

並行調用,即多個板塊之間可以並行調用,提高整體接口響應效率。

其實以上兩個方案,各有利弊。

方案一串行調用,好處是開發模型簡單,按照串行方式依次調用接口,內容數據去重,聚合所有的數據返回給客戶端。

但是,接口響應時間依賴於第三方接口的響應時間,通常第三方接口總是不可靠的,可能就會拉高接口整體響應時間,進而導致佔用線程時間過長,影響接口整體吞吐量。

方案二並行調用,理論上是可以提高接口的整體響應時間,假設同時調用多個第三方接口,取決於最慢的接口響應時間。

並行調用時,需要考慮到「池化技術」,即不能無限制的在 JVM 進程上創建過多的線程。同時,也要考慮到板塊與板塊之間的內容數據,要按照產品設計上的先後順序做去重。

根據這個需求場景,我們選擇第二種方案來實現更合適一些。

選擇了方案二,我們抽象出如下圖所示的簡易模型:

有了 CompletableFuture,使得異步編程沒有那麼難了

T1、T2、T3 表示多個板塊內容線程。T1 線程先返回結果,T2 線程返回的結果不能與與 T1 線程返回的結果內容重複,T3 線程返回的結果不能與 T1、T2 兩個線程返回的結果內容重複。

我們從技術實現上考量,當並行調用多個第三方接口時,需要獲取接口的返回結果,首先想到的就是 Future ,能夠實現異步獲取任務結果。

另外,JDK8 提供了 CompletableFuture 易於使用的獲取異步結果的工具類,解決了 Future 的一些使用上的痛點,以更優雅的方式實現組合式異步編程,同時也契合函數式編程。

3、Future 設計模式實戰


Future 接口設計:

提供了獲取任務結果、取消任務、判斷任務狀態接口。調用獲取任務結果方法,在任務未完成情況下,會導致調用阻塞。

Future 接口提供的方法:

```

// 獲取任務結果

V get() throws InterruptedException, ExecutionException;

// 支持超時時間的獲取任務結果

V get(long timeout, TimeUnit unit)

throws InterruptedException, ExecutionException, TimeoutException;

// 判斷任務是否已完成

boolean isDone();

// 判斷任務是否已取消

boolean isCancelled();

// 取消任務

boolean cancel(boolean mayInterruptIfRunning);

```

通常,我們在考慮到使用 Future 獲取任務結果時,會使用 ThreadPoolExecutor 或者 FutureTask 來實現功能需求。

ThreadPoolExecutor、FutureTask 與 Future 接口關係類圖:

有了 CompletableFuture,使得異步編程沒有那麼難了

TheadPoolExecutor 提供三個 submit 方法:

// 1. 提交無需返回值的任務,Runnable 接口 run() 方法無返回值
public Future> submit(Runnable task) {
}
// 2. 提交需要返回值的任務,Callable 接口 call() 方法有返回值
public Future submit(Callable task) {
}
// 3. 提交需要返回值的任務,任務結果是第二個參數 result 對象
public Future submit(Runnable task, T result) {
}

第 3 個 submit 方法使用示例如下所示:

static String x = "東昇的思考";
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(1);
// 創建 Result 對象 r
Result r = new Result();
r.setName(x);
// 提交任務
Future<result> future =
executor.submit(new Task(r), r);
Result fr = future.get();
// 下面等式成立
System.out.println(fr == r);

System.out.println(fr.getName() == x);
System.out.println(fr.getNick() == x);
}
static class Result {
private String name;
private String nick;
// ... ignore getter and setter
}
static class Task implements Runnable {
Result r;
// 通過構造函數傳入 result
Task(Result r) {
this.r = r;
}
@Override
public void run() {
// 可以操作 result
String name = r.getName();
r.setNick(name);
}
}
/<result>

執行結果都是true。

FutureTask 設計實現:

實現了 Runnable 和 Future 兩個接口。實現了 Runnable 接口,說明可以作為任務對象,直接提交給 ThreadPoolExecutor 去執行。實現了 Future 接口,說明能夠獲取執行任務的返回結果。

我們來根據產品的需求,使用 FutureTask 模擬兩個線程,通過示例實現下功能。

結合示例代碼註釋理解:

public static void main(String[] args) throws Exception {
// 創建任務 T1 的 FutureTask,調用推薦接口獲取數據

FutureTask<string> ft1 = new FutureTask<>(new T1Task());
// 創建任務 T1 的 FutureTask,調用搜索接口獲取數據,依賴 T1 結果
FutureTask<string> ft2 = new FutureTask<>(new T2Task(ft1));
// 線程 T1 執行任務 ft1
Thread T1 = new Thread(ft1);
T1.start();
// 線程 T2 執行任務 ft2
Thread T2 = new Thread(ft2);
T2.start();
// 等待線程 T2 執行結果
System.out.println(ft2.get());
}
// T1Task 調用推薦接口獲取數據
static class T1Task implements Callable<string> {
@Override
public String call() throws Exception {
System.out.println("T1: 調用推薦接口獲取數據...");
TimeUnit.SECONDS.sleep(1);
System.out.println("T1: 得到推薦接口數據...");
TimeUnit.SECONDS.sleep(10);
return " [T1 板塊數據] ";
}
}

// T2Task 調用搜索接口數據,同時需要推薦接口數據
static class T2Task implements Callable<string> {
FutureTask<string> ft1;
// T2 任務需要 T1 任務的 FutureTask 返回結果去重
T2Task(FutureTask<string> ft1) {
this.ft1 = ft1;
}
@Override
public String call() throws Exception {
System.out.println("T2: 調用搜索接口獲取數據...");
TimeUnit.SECONDS.sleep(1);
System.out.println("T2: 得到搜索接口的數據...");
TimeUnit.SECONDS.sleep(5);
// 獲取 T2 線程的數據

System.out.println("T2: 調用 T1.get() 接口獲取推薦數據");
String tf1 = ft1.get();
System.out.println("T2: 獲取到推薦接口數據:" + tf1);
System.out.println("T2: 將 T1 與 T2 板塊數據做去重處理");
return "[T1 和 T2 板塊數據聚合結果]";
}
}
/<string>/<string>/<string>/<string>/<string>/<string>

執行結果如下:

> Task :FutureTaskTest.main()
T1: 調用推薦接口獲取數據...
T2: 調用搜索接口獲取數據...
T1: 得到推薦接口數據...
T2: 得到搜索接口的數據...
T2: 調用 T1.get() 接口獲取推薦數據
T2: 獲取到推薦接口數據: [T1 板塊數據]
T2: 將 T1 與 T2 板塊數據做去重處理
[T1 和 T2 板塊數據聚合結果]

小結:

Future 表示「未來」的意思,主要是將耗時的一些操作任務,交給單獨的線程去執行。從而達到異步的目的,提交任務的當前線程,在提交任務後和獲取任務結果的過程中,當前線程可以繼續執行其他操作,不需要在那傻等著返回執行結果。

4、CompleteableFuture 模式實戰


對於 Future 設計模式,雖然我們提交任務時,不會進入任何阻塞,但是當調用方要獲得這個任務的執行結果,還是可能會阻塞直至任務執行完成。

在 JDK1.5 設計之初就一直存在這個問題,發展到 JDK1.8 引入了 CompletableFuture 才得到完美的增強。

在此期間,Google 開源的 Guava 工具包提供了 ListenableFuture ,用於支持任務完成時支持回調方式,感興趣的朋友們可以自行查閱研究。

在業務需求場景介紹中,不同板塊的數據來源是不同的,並且板塊與板塊之間是存在數據依賴關係的。

可以理解為任務與任務之間是有時序關係的,而根據 CompletableFuture 提供的一些功能特性,是非常適合這種業務場景的。

CompletableFuture 類圖:

有了 CompletableFuture,使得異步編程沒有那麼難了

CompletableFuture 實現了 Future 和 CompletionStage 兩個接口。實現 Future 接口是為了關注異步任務什麼時候結束,和獲取異步任務執行的結果。實現 CompletionStage 接口,其提供了非常豐富的功能,實現了串行關係、並行關係、匯聚關係等。

CompletableFuture 核心優勢:

1)無需手工維護線程,給任務分配線程的工作無需開發人員關注;

2)在使用上,語義更加清晰明確;

例如:t3 = t1.thenCombine(t2, () -> { // doSomething ... } 能夠明確的表述任務 3 要等任務 2 和 任務 1完成後才會開始執行。

3)代碼更加簡練,支持鏈式調用,讓你更專注業務邏輯。

4)方便的處理異常情況

接下來,通過 CompletableFuture 來模擬實現專輯下多板塊數據聚合處理。

代碼如下所示:

public static void main(String[] args) throws Exception {
// 暫存數據
List<string> stashList = Lists.newArrayList();
// 任務 1:調用推薦接口獲取數據
CompletableFuture<string> t1 =

CompletableFuture.supplyAsync(() -> {
System.out.println("T1: 獲取推薦接口數據...");
sleepSeconds(5);
stashList.add("[T1 板塊數據]");
return "[T1 板塊數據]";
});
// 任務 2:調用搜索接口獲取數據
CompletableFuture<string> t2 =
CompletableFuture.supplyAsync(() -> {
System.out.println("T2: 調用搜索接口獲取數據...");
sleepSeconds(3);
return " [T2 板塊數據] ";
});
// 任務 3:任務 1 和任務 2 完成後執行,聚合結果
CompletableFuture<string> t3 =
t1.thenCombine(t2, (t1Result, t2Result) -> {
System.out.println(t1Result + " 與 " + t2Result + "實現去重邏輯處理");
return "[T1 和 T2 板塊數據聚合結果]";
});
// 等待任務 3 執行結果
System.out.println(t3.get(6, TimeUnit.SECONDS));
}
static void sleepSeconds(int timeout) {
try {
TimeUnit.SECONDS.sleep(timeout);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
/<string>/<string>/<string>/<string>

執行結果如下:

> Task :CompletableFutureTest.main()
T1: 獲取推薦接口數據...
T2: 調用搜索接口獲取數據...
[T1 板塊數據] 與 [T2 板塊數據] 實現去重邏輯處理
[T1 和 T2 板塊數據聚合結果]

上述的示例代碼在 IDEA 中新建個Class,直接複製進去,即可正常運行。

** 5、CompletableFuture 生產建議**


創建合理的線程池:

在生產環境下,不建議直接使用上述示例代碼形式。因為示例代碼中使用的

CompletableFuture.supplyAsync(() -> {});

創建 CompletableFuture 對象的 supplyAsync() 方法(這裡使用的工廠方法模式),底層使用的默認線程池,不一定能滿足業務需求。

結合底層源代碼來看一下:

// 默認使用 ForkJoinPool 線程池
private static final Executor asyncPool = useCommonPool ?
ForkJoinPool.commonPool() : new ThreadPerTaskExecutor();
public static CompletableFuture supplyAsync(Supplier supplier) {
return asyncSupplyStage(asyncPool, supplier);
}

創建 ForkJoinPool 線程池:

默認線程池大小是 Runtime.getRuntime().availableProcessors() - 1(CPU 核數 - 1),可以通過 JVM 參數 -Djava.util.concurrent.ForkJoinPool.common.parallelism 設置線程池大小。

JVM 參數上配置 -Djava.util.concurrent.ForkJoinPool.common.threadFactory 設置線程工廠類;配置 -Djava.util.concurrent.ForkJoinPool.common.exceptionHandler 設置異常處理類,這兩個參數設置後,內部會通過系統類加載器加載 Class。

如果所有 CompletableFuture 都使用默認線程池,一旦有任務執行很慢的 I/O 操作,就會導致所有線程都阻塞在 I/O 操作上,進而影響系統整體性能。

所以,建議大家在生產環境使用時,根據不同的業務類型創建不同的線程池,以避免互相影響。

CompletableFuture 還提供了另外支持線程池的方法。

// 第二個參數支持傳遞 Executor 自定義線程池
public static CompletableFuture supplyAsync(Supplier supplier,
Executor executor) {
return asyncSupplyStage(screenExecutor(executor), supplier);
}

自定義線程池,建議參考 「阿里巴巴 Java 開發手冊」,推薦使用 ThreadPoolExecutor 自定義線程池,使用有界隊列,根據實際業務情況設置隊列大小。

線程池大小的設置,在 「Java 併發編程實戰」一書中,Brian Goetz 提供了不少優化建議。如果線程池數量過多,競爭 CPU 和內存資源,導致大量時間在上下文切換上。反之,如果線程池數量過少,無法充分利用 CPU 多核優勢。

線程池大小與 CPU 處理器的利用率之比可以用下面公式估算:

有了 CompletableFuture,使得異步編程沒有那麼難了

異常處理:

CompletableFuture 提供了非常簡單的異常處理 ,如下這些方法,支持鏈式編程方式。

// 類似於 try{}catch{} 中的 catch{}
public CompletionStage exceptionally
(Function<throwable> fn);

// 類似於 try{}finally{} 中的 finally{},不支持返回結果
public CompletionStage whenComplete
(BiConsumer super T, ? super Throwable> action);
public CompletionStage whenCompleteAsync
(BiConsumer super T, ? super Throwable> action);

// 類似於 try{}finally{} 中的 finally{},支持返回結果
public CompletionStage handle
(BiFunction super T, Throwable, ? extends U> fn);
public CompletionStage handleAsync
(BiFunction super T, Throwable, ? extends U> fn);
/<throwable>

#### 6、CompletableFuture 性能測試:

循環壓測任務數如下所示,每次執行壓測,從 1 到 jobNum 數據疊加匯聚結果,計算耗時。

統計維度:CompletableFuture 默認線程池 與 自定義線程池。

性能測試代碼:

// 性能測試代碼
Arrays.asList(-3, -1, 0, 1, 2, 4, 5, 10, 16, 17, 30, 50, 100, 150, 200, 300).forEach(offset -> {
int jobNum = PROCESSORS + offset;
System.out.println(
String.format("When %s tasks => stream: %s, parallelStream: %s, future default: %s, future custom: %s",
testCompletableFutureDefaultExecutor(jobNum), testCompletableFutureCustomExecutor(jobNum)));
});
// CompletableFuture 使用默認 ForkJoinPool 線程池
private static long testCompletableFutureDefaultExecutor(int jobCount) {
List<completablefuture>> tasks = new ArrayList<>();
IntStream.rangeClosed(1, jobCount).forEach(value -> tasks.add(CompletableFuture.supplyAsync(CompleteableFuturePerfTest::getJob)));
long start = System.currentTimeMillis();
int sum = tasks.stream().map(CompletableFuture::join).mapToInt(Integer::intValue).sum();
checkSum(sum, jobCount);
return System.currentTimeMillis() - start;
}
// CompletableFuture 使用自定義的線程池
private static long testCompletableFutureCustomExecutor(int jobCount) {
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(200, 200, 5, TimeUnit.MINUTES, new ArrayBlockingQueue<>(100000), new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
Thread thread = new Thread(r);
thread.setName("CUSTOM_DAEMON_COMPLETABLEFUTURE");
thread.setDaemon(true);
return thread;
}
}, new ThreadPoolExecutor.CallerRunsPolicy());
List<completablefuture>> tasks = new ArrayList<>();
IntStream.rangeClosed(1, jobCount).forEach(value -> tasks.add(CompletableFuture.supplyAsync(CompleteableFuturePerfTest::getJob, threadPoolExecutor)));
long start = System.currentTimeMillis();
int sum = tasks.stream().map(CompletableFuture::join).mapToInt(Integer::intValue).sum();
checkSum(sum, jobCount);
return System.currentTimeMillis() - start;
}
/<completablefuture>/<completablefuture>

測試機器配置:8 核CPU,16G內存

性能測試結果:

有了 CompletableFuture,使得異步編程沒有那麼難了

根據壓測結果看到,隨著壓測任務數量越大,使用默認的線程池性能越差。

7、CompletableFuture 使用擴展:


對象創建:

除前面提到的 supplyAsync 方法外,CompletableFuture 還提供瞭如下方法:

// 執行任務,CompletableFuture<void> 無返回值,默認線程池 

public static CompletableFuture<void> runAsync(Runnable runnable) {
return asyncRunStage(asyncPool, runnable);
}
// 執行任務,CompletableFuture<void> 無返回值,支持自定義線程池
public static CompletableFuture<void> runAsync(Runnable runnable,
Executor executor) {
return asyncRunStage(screenExecutor(executor), runnable);
}
/<void>/<void>/<void>/<void>

我們在 CompletableFuture 模式實戰中,提到了 CompletableFuture 實現了 CompletionStage 接口,該接口提供了非常豐富的功能。

CompletionStage 接口支持串行關係、匯聚 AND 關係、匯聚 OR 關係。

下面對這些關係的接口做個簡單描述,大家在使用時可以去自行查閱 JDK API。

同時,這些關係接口中每個方法都提供了對應的 xxxAsync() 方法,表示異步化執行任務。

串行關係:

CompletionStage 描述串行關係,主要有 thenApply、thenRun、thenAccept 和 thenCompose 系列接口。

源碼如下所示:

// 對應 U apply(T t) ,接收參數 T並支持返回值 U
public CompletionStage thenApply(Function super T,? extends U> fn);
public CompletionStage thenApplyAsync(Function super T,? extends U> fn);

// 不接收參數也不支持返回值
public CompletionStage<void> thenRun(Runnable action);
public CompletionStage<void> thenRunAsync(Runnable action);
// 接收參數但不支持返回值
public CompletionStage<void> thenAccept(Consumer super T> action);
public CompletionStage<void> thenAcceptAsync(Consumer super T> action);
// 組合兩個依賴的 CompletableFuture 對象
public CompletionStage thenCompose
(Function super T, ? extends CompletionStage> fn);
public CompletionStage thenComposeAsync
(Function super T, ? extends CompletionStage> fn);
/<void>/<void>/<void>/<void>

匯聚 AND 關係:

CompletionStage 描述 匯聚 AND 關係,主要有 thenCombine、thenAcceptBoth 和 runAfterBoth 系列接口。

源碼如下所示(省略了Async 方法):

// 當前和另外的 CompletableFuture 都完成時,兩個參數傳遞給 fn,fn 有返回值
public CompletionStage thenCombine

(CompletionStage extends U> other,
BiFunction super T,? super U,? extends V> fn);
// 當前和另外的 CompletableFuture 都完成時,兩個參數傳遞給 action,action 沒有返回值
public CompletionStage<void> thenAcceptBoth
(CompletionStage extends U> other,
BiConsumer super T, ? super U> action);
// 當前和另外的 CompletableFuture 都完成時,執行 action
public CompletionStage<void> runAfterBoth(CompletionStage> other,
Runnable action);
/<void>/<void>

匯聚 OR 關係:

CompletionStage 描述 匯聚 OR 關係,主要有 applyToEither、acceptEither 和 runAfterEither 系列接口。

源碼如下所示(省略了Async 方法):

// 當前與另外的 CompletableFuture 任何一個執行完成,將其傳遞給 fn,支持返回值
public CompletionStage applyToEither
(CompletionStage extends T> other,
Function super T, U> fn);
// 當前與另外的 CompletableFuture 任何一個執行完成,將其傳遞給 action,不支持返回值
public CompletionStage<void> acceptEither
(CompletionStage extends T> other,
Consumer super T> action);
// 當前與另外的 CompletableFuture 任何一個執行完成,直接執行 action
public CompletionStage<void> runAfterEither(CompletionStage> other,
Runnable action);

/<void>/<void>

到此,CompletableFuture 的相關特性都介紹完了。

異步編程慢慢變得越來越成熟,Java 語言官網也開始支持異步編程模式,所以學好異步編程還是有必要的。

本文結合業務需求場景驅動,引出了 Future 設計模式實戰,然後對 JDK1.8 中的 CompletableFuture 是如何使用的,核心優勢、性能測試對比、使用擴展方面做了進一步剖析。

希望對大家有所幫助!

"


分享到:


相關文章: