まず「非同期の例外」がなぜややこしいのか
同期コードなら、try-catch で囲めばだいたい済みます。
try {
int x = doSomething(); // ここで例外が出たら catch に飛ぶ
System.out.println(x);
} catch (Exception e) {
e.printStackTrace();
}
Javaでも、非同期になると「例外が起きる場所」と「それを扱いたい場所」が時間的にもスレッド的にも離れます。CompletableFuture や ExecutorService を使うとき、
「例外はどこに行くのか」「どこで拾えばいいのか」を意識しないと、
例外が黙って飲み込まれたり、気づいたらログにだけ出ていたりします。
ここでは主に CompletableFuture を軸に、非同期の例外処理を整理していきます。
スレッドと ExecutorService の例外の基本
Thread 直書きの場合の例外
new Thread(...).start() でスレッドを作った場合、
その中で投げられた例外は「そのスレッドの中で」処理されます。
Thread t = new Thread(() -> {
System.out.println("start");
int x = 1 / 0; // ここで ArithmeticException
System.out.println("end"); // ここには来ない
});
t.start();
Javaこの例外は、呼び出し元のスレッド(main など)には飛んできません。
デフォルトでは、そのスレッドのスタックトレースが標準エラーに出て終わりです。
つまり、「非同期で投げた例外は、呼び出し元の try-catch では捕まらない」というのが大前提です。
ExecutorService + Runnable の場合
ExecutorService に Runnable を投げた場合も同様で、
タスク内で投げられた例外は呼び出し元には戻ってきません。
ExecutorService executor = Executors.newSingleThreadExecutor();
executor.execute(() -> {
System.out.println("task");
throw new RuntimeException("失敗");
});
executor.shutdown();
Javaここでも、例外はそのスレッドの中で発生し、
デフォルトではログに出るだけです。
「非同期タスクの例外を“値として扱いたい”」ときに必要になるのが、Future や CompletableFuture です。
Future と例外:get したときに初めて「表に出る」
Callable + Future の場合
ExecutorService.submit(Callable) を使うと、
戻り値として Future<V> が返ってきます。
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> future = executor.submit(() -> {
System.out.println("計算開始");
if (true) {
throw new RuntimeException("計算失敗");
}
return 42;
});
try {
Integer result = future.get(); // ここで例外が表に出る
} catch (ExecutionException e) {
System.out.println("タスク内例外: " + e.getCause());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
Javaポイントは、
タスク内で例外が起きても、その場では「Future が例外状態になるだけ」。get() した瞬間に ExecutionException としてラップされて飛んでくる。
ということです。
「非同期タスクの例外を呼び出し元で扱いたいなら、get()(あるいは join())を呼ぶ必要がある」
という感覚を持っておいてください。
CompletableFuture の例外の基本:join で表に出る
supplyAsync + join の例
CompletableFuture も同じ構造です。
CompletableFuture<Integer> future =
CompletableFuture.supplyAsync(() -> {
System.out.println("計算開始");
if (true) {
throw new RuntimeException("失敗");
}
return 42;
});
Integer result = future.join(); // ここで CompletionException が飛ぶ
Javajoin() を呼ぶと、
タスク内の例外は CompletionException にラップされて飛んできます。
get() を使うと ExecutionException、join() を使うと CompletionException。
どちらも「中身の例外は getCause() に入っている」という点は同じです。
CompletableFuture の「流れの中」で例外を扱う
exceptionally:失敗したときだけ「代替値」を返す
exceptionally は、
「非同期処理が例外で終わったときにだけ呼ばれるハンドラ」です。
CompletableFuture<Integer> future =
CompletableFuture.supplyAsync(() -> {
if (true) {
throw new RuntimeException("失敗");
}
return 10;
}).exceptionally(ex -> {
System.out.println("エラー: " + ex.getMessage());
return 0; // 代替値
});
Integer result = future.join(); // 0
Javaここでの流れはこうです。
正常に終われば、そのまま値が流れる。
例外で終われば、exceptionally が呼ばれ、そこで返した値が「最終結果」になる。
「失敗したらデフォルト値にする」「ログだけ出して 0 を返す」
といった「失敗を“成功値”に変換する」場面でよく使います。
handle:成功・失敗どちらも一箇所で扱う
handle は、
「成功時の結果と、失敗時の例外の両方を受け取って処理する」メソッドです。
CompletableFuture<Integer> future =
CompletableFuture.supplyAsync(() -> {
if (Math.random() < 0.5) {
return 10;
} else {
throw new RuntimeException("失敗");
}
}).handle((value, ex) -> {
if (ex != null) {
System.out.println("エラー: " + ex.getMessage());
return 0;
} else {
return value * 2;
}
});
Integer result = future.join();
Javahandle の引数は (結果, 例外) の 2 つ。
成功時:結果 に値が入り、例外 は null。
失敗時:結果 は未定義、例外 に Throwable が入る。
「成功ならこう、失敗ならこう」と一箇所で分岐したいときに便利です。
thenApply / thenCompose との組み合わせでの例外伝播
thenApply での例外
thenApply の中で例外を投げると、その時点で Future は「例外状態」になります。
CompletableFuture<Integer> future =
CompletableFuture.supplyAsync(() -> 10)
.thenApply(x -> {
if (x == 10) {
throw new RuntimeException("変換失敗");
}
return x * 2;
});
future.join(); // CompletionException(原因は RuntimeException)
Javaこの例外は、その後のチェーンにも伝播します。
途中で exceptionally や handle を挟まない限り、
最後の join() まで例外状態のまま流れていきます。
thenCompose での例外
thenCompose でつないだ先の非同期処理内で例外が起きた場合も同様です。
CompletableFuture<Integer> future =
CompletableFuture.supplyAsync(() -> "user-123")
.thenCompose(id -> fetchUserAsync(id)) // 中で例外が出るかもしれない
.thenApply(user -> user.getAge());
CompletableFuture<User> fetchUserAsync(String id) {
return CompletableFuture.supplyAsync(() -> {
throw new RuntimeException("ユーザー取得失敗");
});
}
JavafetchUserAsync 内の例外は、future.join() で CompletionException として表に出てきます。
つまり、
チェーンのどこかで例外が起きると、その後ろは全部「例外状態の Future」になる。
途中で exceptionally や handle を挟めば、そこで「回復」させられる。
というイメージです。
「どこで例外を潰し、どこまで伝播させるか」を設計する
例外を「ログだけ出して握りつぶす」のは慎重に
exceptionally や handle で例外をキャッチして、
ログだけ出して「とりあえず 0 を返す」ようなコードは簡単に書けます。
.thenApply(...)
.exceptionally(ex -> {
log.error("失敗", ex);
return 0;
})
Javaただし、これを乱用すると、
「本来は上位に伝えるべき致命的なエラー」まで
静かに握りつぶしてしまう危険があります。
設計としては、
ここで本当に「成功値に変換」してよいのか?
それとも「ここではログだけ出して、例外のまま上に流すべき」か?
を意識して決める必要があります。
「境界」で例外をまとめて扱う
よくあるパターンは、
ドメインロジックの中では例外をそのまま伝播させ、
「外側の境界」(例えば Web コントローラやバッチのエントリポイント)でhandle や exceptionally を使ってまとめて扱う、という設計です。
例えば、Web API のハンドラで:
CompletableFuture<Response> responseFuture =
service.doAsyncSomething(request)
.handle((value, ex) -> {
if (ex != null) {
log.error("非同期処理失敗", ex);
return errorResponse();
} else {
return successResponse(value);
}
});
return responseFuture; // フレームワーク側が join してくれる想定
Javaこうすると、
内部の非同期チェーンでは「例外は例外のまま」流し、
一番外側で「HTTP レスポンスに変換する」責務を集中させられます。
まとめ:非同期例外処理を自分の言葉で説明するなら
あなたの言葉で整理すると、こうなります。
「非同期処理の例外は、呼び出し元の try-catch には直接飛んでこない。Future や CompletableFuture の“状態”として保持され、get() や join() を呼んだとき、あるいはexceptionally や handle などのハンドラで初めて“表に出る”。
exceptionally は『失敗したときだけ代替値を返す』ためのもの、handle は『成功と失敗を一箇所で分岐して扱う』ためのもの。
チェーンのどこかで例外が起きると、その後ろは例外状態の Future になり、
途中でハンドラを挟めばそこで“回復”させられる。
大事なのは、
『どこで例外を握りつぶし、どこまで伝播させるか』を設計として決めること。
内部では例外をそのまま流し、外側の境界でまとめて
ログ・エラーレスポンス・リトライなどに変換する、という構図を意識すると、
非同期の例外処理が“見える化”されて、怖くなくなる。」
