Javaを利用したシステム開発において、プログラムの実行中に予期しないエラーが発生することは避けられません。
ネットワークの遮断、ファイルの欠損、あるいは入力データの不整合など、プログラムの外部要因によって正常な動作が妨げられる場面は多々あります。
こうした予期せぬ事態に適切に対処し、システムを安全に停止させたり、あるいは回復処理を行ったりするための仕組みが例外処理です。
Javaでは、この例外処理を実現するためにtry-catch文という構文が用意されています。
初心者が最初につまずきやすいポイントであると同時に、熟練のエンジニアであっても「どこまでキャッチすべきか」「どのようにログを残すべきか」といった設計判断で頭を悩ませる奥の深いテーマでもあります。
本記事では、Javaの例外処理の基本概念から、実務で必須となる応用的な書き方、さらには保守性の高いコードを書くためのベストプラクティスまでを徹底的に解説します。
Javaの例外処理とは何か
Javaにおける例外(Exception)とは、プログラムの実行中に発生する「予期しないイベント」を指します。
例えば、存在しないファイルを開こうとしたり、数値をゼロで除算しようとしたりした場合、Javaの実行環境は「例外」をスロー(投げ)します。
例外処理の目的
例外処理の最大の目的は、プログラムの異常終了を防ぎ、システムの堅牢性を高めることにあります。
もし例外処理が適切に行われていない場合、エラーが発生した瞬間にプログラムは強制終了してしまい、ユーザーに不親切なエラーメッセージが表示されたり、処理途中のデータが破損したりするリスクが生じます。
適切な例外処理を記述することで、以下のような対応が可能になります。
- エラーが発生した際に、ユーザーに対して分かりやすいメッセージを表示する。
- データベースの接続を確実にクローズするなど、後処理を安全に行う。
- エラーの内容をログに記録し、後の原因究明に役立てる。
- 軽微なエラーであれば、リトライ(再試行)処理を行う。
例外の階層構造
Javaの例外はすべてクラスとして定義されており、java.lang.Throwableクラスを頂点とした継承関係を持っています。
この構造を理解することは、適切なtry-catchを書くための第一歩です。
- Throwable
すべての異常系の親クラス。
- Error
メモリ不足(
OutOfMemoryError)など、プログラム側で回復不可能な致命的な問題。通常はキャッチしません。
- Exception
プログラム側で対処すべき例外。
- RuntimeException(非検査例外)
実行時に判明する例外。
プログラミングミス(
NullPointerExceptionなど)に起因することが多いです。- 上記以外のException(検査例外)
コンパイル時にチェックされる例外。
ファイル操作やネットワーク通信など、外部要因によるエラーが該当します。
try-catch文の基本構文
例外が発生する可能性のある処理を記述する際、Javaではtryブロックとcatchブロックを組み合わせて使用します。
基本的な書き方
もっともシンプルな構造は以下の通りです。
try {
// 例外が発生する可能性のある処理
} catch (例外クラス名 変数名) {
// 例外が発生した際の処理
}
tryブロック内で例外が発生すると、それ以降の処理はスキップされ、対応するcatchブロックに制御が移ります。
具体的なコード例
以下の例は、配列の範囲外にアクセスしようとした際に発生するArrayIndexOutOfBoundsExceptionを捕捉するものです。
public class ExceptionBasic {
public static void main(String[] args) {
try {
int[] numbers = {1, 2, 3};
// 配列のインデックスは0〜2だが、3を指定(範囲外)
int result = numbers[3];
System.out.println("この行は実行されません: " + result);
} catch (ArrayIndexOutOfBoundsException e) {
System.out.println("エラー:配列の範囲外にアクセスしました。");
System.out.println("詳細メッセージ: " + e.getMessage());
}
System.out.println("プログラムを正常に終了します。");
}
}
エラー:配列の範囲外にアクセスしました。
詳細メッセージ: Index 3 out of bounds for length 3
プログラムを正常に終了します。
このコードでは、numbers[3] にアクセスした瞬間に例外が発生するため、その直後の System.out.println は実行されず、即座に catch ブロック内へと処理がジャンプしています。
複数の例外を処理する方法
実務のプログラムでは、一つの処理ブロック(tryブロック)の中で、複数の異なる種類の例外が発生する可能性があります。
例えば、ファイルを開いてその内容を数値に変換する場合、「ファイルが見つからない(FileNotFoundException)」可能性と「数値形式が正しくない(NumberFormatException)」可能性の両方を考慮しなければなりません。
複数のcatchブロックを並べる
Javaでは、catchブロックを複数並べることで、例外の種類に応じた個別の処理を記述できます。
try {
// ファイルを読み込み、数値に変換する処理など
} catch (FileNotFoundException e) {
// ファイルがない場合の処理
} catch (NumberFormatException e) {
// 数値が不正な場合の処理
} catch (Exception e) {
// それ以外の予期せぬ例外に対する汎用的な処理
}
ここで重要なルールは、「サブクラス(具体的な例外)から先に記述し、スーパークラス(抽象的な例外)を後に記述する」ということです。
Javaは上から順番に型をチェックするため、最初に catch (Exception e) を書いてしまうと、すべての例外がそこで捕捉されてしまい、後続の具体的な例外処理が実行されなくなります。
マルチキャッチ(Multi-catch)
Java 7以降では、複数の例外に対して全く同じ処理を行いたい場合に、パイプ記号 | を使って一つにまとめることができます。
try {
// 何らかの処理
} catch (IOException | SQLException e) {
// IOException または SQLException が発生した場合に実行される
System.out.println("入出力またはデータベースエラーが発生しました。");
e.printStackTrace();
}
コードの重複を減らし、可読性を高めることができるため、実務でも頻繁に利用されます。
finallyブロックによる終了処理
例外が発生したかどうかにかかわらず、「必ず実行したい処理」がある場合には、finallyブロックを使用します。
finallyの役割
主に、ファイルやデータベース接続といった外部リソースの解放(クローズ処理)に使用されます。
もし例外が発生して処理が中断されたとしても、finally ブロックに記述された内容は必ず実行されるため、リソース漏洩(リーク)を防ぐことができます。
public class FinallyExample {
public static void main(String[] args) {
try {
System.out.println("処理を開始します。");
// 意図的に例外を発生させる(0での除算)
int division = 10 / 0;
} catch (ArithmeticException e) {
System.out.println("例外をキャッチしました。");
} finally {
System.out.println("finallyブロック:この処理は必ず実行されます。");
}
System.out.println("メイン処理を終了します。");
}
}
処理を開始します。
例外をキャッチしました。
finallyブロック:この処理は必ず実行されます。
メイン処理を終了します。
たとえ catch ブロックの中で return 文が呼ばれたとしても、その直前に finally ブロックが実行されるという極めて強い実行保証を持っています。
try-with-resources:現代的なリソース管理
Java 7で導入されたtry-with-resources構文は、従来の try-finally によるリソース管理を劇的に簡素化しました。
従来の書き方とその課題
以前は、以下のように finally 内で null チェックを行いながらクローズ処理を記述する必要がありました。
BufferedReader reader = null;
try {
reader = new BufferedReader(new FileReader("test.txt"));
// 読み込み処理
} catch (IOException e) {
e.printStackTrace();
} finally {
if (reader != null) {
try {
reader.close();
} catch (IOException e) {
// close自体の失敗も考慮が必要
}
}
}
この書き方はコードが冗長になりやすく、close() 自体が例外を投げる可能性もあるため、非常に煩雑でした。
try-with-resourcesの構文
java.lang.AutoCloseable インターフェースを実装しているクラスであれば、try の後に続く括弧内で宣言することで、ブロックを抜ける際に自動的に close() が呼び出されます。
try (BufferedReader reader = new BufferedReader(new FileReader("test.txt"))) {
String line = reader.readLine();
System.out.println(line);
} catch (IOException e) {
System.out.println("ファイルの読み込み中にエラーが発生しました。");
}
この構文を使用することで、クローズ処理の漏れを物理的に防ぐことができ、コードの安全性と見通しが飛躍的に向上します。
実務において、ファイルやネットワーク、DB接続などを扱う際は、このtry-with-resourcesが標準的な書き方となっています。
例外を投げる:throw と throws
例外は「発生した場所でキャッチする」だけではありません。
呼び出し元のメソッドに「例外が起きたことを通知する」ことも重要です。
throw文(例外を発生させる)
特定の条件に合致しない場合に、意図的に例外を発生させるには throw キーワードを使用します。
public void checkAge(int age) {
if (age < 0) {
throw new IllegalArgumentException("年齢に負の値は指定できません。");
}
}
throwsキーワード(例外の宣言)
そのメソッド内で例外を処理せず、呼び出し元(親)に処理を委ねる(丸投げする)場合には、メソッド定義に throws を記述します。
public void readFile(String path) throws IOException {
// IOExceptionが発生する可能性のある処理
// ここではtry-catchを書かず、呼び出し元に任せる
}
これにより、呼び出し側のコードに対して「このメソッドを使うなら、例外が発生する可能性を考慮してね(try-catchを書いてね)」という契約を強制することができます。
検査例外と非検査例外の使い分け
Javaの例外設計においてもっとも議論されるのが、この2つの使い分けです。
| 種類 | クラスの継承 | 特徴 | 主な例 |
|---|---|---|---|
| 検査例外 | Exceptionの直下 | コンパイル時にチェックされる。try-catchが必須。 | IOException, SQLException |
| 非検査例外 | RuntimeExceptionの直下 | コンパイル時はチェックされない。実行時に判明する。 | NullPointerException, IllegalArgumentException |
実務での判断基準
現代的なJava開発(特にSpring Bootなどのフレームワークを利用する場合)では、不必要な検査例外を避け、非検査例外(RuntimeException)を活用する傾向にあります。
- 検査例外を使うべきケース
呼び出し側でリトライが可能であったり、エラーが起きることがビジネス上のフローの一部であったりする場合。
- 非検査例外を使うべきケース
プログラミングミス(バグ)である場合、またはその場で回復する手段がなく、共通のエラー画面に飛ばすしかない場合。
独自の例外クラスを作成する
標準の例外クラスだけでは、発生したエラーの「ビジネス的な意味」を表現しきれないことがあります。
その場合、独自の例外クラス(カスタム例外)を作成します。
カスタム例外の定義方法
通常は RuntimeException または Exception を継承して作成します。
// 独自例外クラスの定義
public class InsufficientBalanceException extends RuntimeException {
private final int requiredAmount;
public InsufficientBalanceException(String message, int requiredAmount) {
super(message);
this.requiredAmount = requiredAmount;
}
public int getRequiredAmount() {
return requiredAmount;
}
}
活用例
銀行口座の引き落とし処理などで、残高不足を表現するために使用します。
public void withdraw(int amount) {
if (balance < amount) {
throw new InsufficientBalanceException("残高が不足しています。", amount - balance);
}
balance -= amount;
}
このように独自の情報を保持させることで、エラーハンドリングの際に「あといくら足りないのか」といった情報を取得しやすくなります。
実務で役立つ例外処理のベストプラクティス
現場で「綺麗なコード」と評価されるためには、単に文法を知っているだけでは不十分です。
以下のベストプラクティスを意識しましょう。
1. catchブロックを空にしない
もっともやってはいけないアンチパターンが、例外をキャッチした後に何もしないことです。
// 絶対にやってはいけない例
try {
doSomething();
} catch (Exception e) {
// 何も書かない
}
これをやってしまうと、エラーが発生したという事実が隠蔽(スローイング・アウェイ)され、不具合の原因特定が極めて困難になります。
最低限、ログに出力するか、上位に再スローする必要があります。
2. 具体的(特定)の例外をキャッチする
何でもかんでも catch (Exception e) で受けるのではなく、可能な限り具体的な例外クラス(例:NumberFormatException)を指定しましょう。
これにより、想定外の重大なエラー(例:OutOfMemoryError)まで誤ってキャッチしてしまうリスクを減らせます。
3. 原因例外(Cause)を保持する
例外を別の例外にラップして投げ直す場合、元の例外情報を捨ててはいけません。
try {
// 低レベルな処理
} catch (SQLException e) {
// 元の例外(e)を引数に渡すことで、スタックトレースを維持する
throw new ServiceException("データ取得に失敗しました", e);
}
これを「例外の連鎖(Exception Chaining)」と呼びます。
デバッグ時に「真の原因」がどこにあるのかを追跡するために不可欠な技術です。
4. スタックトレースを適切に出力する
e.getMessage() だけでは、「どのファイルの何行目でエラーが起きたか」という情報が欠落します。
開発や運用フェーズでは e.printStackTrace() や、ロギングライブラリ(SLF4J等)の logger.error("Error occurred", e) を使用して、スタックトレース全体を記録するようにしてください。
まとめ
Javaの try-catch は、単なるエラー回避の道具ではなく、「プログラムの品質と信頼性を担保するための設計思想」そのものです。
基本となる try-catch-finally の流れを理解した上で、現代的な try-with-resources によるリソース管理をマスターしましょう。
また、実務においては「どの例外をどこでキャッチし、どこで投げるべきか」という戦略が重要になります。
- 外部リソースを扱う際は必ず try-with-resources を検討する。
- 例外を隠蔽せず、ログやスタックトレースを確実に残す。
- 検査例外と非検査例外を適切に使い分け、呼び出し側に必要な情報を伝える。
これらのポイントを意識することで、あなたの書くJavaプログラムはより堅牢で、メンテナンスのしやすいものへと進化するはずです。
例外処理を正しく味方につけ、予期せぬトラブルにも動じないシステムを構築していきましょう。






