Java | Java 詳細・モダン文法:並行・非同期 – 非同期例外処理

Java Java
スポンサーリンク

まず「非同期の例外」がなぜややこしいのか

同期コードなら、try-catch で囲めばだいたい済みます。

try {
    int x = doSomething(); // ここで例外が出たら catch に飛ぶ
    System.out.println(x);
} catch (Exception e) {
    e.printStackTrace();
}
Java

でも、非同期になると「例外が起きる場所」と「それを扱いたい場所」が時間的にもスレッド的にも離れます。
CompletableFutureExecutorService を使うとき、
「例外はどこに行くのか」「どこで拾えばいいのか」を意識しないと、
例外が黙って飲み込まれたり、気づいたらログにだけ出ていたりします。

ここでは主に 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 の場合

ExecutorServiceRunnable を投げた場合も同様で、
タスク内で投げられた例外は呼び出し元には戻ってきません。

ExecutorService executor = Executors.newSingleThreadExecutor();

executor.execute(() -> {
    System.out.println("task");
    throw new RuntimeException("失敗");
});

executor.shutdown();
Java

ここでも、例外はそのスレッドの中で発生し、
デフォルトではログに出るだけです。

「非同期タスクの例外を“値として扱いたい”」ときに必要になるのが、
FutureCompletableFuture です。


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 が飛ぶ
Java

join() を呼ぶと、
タスク内の例外は 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();
Java

handle の引数は (結果, 例外) の 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

この例外は、その後のチェーンにも伝播します。
途中で exceptionallyhandle を挟まない限り、
最後の 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("ユーザー取得失敗");
    });
}
Java

fetchUserAsync 内の例外は、
future.join()CompletionException として表に出てきます。

つまり、

チェーンのどこかで例外が起きると、その後ろは全部「例外状態の Future」になる。
途中で exceptionallyhandle を挟めば、そこで「回復」させられる。

というイメージです。


「どこで例外を潰し、どこまで伝播させるか」を設計する

例外を「ログだけ出して握りつぶす」のは慎重に

exceptionallyhandle で例外をキャッチして、
ログだけ出して「とりあえず 0 を返す」ようなコードは簡単に書けます。

.thenApply(...)
.exceptionally(ex -> {
    log.error("失敗", ex);
    return 0;
})
Java

ただし、これを乱用すると、
「本来は上位に伝えるべき致命的なエラー」まで
静かに握りつぶしてしまう危険があります。

設計としては、

ここで本当に「成功値に変換」してよいのか?
それとも「ここではログだけ出して、例外のまま上に流すべき」か?

を意識して決める必要があります。

「境界」で例外をまとめて扱う

よくあるパターンは、

ドメインロジックの中では例外をそのまま伝播させ、
「外側の境界」(例えば Web コントローラやバッチのエントリポイント)で
handleexceptionally を使ってまとめて扱う、という設計です。

例えば、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 には直接飛んでこない。
FutureCompletableFuture の“状態”として保持され、
get()join() を呼んだとき、あるいは
exceptionallyhandle などのハンドラで初めて“表に出る”。

exceptionally は『失敗したときだけ代替値を返す』ためのもの、
handle は『成功と失敗を一箇所で分岐して扱う』ためのもの。
チェーンのどこかで例外が起きると、その後ろは例外状態の Future になり、
途中でハンドラを挟めばそこで“回復”させられる。

大事なのは、
『どこで例外を握りつぶし、どこまで伝播させるか』を設計として決めること。
内部では例外をそのまま流し、外側の境界でまとめて
ログ・エラーレスポンス・リトライなどに変換する、という構図を意識すると、
非同期の例外処理が“見える化”されて、怖くなくなる。」

タイトルとURLをコピーしました