SJ blog
backend
A

信頼度ランク

S 公式ソース確認済み
A 成功実績多数・失敗例少数
B 賛否両論
C 動作未確認・セキュリティリスク高
Z 個人所感

例外・スタックトレースの正体 ── try-catch-finally がメモリ上で何をしているか

例外はオブジェクト。スタックトレースはスタックのスナップショット。finally が return より強い理由。try-with-resources が AutoCloseable を呼ぶ仕組み。例外の構造をメモリから理解する。

一言結論

例外は throw した瞬間にスタックを巻き戻す。finally はそのどこかで必ず割り込む。スタックトレースは「throw した瞬間のスタックの全フレーム」の記録だ。

Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "str" is null
    at Main.process(Main.java:15)
    at Main.run(Main.java:8)
    at Main.main(Main.java:3)

このスタックトレースを「エラーメッセージ」として読んでいるだけなら、構造の半分しか見えていない。スタックトレースは**「throw した瞬間のスタックの全フレーム」のスナップショット**だ。


1. 例外はオブジェクト

Java の例外は全部ヒープ上のオブジェクトだ。throw するということは「例外オブジェクトを new して JVM に投げる」ことだ。

throw new IllegalArgumentException("値が不正です");

これは次と概念的に同じだ:

IllegalArgumentException e = new IllegalArgumentException("値が不正です");
// e はヒープ上のオブジェクトへの参照
throw e; // JVM に渡す
【例外オブジェクトの構造(ヒープ)】

┌───────────────────────────────────────┐
│ IllegalArgumentException              │
│ ├─ message: "値が不正です"            │
│ ├─ cause: null(原因例外)            │
│ └─ stackTrace: [                      │
│       {class:Main, method:validate,   │
│        file:Main.java, line:22},      │
│       {class:Main, method:process, ...│
│       ...                             │
│    ]                                  │
└───────────────────────────────────────┘

stackTrace フィールドに、throw した瞬間のスタックの全フレーム情報が格納される。これがあの縦長のログの正体だ。


2. スタックトレースの読み方

Exception in thread "main" java.lang.NullPointerException
    at Main.process(Main.java:15)   ← 一番上が throw 発生箇所
    at Main.run(Main.java:8)        ↓ 下に行くほど呼び出し元
    at Main.main(Main.java:3)       ← 一番下が main メソッド

スタックと対応させると:

【throw 発生時のスタック(上が先に積まれた)】

高アドレス
┌──────────────────────────┐
│ main フレーム(line:3)  │  ← スタックトレースの一番下
├──────────────────────────┤
│ run フレーム(line:8)   │
├──────────────────────────┤
│ process フレーム(line:15)← ここで throw → 一番上に表示
└──────────────────────────┘  ← スタックポインタの位置

スタックトレースは「スタックを下から上に読んだもの」だ。一番上の行が最初に見るべき場所(例外が投げられた場所)で、下に行くほど呼び出しの根本(main)に近づく。


3. throw するとスタックが「巻き戻る」

throw が実行されると、JVM は次のことを行う:

  1. 現在のフレームを捨てる(finally があれば実行してから)
  2. 1 つ前のフレームに戻る
  3. そのフレームに catch ブロックがあるか確認する
  4. なければさらに 1 つ前に戻る(スタックの巻き戻し)
  5. catch が見つかれば実行、見つからなければ main まで戻って JVM がログを出して終了
void main() {
    try {
        run();
    } catch (Exception e) {  // ここで捕まえる
        System.out.println("caught: " + e.getMessage());
    }
}

void run() {
    process();
}

void process() {
    throw new RuntimeException("error"); // ここで投げる
}
【スタックの巻き戻り】

throw 直後:
┌──────────┐
│ main     │ catch あり → 後で捕まえる
├──────────┤
│ run      │ catch なし → 巻き戻り
├──────────┤
│ process  │ throw 発生 → finally があれば実行 → フレーム破棄
└──────────┘

巻き戻り中:
┌──────────┐
│ main     │ catch あり → ここで捕まえる!
├──────────┤
│ run      │ finally があれば実行 → フレーム破棄
└──────────┘

catch で実行再開:
┌──────────┐
│ main     │ catch ブロックを実行
└──────────┘

4. finally はなぜ必ず実行されるのか

finally は「スタック巻き戻りの途中で必ず割り込む」仕組みだ。

void process() {
    try {
        riskyOperation();
        return "success"; // ← return があっても
    } catch (Exception e) {
        return "failed";  // ← ここに return があっても
    } finally {
        closeResource();  // ← 必ず実行される
    }
}

JVM は try ブロックや catch ブロックが終わる前に、必ず finally を実行してからフレームを抜ける

finally の return は try/catch の return を上書きする

int getValue() {
    try {
        return 1;
    } finally {
        return 2; // ← これが最終的な戻り値になる
    }
}
System.out.println(getValue()); // 2

tryreturn 1 の値は一時保存されるが、finallyreturn 2 が実行されると上書きされる。実務では finallyreturn を書くのはバグの元なので絶対に避ける。


5. try-with-resources の仕組み

try (BufferedReader br = new BufferedReader(new FileReader("file.txt"))) {
    return br.readLine();
}

これは以下のコードと等価だ(概念的に):

BufferedReader br = new BufferedReader(new FileReader("file.txt"));
Throwable primaryException = null;
try {
    return br.readLine();
} catch (Throwable t) {
    primaryException = t;
    throw t;
} finally {
    if (primaryException != null) {
        try {
            br.close();
        } catch (Throwable suppressedException) {
            primaryException.addSuppressed(suppressedException); // 例外を抑制
        }
    } else {
        br.close();
    }
}

AutoCloseable インターフェースの close()finally で自動的に呼ばれる。例外が発生しても(close() 自体が例外を出しても)リソースが確実に解放される。

close() 中に別の例外が発生した場合、元の例外に**抑制例外(suppressed exception)**として追記される。e.getSuppressed() で確認できる。


6. 検査例外 vs 非検査例外の設計思想

Throwable
├── Error(JVM の深刻な問題。通常は捕まえない)
│   ├── OutOfMemoryError
│   └── StackOverflowError
└── Exception
    ├── IOException(検査例外 = catch または throws が必須)
    ├── SQLException(検査例外)
    └── RuntimeException(非検査例外 = catch は任意)
        ├── NullPointerException
        ├── IllegalArgumentException
        └── ArrayIndexOutOfBoundsException

検査例外(Checked Exception): コンパイラが「この例外を処理しているか」を強制チェックする。ファイル・ネットワークなど「呼び出し元がリカバリできる可能性がある」ケースに使う。

非検査例外(Unchecked Exception): プログラマのバグ(null 参照・範囲外アクセス)を示すことが多く、毎回 catch を強制するとコードが冗長になるため、任意。

実務の傾向: Spring などのフレームワークは検査例外を RuntimeException に包んで再スローするパターンが多い。「呼び出し元にいちいち例外を伝播させない」という設計判断。


まとめ

概念正体
例外オブジェクトヒープ上のオブジェクト(stackTrace フィールドを持つ)
スタックトレースthrow 時のスタック全フレームのスナップショット
スタック巻き戻しthrow 後、catch を探してフレームを順番に捨てていく
finallyスタック巻き戻り中に必ず割り込んで実行される
try-with-resourcesfinally で AutoCloseable.close() を自動呼び出し
検査例外コンパイラが処理を強制する(リカバリ可能な想定)
非検査例外バグを示すことが多い(処理は任意)