Article

🔍 Javaのtry-with-resources:本当の原因を隠さないsuppressed例外の読み方

java, exception, backend

ファイルやDB接続を使うJavaの処理では、try-with-resourcesを使うと終了時にclose()を呼び出せます。

ただし、本来の処理も、後片付けのclose()も失敗した場合、どちらの例外が残るのでしょうか?

障害調査で「接続を閉じるときのエラーだけを見て、SQL実行時の失敗を見落とす」といった混乱を防ぐため、Javaのsuppressed exception(抑制された例外)を実行例で確認します。

対象:Java 17以降で動くサンプル。言語仕様は2026年10月26日時点のJava SE 27の公式仕様で確認し、サンプルはOpenJDK 21で--release 17を指定してコンパイル・実行しました。

1. try-with-resourcesは何をしている?

リソースとは、使用後に解放が必要なファイル、ストリーム、DB接続などのことです。

AutoCloseableというインターフェース(クラスが実装する機能の契約)のclose()を持つオブジェクトをtry (...)に記述すると、処理が正常終了しても例外終了しても、後片付けのためにclose()が呼ばれます。

try (var input = java.nio.file.Files.newInputStream(path)) {
    // ファイルを読み込む
} // ここを抜けるとinput.close()が呼ばれる

pathはjava.nio.file.Path型の変数を想定した抜粋です。

ただし、「close()を試みる」ことと「解放が必ず成功する」ことは別です。close()自身が例外を投げる場合もあります。

2. 本体とclose()が両方失敗するとどうなる?

そのまま動かせる例をSuppressedExceptionDemo.javaとして保存します。

import java.io.IOException;

public class SuppressedExceptionDemo {
    static final class Resource implements AutoCloseable {
        private final String name;

        Resource(String name) {
            this.name = name;
        }

        @Override
        public void close() throws IOException {
            System.out.println("close: " + name);
            throw new IOException("close " + name + " failed");
        }
    }

    public static void main(String[] args) {
        try (Resource first = new Resource("first");
             Resource second = new Resource("second")) {
            throw new IllegalStateException("operation failed");
        } catch (Exception e) {
            System.out.println("primary: " + e.getMessage());
            for (Throwable suppressed : e.getSuppressed()) {
                System.out.println("suppressed: " + suppressed.getMessage());
            }
        }
    }
}

ターミナルで次の2コマンドを実行します。

javac --release 17 SuppressedExceptionDemo.java
java SuppressedExceptionDemo
  • javac:Javaのソースをコンパイル(実行できるクラスファイルへ変換)するコマンド。
  • --release 17:Java 17向けの言語・API・クラスファイルとしてコンパイルするオプション。JDK 17以上のjavacが必要です。
  • java SuppressedExceptionDemo:生成したクラスのmainメソッドを実行するコマンド。

実行結果:

close: second
close: first
primary: operation failed
suppressed: close second failed
suppressed: close first failed

ここで確認できるのは、次の3点です。

  1. 複数のリソースは、宣言と逆順にclose()される(second → first)。
  2. tryの本体で発生したIllegalStateExceptionが主例外(外側へ通知される中心の例外)になる。
  3. 後片付け時の2つのIOExceptionは消えず、主例外のgetSuppressed()から取得できる。

**抑制された例外(suppressed exception)**は「無視されるエラー」ではありません。主例外を上書きせずに、後から診断できるよう保存された例外です。

3. 本体が成功した場合は?

本体のthrow new IllegalStateException(...)を、例えばSystem.out.println("operation ok");に置き換えてみてください。

この場合、先に失敗するsecond.close()のIOExceptionが主例外になります。後から失敗するfirst.close()のIOExceptionは、その主例外のgetSuppressed()に入ります。

つまり、常に本体側の例外が主例外になるのではなく、先に発生した失敗の有無で決まる、ということです。

Java言語仕様では、複数リソースのtry-with-resourcesは入れ子の処理に相当し、後から宣言したリソースから閉じると定義されています。

4. getCause()とgetSuppressed()を混同しない

Javaの例外には、似ている2つの参照があります。

API 意味 代表例
getCause() この例外の原因となった別の例外 new IllegalStateException("保存失敗", sqlException)
getSuppressed() 主例外を優先したため、外側へ直接投げなかった別の例外 try本体の失敗に続くclose()の失敗

例えば業務例外に変換するときは、元の例外を原因(cause)として保持します。

try {
    saveOrder();
} catch (java.sql.SQLException e) {
    throw new IllegalStateException("注文の保存に失敗しました", e);
}

saveOrder()はSQLExceptionを投げ得る処理を表す抜粋です。こうしておけば、元の例外のスタックトレースやsuppressed情報をgetCause()経由でたどれます。

反対に、new IllegalStateException(e.getMessage())だけで包むと、原因の例外オブジェクトを失います。

5. JDBCでの使いどころ

JDBCは、JavaからリレーショナルDBにアクセスするための標準APIです。Connection、PreparedStatement、ResultSetなどのリソースを扱います。

以下は、DataSource dataSource(接続を取得する仕組み)とlong userIdが利用可能なメソッド内の抜粋です。

String sql = "SELECT name FROM users WHERE id = ?";

try (java.sql.Connection connection = dataSource.getConnection();
     java.sql.PreparedStatement statement = connection.prepareStatement(sql)) {
    statement.setLong(1, userId);

    try (java.sql.ResultSet rows = statement.executeQuery()) {
        if (rows.next()) {
            System.out.println(rows.getString("name"));
        }
    }
} catch (java.sql.SQLException e) {
    // ロガーに例外オブジェクトを渡すと、原因やsuppressedの追跡に役立つ
    e.printStackTrace(); // 説明用。本番では利用中のロガーを使う
    throw new IllegalStateException("ユーザー取得に失敗しました", e);
}

このコードは自前で接続を取得・所有するケースの例です。Springの@TransactionalやJdbcTemplateなど、フレームワークが接続・トランザクションを管理する場合には、そちらの管理方法に従ってください。むやみに接続を手動で閉じる実装へ置き換えないことが重要です。

6. 障害調査とテストでのハマりどころ

  • close()側のログだけで原因を断定しない:主例外の種類、getCause()、getSuppressed()を確認します。printStackTrace()は通常、抑制された例外も表示します。
  • 例外メッセージだけを再throwしない:元のThrowableをcauseに渡して保持します。
  • close()が必ず成功すると思わない:自動で呼ばれても、I/O障害などでクローズは失敗し得ます。
  • 手書きのfinallyへ安易に戻さない:finally中の例外で本体の例外が置き換わる書き方をすると、調査すべき失敗を見失います。
  • テストでは2種類の失敗を分ける:「処理中に失敗+closeも失敗」と「処理は成功+closeで失敗」の両方を試すと、主例外の違いを検証できます。

まとめ

try-with-resourcesの利点は後片付けを短く書けることだけではありません。本体処理の失敗を主例外として残し、後片付けの失敗もsuppressedとして保持することで、障害の全体像を追いやすくなります。

本番ログでSuppressed:という行を見つけたら、無関係な情報として切り捨てず、主例外とセットで読むのが重要です。

参考資料