Javaのtry-with-resourcesでclose()の例外はどこへ行くのか: suppressed例外の読み方

tryブロックとclose()の両方が例外を投げたとき、何が伝播し何がgetSuppressed()に残るのかを、リソースを閉じる順序とあわせてJLS 14.20.3に沿って整理します。

javaexceptionstry-with-resourcesjls

try-with-resources文の本体で例外が起き、リソースを閉じるclose()も例外を投げた場合、呼び出し側が受け取るのはどちらでしょうか。この挙動はJava言語仕様(JLS)で決まっています。tryブロックの例外が伝播し、close()の例外はその例外のsuppressedリストに追加されます。消えるわけではありませんが、メッセージだけをログに出すコードでは目に入りません。

この記事はJava SE 25のドキュメントを基準にしています。JLS §14.20.3、ThrowableとAutoCloseableのAPIドキュメントを2026-10-12に確認しました。25を基準に選んだだけで、最新バージョンだという意味ではありません。APIドキュメントによれば、addSuppressed()とgetSuppressed()はJava 1.7から存在します。

サンプルプログラム

この記事のシナリオはすべて次の1ファイルで確認できます。Resは生成時と終了時に名前を出力し、failOnCloseがtrueならclose()でIllegalStateExceptionを投げます。run()はシナリオが投げた例外のメッセージとgetSuppressed()の中身を出力します。

public class TwrDemo {
    static final class Res implements AutoCloseable {
        private final String name;
        private final boolean failOnClose;

        Res(String name, boolean failOnClose) {
            this.name = name;
            this.failOnClose = failOnClose;
            System.out.println("open " + name);
        }

        @Override
        public void close() {
            System.out.println("close " + name);
            if (failOnClose) {
                throw new IllegalStateException("close " + name + " failed");
            }
        }
    }

    static void bodyAndCloseFail() {
        try (Res a = new Res("A", true)) {
            throw new RuntimeException("body failed");
        }
    }

    static void onlyCloseFails() {
        try (Res a = new Res("A", true)) {
            System.out.println("body ok");
        }
    }

    static void closeOrder() {
        try (Res a = new Res("A", false); Res b = new Res("B", false)) {
            System.out.println("body ok");
        }
    }

    static void twoClosesFail() {
        try (Res a = new Res("A", true); Res b = new Res("B", true)) {
            System.out.println("body ok");
        }
    }

    static void bodyAndTwoClosesFail() {
        try (Res a = new Res("A", true); Res b = new Res("B", true)) {
            throw new RuntimeException("body failed");
        }
    }

    static void naiveFinally() {
        Res a = new Res("A", true);
        try {
            throw new RuntimeException("body failed");
        } finally {
            a.close();
        }
    }

    static void run(String label, Runnable scenario) {
        System.out.println("== " + label);
        try {
            scenario.run();
            System.out.println("no exception");
        } catch (RuntimeException e) {
            System.out.println("thrown: " + e.getMessage());
            for (Throwable s : e.getSuppressed()) {
                System.out.println("  suppressed: " + s.getMessage());
            }
        }
    }

    public static void main(String[] args) {
        run("1 body + close", TwrDemo::bodyAndCloseFail);
        run("2 close only", TwrDemo::onlyCloseFails);
        run("3 close order", TwrDemo::closeOrder);
        run("4 two closes", TwrDemo::twoClosesFail);
        run("5 body + two closes", TwrDemo::bodyAndTwoClosesFail);
        run("6 naive finally", TwrDemo::naiveFinally);
    }
}

javac TwrDemo.javaでコンパイルし、java TwrDemoで実行します。以下にシナリオごとに分けて載せた出力は、JLS §14.20.3の規則から導かれる結果です。

本体とclose()の両方が失敗したとき

== 1 body + close
open A
close A
thrown: body failed
  suppressed: close A failed

呼び出し側が受け取るのはtryブロックで投げたbody failedで、close A failedはその例外のgetSuppressed()配列に入っています。JLS §14.20.3.1は、tryブロックが値Vを投げ、自動クローズがV2を投げた場合、文全体はVを投げ、V2はVのsuppressed例外リストに追加されると定めています。

同じ節にある変換後のコードを見ると、仕組みがはっきりします。コンパイラが生成するfinallyは、すでに捕捉した主例外(#primaryExc)があればclose()を改めてtryで包み、そこで出た例外を#primaryExc.addSuppressed(...)で追加します。

close()だけが失敗したとき

== 2 close only
open A
body ok
close A
thrown: close A failed

tryブロックは正常に終わったので、追加先になる主例外がありません。この場合はclose()の例外がそのまま伝播し、suppressed配列は空なのでsuppressed:の行は出ません。変換後のコードでは、#primaryExcがnullで、close()を包まずに直接呼ぶ分岐にあたります。

閉じる順序は宣言の逆順

== 3 close order
open A
open B
body ok
close B
close A
no exception

リソースは左から順に初期化され、その逆順に閉じられます(§14.20.3)。複数のリソースを持つ文は、リソース1つのtry-with-resources文を入れ子にしたものとして扱われるため、後から開いたBが内側にあり、先に閉じられます。

複数のclose()が失敗したとき

あるリソースのclose()が例外を投げても、残りのリソースは引き続き閉じられます。そして、それ以前に初期化式、tryブロック、別のリソースのクローズで例外が出ていれば、後から出たクローズの例外はsuppressedになります(§14.20.3)。

まず、本体は正常で2つのclose()がどちらも失敗する場合です。

== 4 two closes
open A
open B
body ok
close B
close A
thrown: close B failed
  suppressed: close A failed

伝播するのはclose B failedです。先に閉じられる側、つまり宣言の一番右にあるリソースの例外が主例外になり、左側のリソースの例外がそこに追加されます。JLSはこれを、V1(一番右のリソースのクローズ失敗)が投げられ、V2…VnがV1のsuppressedリストに追加される、と表現しています。「最初に宣言したリソースが主例外」と覚えていると、逆に読んでしまいます。

本体も失敗すると、主例外は再び本体の例外になります。

== 5 body + two closes
open A
open B
close B
close A
thrown: body failed
  suppressed: close B failed
  suppressed: close A failed

入れ子の変換をたどると、内側の文がBを閉じてclose B failedを本体の例外に追加し、外側の文がAを閉じてclose A failedを同じ例外に追加します。addSuppressed()はリストの末尾に追加(append)するので、配列には閉じた順にB、Aが入ります。Throwable.printStackTrace()のドキュメントにある出力例も、Resource ID = 2の次にResource ID = 1が来る同じ形です。

初期化の段階で失敗した場合も同じ規則に従います。あるリソースの初期化式が例外を投げると、それまでに初期化されたリソースだけが閉じられ、そのクローズで出た例外は初期化時の例外にsuppressedとして追加されます。

getSuppressed()で確認する

Throwable.getSuppressed()は、この例外を伝えるために抑制された例外を配列で返します。抑制された例外がなければ空の配列を返し、返された配列に書き込んでも以後の呼び出し結果には影響しません。

空の配列になるケースはもう1つあります。例外クラスがThrowableの4引数コンストラクタでsuppressionを無効にしている場合、addSuppressed()は引数の検証以外に何もしません。そのような例外が主例外なら、close()の例外はどこにも記録されません。

printStackTrace()は、suppressed例外を主例外のスタックトレースの下に、さらにインデントして出力します。正確な形式は実装によって異なりますが、ドキュメントの例ではSuppressed:で始まる行として表示されています。一方、上のrun()のようにgetMessage()だけを記録するコードでは、getSuppressed()を自分でたどらない限りクローズの失敗は見えません。cause(getCause())とsuppressedは別の関係で、1つの例外が両方を持てることもaddSuppressed()のドキュメントに書かれています。

finallyで自分で閉じた場合

== 6 naive finally
open A
close A
thrown: close A failed

naiveFinally()はtry-with-resourcesを使わず、finallyでclose()を呼びます。本体が投げたbody failedは結果のどこにもありません。JLS §14.20.2によれば、tryブロックがVを投げ、finallyブロックが理由Sで中断すると、try文は理由Sで終わり、Vは破棄されます。

正確に言えば、このように消えるのはfinally内のclose()が実際に例外を投げたときだけです。close()が正常に終われば、本体の例外はそのまま伝播します。また、手書きのfinallyがすべてこうなるわけでもありません。JLSの変換後のコードのように主例外を変数に保持し、close()をtryで包んでその例外をaddSuppressed()で追加すれば、同じ結果になります。try-with-resourcesは、そのコードをコンパイラが代わりに書いてくれるものだと言えます。

catchやfinallyを併用するとき

try-with-resourcesにcatchやfinallyを付けると、JLS §14.20.3.2はそれを、基本のtry-with-resources文を外側のtry文で包んだものとして解釈します。そのためcatchやfinallyブロックが実行される時点で、すべてのリソースは閉じられているか、閉じる試みが済んでおり、そのcatchではリソースの初期化やクローズで出た例外も捕捉できます。

close()からInterruptedExceptionを投げない

AutoCloseable.close()のAPIノートは、実装がclose()からInterruptedExceptionを投げないよう強く勧めています。この例外はスレッドの割り込み状態と関わっており、suppressedになると実行時に誤動作が起きる可能性が高い、というのが理由です。同じノートは、より一般的に、抑制されると問題になる例外ならclose()から投げるべきではないとも述べています。

同じノートには、close()が失敗しうるなら、例外を投げる前に下位のリソースを解放し、リソースを閉じた状態として記録しておくようにという勧告もあります。close()が複数回呼ばれる可能性は低いからです。

参考資料

いずれも2026-10-12に確認しました。

ブログへ