こんにちは、かつコーチです。
今回はFutureをさらに使いやすくしたCompletableFutureを扱います。ExecutorServiceとFutureだけでは非同期処理の連結や組み合わせが書きにくいと感じている方向けの内容です。
Futureだけでは足りない場面
Future.get()がブロッキングである問題
以前扱ったFutureは、結果を取り出すget()が呼び出し元スレッドをブロックします。
複数の非同期処理を連結したいとき、Futureだけでは以下のようにコードが煩雑になります。
// ❌ Before:Futureの連結が読みにくい
ExecutorService executor = Executors.newFixedThreadPool(2);
Future<String> userFuture = executor.submit(() -> fetchUser());
String user = userFuture.get(); // ここでブロック
Future<String> orderFuture = executor.submit(() -> fetchOrders(user));
String orders = orderFuture.get(); // さらにブロック
System.out.println(orders);
executor.shutdown();
処理を1つずつget()で待ってから次を投げる形になり、非同期処理の利点であるはずの並行性がほとんど活かせません。
また、複数の非同期処理の結果を組み合わせる処理を書こうとすると、コードがネストして読みにくくなります。
CompletableFutureで非同期処理を連結する
✅ After:thenApplyでチェーンする
CompletableFutureはメソッドチェーンで非同期処理をつなげられます。
CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> fetchUser()) // 非同期でユーザー取得
.thenApply(user -> fetchOrders(user)) // 結果を受けて注文取得
.thenApply(orders -> "注文件数: " + orders.size());
System.out.println(future.join()); // 結果を取り出す(joinは検査例外なし版のget)
supplyAsyncは戻り値のある非同期処理を開始し、thenApplyは前の処理結果を受け取って変換します。get()やjoin()を呼ぶまで各処理は裏側で進行するため、Futureのように途中で明示的にブロックする必要がありません。
主なメソッドの使い分け
| メソッド | 用途 |
|---|---|
supplyAsync(Supplier) | 戻り値ありの非同期処理を開始する |
runAsync(Runnable) | 戻り値なしの非同期処理を開始する |
thenApply(Function) | 結果を受け取り、変換した値を返す |
thenAccept(Consumer) | 結果を受け取るが、値は返さない |
thenRun(Runnable) | 前の処理の結果を使わず、次の処理を実行する |
thenCompose(Function) | 結果を受け取り、別のCompletableFutureにつなげる(ネスト解消) |
thenApplyとthenComposeの違いでハマったポイント
thenApplyの変換関数がCompletableFutureを返すメソッドを呼んでしまい、戻り値の型がCompletableFuture<CompletableFuture<String>>という二重構造になってコンパイルエラーになったことがあります。
// ❌ Before:thenApplyでCompletableFutureを返すメソッドを呼ぶと二重構造になる
CompletableFuture<CompletableFuture<String>> nested = CompletableFuture
.supplyAsync(() -> fetchUser())
.thenApply(user -> fetchOrdersAsync(user)); // fetchOrdersAsyncがCompletableFutureを返す
// ✅ After:ネストする場合はthenComposeでフラットにする
CompletableFuture<String> flat = CompletableFuture
.supplyAsync(() -> fetchUser())
.thenCompose(user -> fetchOrdersAsync(user)); // 型がCompletableFuture<String>のまま
判断軸は明確で、変換関数が普通の値を返すならthenApply、CompletableFutureを返すならthenComposeです。
Stream APIのmapとflatMapの関係に似ており、同じ発想で整理すると理解しやすくなります。
複数の非同期処理を組み合わせる
allOfで複数タスクの完了を待つ
CompletableFuture<String> userTask = CompletableFuture.supplyAsync(() -> fetchUser());
CompletableFuture<String> orderTask = CompletableFuture.supplyAsync(() -> fetchOrders("u1"));
CompletableFuture<Void> combined = CompletableFuture.allOf(userTask, orderTask);
combined.join(); // 両方完了するまで待つ
System.out.println(userTask.join() + ", " + orderTask.join());
allOfは全タスクの完了を待つだけで、直接結果をまとめて返してはくれない点に注意が必要です。
結果を組み合わせたい場合はthenCombineを使います。
CompletableFuture<String> combined2 = userTask.thenCombine(
orderTask,
(user, orders) -> user + "の注文: " + orders
);
System.out.println(combined2.join());
例外処理:exceptionallyとhandle
非同期処理中に例外が発生した場合の扱いも組み込まれています。
CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> {
if (Math.random() < 0.5) {
throw new RuntimeException("取得失敗");
}
return fetchUser();
})
.exceptionally(ex -> {
System.out.println("エラー発生: " + ex.getMessage());
return "デフォルトユーザー"; // 例外時の代替値
});
System.out.println(future.join());
exceptionallyは例外発生時のみ実行され、正常終了時は無視されます。
成功・失敗どちらの場合も共通処理を書きたい場合はhandleを使います。
まとめ
この記事のポイント
CompletableFutureはFutureと違い、メソッドチェーンで非同期処理を宣言的に連結できる- 変換関数が通常の値を返すなら
thenApply、CompletableFutureを返すならthenComposeを使う - 複数タスクの完了待ちは
allOf、結果を組み合わせたいときはthenCombineを使う - 例外処理は
exceptionallyやhandleで非同期処理の流れの中に組み込める
次に読むべき記事
- スレッドプールでのタスク管理は「ExecutorServiceでスレッドプールを使う」の記事へ
- 共有データの排他制御は「synchronizedと排他制御」の記事へ
タグ: Java, 上級者向け, 並行処理