Javaのfinallyでreturnするとtryの戻り値と例外が消える危険を解説する記事のアイキャッチ。

Java

Javaのfinallyでreturnしてはいけない理由|tryの戻り値と例外が消える

AIによる要約

Javaでは、tryでreturnしてもメソッドが即座に終了するとは限りません。finallyがある場合はfinallyを実行してから呼び出し元へ戻ります。そのfinallyにもreturnがあると、tryの戻り値は上書きされます。さらにtryで発生した例外まで外へ伝わらなくなるため、障害を正常終了に見せる危険があります。finallyはcloseやunlockなどの後処理に限定し、returnや業務判断を置かないのが基本です。

新人SE
新人SE
tryで`return "A"`しているなら、Aが返って終わりではないんですか?
ポンコツSE
ポンコツSE
finallyはreturnの後にも実行されます。しかもfinally側でreturnすると、先に決まっていた戻り値や例外を上書きします。

新人向けのJava解説では、finallyは「例外が起きても最後に実行される場所」と説明されます。説明自体は正しいのですが、現場で重要なのは、finallyが実行される順番と、finallyに何を書いてはいけないかです。

戻り値が違うだけなら単純なバグに見えます。しかし、finallyのreturnは例外も消します。バッチが失敗したのに成功件数を返す、外部APIエラーなのに正常レスポンスを返す、といった障害につながるため、コードレビューで確実に止めたい書き方です。

この記事のポイント

  • tryのreturn後にもfinallyは実行される
  • finallyのreturnがtry・catchの戻り値を上書きする
  • finallyのreturnは処理中の例外まで握り潰す
  • リソース解放はtry-with-resourcesを優先する
  • finallyには後処理だけを書き、業務判断を書かない

まずはXのJavaクイズを確認する

tryでAをreturnし、finallyでBをreturnするJavaコードについて、どちらが返るかを問うクイズ画像。
tryとfinallyの両方にreturnがある場合、どちらが返るでしょうか。
static String getValue() {
    try {
        return "A";
    } finally {
        return "B";
    }
}

答えはBです。tryでAを返そうとした後、finallyが実行され、そこでBをreturnするため、呼び出し元が受け取る値はBになります。

finallyのreturnがtryの戻り値を上書きし、答えはBになると説明するJavaクイズの回答画像。
finallyのreturnが、先に決まっていたtryの戻り値を上書きします。

元のクイズは、Xの出題ポスト回答ポストでも確認できます。

returnしてもfinallyが終わるまでは戻らない

return文が実行されると、Javaは返す値を決めます。ただしfinallyがある場合は、その値を呼び出し元へ渡す前にfinallyを実行します。finallyが正常に終われば、最初に決めた値が返ります。

static String getValue() {
    try {
        return "A";
    } finally {
        System.out.println("cleanup");
    }
}

この場合の出力は`cleanup`で、戻り値はAです。finallyが後処理だけを行っているため、tryで決めた戻り値は変わりません。

一方、finallyで別のreturnを実行すると、メソッドの最終的な終了方法がfinally側へ変わります。「後から実行されたreturnが勝つ」と覚えるだけでもクイズには答えられますが、現場では処理途中の失敗まで隠れる点を理解する必要があります。

finallyのreturnが危険な本当の理由

tryの戻り値が上書きされる

static boolean updateOrder() {
    try {
        repository.save();
        return true;
    } finally {
        auditLog();
        return false;
    }
}

保存に成功してもfalseが返ります。呼び出し元がfalseを見て再実行すれば、二重登録や二重送信の原因になります。戻り値が単なる画面表示ではなく、後続処理の分岐に使われるSIer案件では影響が広がります。

例外が外へ伝わらなくなる

static String loadName() {
    try {
        throw new IllegalStateException("DB connection failed");
    } finally {
        return "guest";
    }
}

このメソッドは`IllegalStateException`を呼び出し元へ投げず、`guest`を返します。catchして復旧したわけではありません。finallyのreturnによって、処理中だった例外が破棄された状態です。

障害が正常終了に見える

監視や共通例外ハンドラまで例外が届かないため、ERRORログやアラートも出ない可能性があります。利用者には既定値が表示され、裏ではデータ取得に失敗しているという調査しにくい状態になります。

catchの判断まで上書きされる

static int execute() {
    try {
        return 0;
    } catch (RuntimeException e) {
        return 1;
    } finally {
        return 9;
    }
}

正常時も例外時も9になります。tryとcatchに分けて書いた意図がすべて無効になるため、読んだ人の予測と実際の挙動が大きくずれます。こうしたコードは「動くか」ではなく「保守できるか」の観点でも採用できません。

finallyには何を書けばよいか

finallyの役割は、処理結果を決めることではなく、tryで確保したものを解放することです。代表例はロック解除、一時状態の復元、古いAPIで取得したリソースの後片付けです。

lock.lock();
try {
    updateSharedState();
} finally {
    lock.unlock();
}

unlockをfinallyに置けば、更新処理で例外が起きてもロックを解放できます。ここにreturnや「更新に失敗したら別の業務値を返す」といった判断は置きません。

close処理はtry-with-resourcesを優先する

ファイル、ストリーム、JDBCのConnectionなど`AutoCloseable`を実装するリソースは、finallyで手動closeするよりtry-with-resourcesを使います。

static List<String> readLines(Path path) throws IOException {
    try (BufferedReader reader = Files.newBufferedReader(path)) {
        return reader.lines().toList();
    }
}

正常時も例外時もreaderが閉じられます。また、本文処理とcloseの両方で例外が起きた場合、close側の例外はsuppressed exceptionとして保持されます。手書きfinallyより、元の例外を失いにくい構造です。

Spring Data JPAやJdbcTemplateを使う処理では、フレームワークが接続管理を担当することも多いため、案件の共通部品を確認せず独自にcloseしないでください。「finallyを使う知識」と「自分で管理すべきリソースか」の判断は別です。

finally内の処理も例外を起こす可能性がある

try {
    importOrders(file);
} finally {
    temporaryFileService.delete(file);
}

削除処理が例外を投げると、importOrdersで発生していた元の例外が見えにくくなる場合があります。後処理だから安全とは限りません。後処理の失敗をどう扱うか、元の失敗を失わないかを確認します。

RuntimeException failure = null;
try {
    importOrders(file);
} catch (RuntimeException e) {
    failure = e;
    throw e;
} finally {
    try {
        temporaryFileService.delete(file);
    } catch (RuntimeException cleanupError) {
        if (failure != null) {
            failure.addSuppressed(cleanupError);
        } else {
            throw cleanupError;
        }
    }
}

これは考え方を示す例で、毎回この処理を手書きするという意味ではありません。共通部品やtry-with-resourcesで吸収できるなら、そちらを優先します。大切なのは「後処理の例外で元の原因を消さない」という設計です。

JUnitで戻り値と例外を固定する

finally周辺を修正するときは、正常時の戻り値だけでなく例外が呼び出し元へ届くこともテストします。

@Test
void finallyのreturnがtryの戻り値を上書きする() {
    assertEquals("B", Sample.getValue());
}

@Test
void 例外を呼び出し元へ伝える() {
    IllegalStateException exception = assertThrows(
            IllegalStateException.class,
            () -> service.loadName());

    assertEquals("DB connection failed", exception.getMessage());
}

1つ目は危険な挙動を理解する学習用です。本番コードでBを期待する設計にしないでください。2つ目のように、例外型とメッセージを確認すると、将来finallyへreturnを追加して例外を消す変更を検知できます。

現場レビューでよくある指摘

// レビューコメント例
finallyのreturnがtryの戻り値と例外を上書きします。
finallyは後処理だけにし、戻り値はtryまたはメソッド末尾で決めてください。

// レビューコメント例
InputStreamはAutoCloseableなので、手動closeではなく
try-with-resourcesへ変更してください。

// レビューコメント例
後処理のdeleteが失敗すると、元の取込例外が見えなくなる可能性があります。
元例外を維持する扱いを確認してください。

レビューを受けたときは「結果が正しいので問題ない」と返すのではなく、例外経路まで確認します。finallyのreturnは、現在のテストデータでは問題が出なくても、障害時に最も困る種類の実装です。

提出前のセルフチェック

  • finallyにreturnを書いていないか
  • finallyで業務結果やレスポンスを決めていないか
  • 後処理の例外が元の例外を隠さないか
  • AutoCloseableをtry-with-resourcesで扱えないか
  • ロックは例外時にも必ず解除されるか
  • 正常時の戻り値をテストしたか
  • 異常時の例外型と原因をテストしたか
  • 共通例外ハンドラや監視まで例外が届くか

Javaの基本をコードで整理する参考書

例外、return、スコープなどの基本を一度体系的に整理すると、クイズだけでなくレビューでも処理順を説明しやすくなります。

書籍「スッキリわかるJava入門 第5版」の表紙
商品画像:Amazon.co.jp
PR Java初学者向け

スッキリわかるJava入門 第5版

中山 清喬 (著), 国本 大悟 (著), 株式会社フレアリンク (監修)

Java文法とオブジェクト指向を、最初から整理する一冊。

豊富な図とサンプルで、変数、条件分岐、クラス、例外処理まで順番に学べます。Javaの最初の教科書を探している人向けです。

  • Javaを基礎から体系的に学びたい
  • Java 25 LTS対応の入門書を選びたい

当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。

この記事とあわせて読みたい

まとめ

tryのreturnは、finallyが終わるまで呼び出し元へ返りません。finallyにreturnを書くと、tryやcatchの戻り値を上書きし、処理中の例外まで消してしまいます。

finallyはcloseやunlockなどの後処理に限定し、returnや業務判断を置かないでください。AutoCloseableはtry-with-resourcesを優先し、正常時の戻り値と異常時の例外経路をテストすれば、現場で調査しにくい障害を防げます。

-Java
-, , ,