信頼度ランク
| 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 は次のことを行う:
- 現在のフレームを捨てる(
finallyがあれば実行してから) - 1 つ前のフレームに戻る
- そのフレームに
catchブロックがあるか確認する - なければさらに 1 つ前に戻る(スタックの巻き戻し)
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
try の return 1 の値は一時保存されるが、finally の return 2 が実行されると上書きされる。実務では finally に return を書くのはバグの元なので絶対に避ける。
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-resources | finally で AutoCloseable.close() を自動呼び出し |
| 検査例外 | コンパイラが処理を強制する(リカバリ可能な想定) |
| 非検査例外 | バグを示すことが多い(処理は任意) |