Exception Handling in Java
Learn how Java handles errors — the exception hierarchy, try/catch/finally, checked vs unchecked exceptions, throw/throws, and creating custom exceptions.
Exceptions are Java’s mechanism for handling errors at runtime. Instead of returning error codes that callers can silently ignore, Java throws exception objects that propagate up the call stack until they’re caught or crash the program with a clear message and stack trace. This makes error conditions hard to ignore and gives you a structured way to separate normal logic from error-handling logic.
The Exception Hierarchy
Understanding the hierarchy tells you what you must handle, what you should handle, and what you should never catch. Checked exceptions represent conditions a caller can reasonably anticipate and recover from. Unchecked exceptions are almost always programming bugs. Errors are JVM-level failures you can do nothing about.
Throwable
├── Error (JVM problems — never catch these)
│ ├── OutOfMemoryError
│ ├── StackOverflowError
│ └── ...
└── Exception
├── IOException ← checked: compiler requires handling
├── SQLException ← checked: compiler requires handling
├── ClassNotFoundException ← checked: compiler requires handling
└── RuntimeException ← unchecked: no compiler requirement
├── NullPointerException
├── ArrayIndexOutOfBoundsException
├── IllegalArgumentException
├── IllegalStateException
├── NumberFormatException
└── ...
- Checked exceptions — compiler requires you to handle or declare them
- Unchecked exceptions (RuntimeException subclasses) — compiler does not require handling
- Errors — serious JVM failures; never catch these
try / catch / finally
The try block contains code that might throw. The catch block handles a specific exception type. The finally block always runs — whether the try completed normally, threw an exception, or returned early — making it the right place for cleanup code like closing resources.
try {
// code that might throw
int result = 10 / 0;
} catch (ArithmeticException e) {
// handle the specific exception — e carries the message and stack trace
System.out.println("Cannot divide by zero: " + e.getMessage());
} finally {
// always runs — use for cleanup (but prefer try-with-resources for resources)
System.out.println("Cleanup happens here");
}
Multiple catch Blocks
You can catch several exception types from the same try block. Always order catch blocks from most specific to most general — a more general type listed first would swallow the specific ones and prevent them from being reached.
public static int parseAndDivide(String a, String b) {
try {
int x = Integer.parseInt(a); // may throw NumberFormatException
int y = Integer.parseInt(b);
return x / y; // may throw ArithmeticException
} catch (NumberFormatException e) {
// most specific first — handles non-numeric input
System.err.println("Not a valid number: " + e.getMessage());
return 0;
} catch (ArithmeticException e) {
// handles division by zero
System.err.println("Division by zero");
return 0;
}
}
Multi-catch (Java 7+)
When two exception types warrant exactly the same handling, the multi-catch syntax eliminates duplication without sacrificing specificity.
try {
// ...
} catch (IOException | SQLException e) {
// handles both types the same way — e is effectively final here
System.err.println("Data error: " + e.getMessage());
}
Exception Information
Every exception carries a message, its class name, and a full stack trace. The stack trace is the most useful debugging tool — it shows exactly which method calls led to the error.
try {
int[] arr = new int[3];
arr[10] = 1;
} catch (ArrayIndexOutOfBoundsException e) {
System.out.println(e.getMessage()); // "Index 10 out of bounds for length 3"
System.out.println(e.getClass().getName()); // "java.lang.ArrayIndexOutOfBoundsException"
e.printStackTrace(); // full stack trace to stderr — essential for debugging
}
throw — Raising an Exception
Use throw to raise an exception from your own code when a precondition is violated or a situation is unrecoverable. Throwing a specific, well-named exception with a clear message makes it obvious to the caller what went wrong and what they passed in.
public static double sqrt(double n) {
if (n < 0) {
// IllegalArgumentException is the standard unchecked exception for bad arguments
throw new IllegalArgumentException("Cannot take sqrt of negative number: " + n);
}
return Math.sqrt(n);
}
// Calling code:
try {
System.out.println(sqrt(-4));
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
// Cannot take sqrt of negative number: -4.0
}
throws — Declaring Checked Exceptions
If a method can throw a checked exception and doesn’t catch it internally, it must declare it with throws. This is the compiler’s way of forcing the contract to be explicit: every caller knows they need to handle or propagate the exception.
import java.io.*;
// The throws declaration is part of the method's public contract
public static String readFile(String path) throws IOException {
StringBuilder sb = new StringBuilder();
try (BufferedReader br = new BufferedReader(new FileReader(path))) {
String line;
while ((line = br.readLine()) != null) sb.append(line).append("\n");
}
return sb.toString();
}
// Caller must handle it — the compiler won't let you forget
try {
String content = readFile("data.txt");
System.out.println(content);
} catch (IOException e) {
System.err.println("Could not read file: " + e.getMessage());
}
try-with-resources
Before Java 7, closing resources (files, database connections, streams) correctly required verbose, error-prone finally blocks. try-with-resources solves this cleanly: any object implementing AutoCloseable is automatically closed when the try block exits, whether normally or via exception. This eliminates an entire class of resource-leak bugs.
// Without try-with-resources — verbose and error-prone
BufferedReader br = null;
try {
br = new BufferedReader(new FileReader("file.txt"));
System.out.println(br.readLine());
} catch (IOException e) {
e.printStackTrace();
} finally {
if (br != null) try { br.close(); } catch (IOException e) { /* swallowed */ }
}
// With try-with-resources — clean, resource always closed even on exception
try (BufferedReader br = new BufferedReader(new FileReader("file.txt"))) {
System.out.println(br.readLine());
} catch (IOException e) {
e.printStackTrace();
}
// Multiple resources — closed in reverse order of declaration
try (FileInputStream in = new FileInputStream("input.txt");
FileOutputStream out = new FileOutputStream("output.txt")) {
// both streams are guaranteed to close when the block exits
}
Custom Exceptions
Creating your own exception classes lets you express domain-specific error conditions precisely. A InsufficientFundsException carries more information and intent than a generic RuntimeException("not enough money"). Callers can catch your specific type and access the structured data you attach to it.
// Checked custom exception — caller is forced to handle it
public class InsufficientFundsException extends Exception {
private final double amount;
private final double balance;
public InsufficientFundsException(double amount, double balance) {
super(String.format("Cannot withdraw %.2f — balance is %.2f", amount, balance));
this.amount = amount;
this.balance = balance;
}
// Structured data callers can use for decisions or display
public double getAmount() { return amount; }
public double getBalance() { return balance; }
}
// Unchecked custom exception — for programming errors or unrecoverable states
public class InvalidAgeException extends RuntimeException {
public InvalidAgeException(int age) {
super("Invalid age: " + age + " (must be 0–150)");
}
}
Using them:
public class BankAccount {
private double balance;
public BankAccount(double initialBalance) { this.balance = initialBalance; }
public void withdraw(double amount) throws InsufficientFundsException {
if (amount > balance) throw new InsufficientFundsException(amount, balance);
balance -= amount;
}
public double getBalance() { return balance; }
}
BankAccount account = new BankAccount(100.0);
try {
account.withdraw(150.0);
} catch (InsufficientFundsException e) {
System.out.println(e.getMessage());
System.out.printf("Shortfall: %.2f%n", e.getAmount() - e.getBalance());
}
Exception Chaining
When you catch a low-level exception and re-throw a higher-level one, always pass the original as the cause. Exception chaining preserves the full diagnostic picture — you can see both the domain-level error and the underlying technical cause in the stack trace. Dropping the cause is a common mistake that makes bugs very hard to diagnose in production.
public static User loadUser(int id) throws ServiceException {
try {
return database.findUser(id);
} catch (SQLException e) {
// Wrap with the domain exception, but keep the original cause
throw new ServiceException("Failed to load user #" + id, e);
}
}
// In the caller, you can inspect both levels of the chain
try {
User u = loadUser(42);
} catch (ServiceException e) {
System.err.println(e.getMessage()); // domain error
System.err.println("Caused by: " + e.getCause().getMessage()); // technical cause
}
Best Practices
// Never swallow exceptions silently — bugs disappear and are never fixed
try {
riskyOperation();
} catch (Exception e) {
// WRONG — the exception is gone, the bug is invisible
}
// Log or re-throw — always do one or the other
try {
riskyOperation();
} catch (IOException e) {
logger.error("Operation failed", e); // log with full stack trace
throw new ServiceException("Could not complete operation", e); // or wrap and rethrow
}
// Don't use exceptions for normal flow control — they are expensive and confusing
// WRONG:
try {
return Integer.parseInt(s);
} catch (NumberFormatException e) {
return -1; // using exception as a branch is a code smell
}
// Better: check first, then parse
if (s.matches("-?\\d+")) return Integer.parseInt(s);
else return -1;
Project: Banking Application
public class BankingApp {
public static class Account {
private final String id;
private double balance;
public Account(String id, double initialBalance) {
if (initialBalance < 0) throw new IllegalArgumentException("Initial balance cannot be negative");
this.id = id;
this.balance = initialBalance;
}
public void deposit(double amount) {
if (amount <= 0) throw new IllegalArgumentException("Deposit amount must be positive");
balance += amount;
}
public void withdraw(double amount) throws InsufficientFundsException {
if (amount <= 0) throw new IllegalArgumentException("Withdrawal amount must be positive");
if (amount > balance) throw new InsufficientFundsException(amount, balance);
balance -= amount;
}
public void transfer(Account target, double amount) throws InsufficientFundsException {
withdraw(amount); // throws if insufficient — transfer is atomic
target.deposit(amount); // only reached if withdraw succeeded
}
public double getBalance() { return balance; }
public String getId() { return id; }
@Override public String toString() {
return String.format("Account[%s, balance=%.2f]", id, balance);
}
}
public static void main(String[] args) {
Account alice = new Account("ALICE-001", 1000.0);
Account bob = new Account("BOB-001", 500.0);
try {
alice.transfer(bob, 300.0);
System.out.println("Transfer successful");
System.out.println(alice);
System.out.println(bob);
alice.withdraw(800.0); // will throw — only 700 left
} catch (InsufficientFundsException e) {
System.out.println("Transfer failed: " + e.getMessage());
} catch (IllegalArgumentException e) {
System.out.println("Invalid input: " + e.getMessage());
}
}
}