Java exception handling uses a class hierarchy rooted at Throwable — checked exceptions (extend Exception) must be declared or caught at compile time, unchecked exceptions (extend RuntimeException) may propagate freely, and try-with-resources automatically closes AutoCloseable resources.
Java exceptions are objects that represent errors — checked exceptions must be handled at compile time, unchecked exceptions can propagate freely, and try-with-resources ensures resources are always closed.
The exception hierarchy
All exceptions in Java extend Throwable. The two branches matter: Error (JVM-level — don't catch) and Exception (application-level — handle these).
| Class | Type | Must handle? | Examples |
|---|---|---|---|
Error | Unchecked | No (don't try) | OutOfMemoryError, StackOverflowError |
RuntimeException | Unchecked | No | NullPointerException, IllegalArgumentException, IndexOutOfBoundsException |
Exception (other) | Checked | Yes — throws or catch | IOException, SQLException, ParseException |
// try-catch-finally
try {
int result = 10 / 0; // throws ArithmeticException
} catch (ArithmeticException e) {
System.err.println("Error: " + e.getMessage()); // "/ by zero"
} finally {
// always runs — cleanup, logging
System.out.println("Done");
}
// Multiple catch blocks
try {
String s = null;
int n = Integer.parseInt(s); // NullPointerException
} catch (NumberFormatException e) {
System.err.println("Not a number");
} catch (NullPointerException e) {
System.err.println("Null input");
} catch (Exception e) {
System.err.println("Unexpected: " + e.getClass().getSimpleName());
}
// More specific exceptions must come BEFORE more general ones
// Multi-catch (Java 7+) — when handling is identical
try {
process();
} catch (IOException | SQLException e) {
log.error("Data error: {}", e.getMessage());
}Checked vs unchecked
// CHECKED exception — extends Exception (not RuntimeException)
// Compiler requires you to either catch it or declare throws
// Method that declares it throws checked exception
public String readFile(Path path) throws IOException {
return Files.readString(path); // Files.readString throws IOException
}
// Caller must handle:
try {
String content = readFile(Path.of("data.txt"));
} catch (IOException e) {
System.err.println("Could not read file: " + e.getMessage());
}
// UNCHECKED exception — extends RuntimeException
// No obligation to declare or catch (but can still catch)
public int divide(int a, int b) {
if (b == 0) throw new ArithmeticException("Divisor cannot be zero");
return a / b;
}
// Throwing checked exceptions from lambdas requires wrapping
// (lambdas cannot declare checked exceptions)
List<Path> paths = List.of(Path.of("a.txt"), Path.of("b.txt"));
paths.forEach(p -> {
try {
System.out.println(Files.readString(p));
} catch (IOException e) {
throw new RuntimeException(e); // wrap in unchecked
}
});try-with-resources
// try-with-resources — automatically closes AutoCloseable resources
// Resource declared in the () is closed when the try block exits
// (whether normally, via exception, or via return)
// Old style — error-prone (what if close() itself throws?)
BufferedReader br = null;
try {
br = new BufferedReader(new FileReader("file.txt"));
String line = br.readLine();
} catch (IOException e) {
e.printStackTrace();
} finally {
if (br != null) {
try { br.close(); } catch (IOException e) { /* swallow */ }
}
}
// Modern style — clean and correct
try (BufferedReader br = new BufferedReader(new FileReader("file.txt"))) {
String line;
while ((line = br.readLine()) != null) {
System.out.println(line);
}
} catch (IOException e) {
System.err.println("Read error: " + e.getMessage());
}
// br.close() called automatically — even if exception was thrown
// Multiple resources — closed in reverse order of declaration
try (var conn = dataSource.getConnection();
var stmt = conn.prepareStatement("SELECT * FROM users")) {
ResultSet rs = stmt.executeQuery();
while (rs.next()) System.out.println(rs.getString("name"));
} catch (SQLException e) {
throw new RuntimeException("DB query failed", e);
}Custom exceptions
// Custom checked exception — extend Exception
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;
}
public double getAmount() { return amount; }
public double getBalance() { return balance; }
}
// Custom unchecked exception — extend RuntimeException
public class DatabaseUnavailableException extends RuntimeException {
public DatabaseUnavailableException(String message, Throwable cause) {
super(message, cause);
}
}
// Using custom exceptions
class BankAccount {
private double balance;
public void withdraw(double amount) throws InsufficientFundsException {
if (amount > balance) {
throw new InsufficientFundsException(amount, balance);
}
balance -= amount;
}
}
// Exception chaining — preserve the original cause
try {
dbOperation();
} catch (SQLException e) {
// Wrap in domain exception, preserving the cause
throw new DatabaseUnavailableException("User lookup failed", e);
}
// getCause() on the outer exception returns the original SQLExceptionException best practices
// 1. Catch specific, not general
// WRONG:
try { process(); } catch (Exception e) { /* swallows everything */ }
// RIGHT:
try { process(); }
catch (FileNotFoundException e) { createDefaultConfig(); }
catch (IOException e) { log.error("I/O error", e); throw e; }
// 2. Never swallow exceptions silently
// WRONG:
try { risky(); } catch (Exception e) {} // hides bugs
// RIGHT:
try { risky(); } catch (Exception e) { log.error("risky() failed", e); }
// 3. Include context in the message
// WRONG:
throw new IllegalArgumentException("Invalid input");
// RIGHT:
throw new IllegalArgumentException(
"userId must be positive, got: " + userId);
// 4. Preserve the cause when wrapping
// WRONG:
catch (IOException e) { throw new ServiceException("Failed"); } // cause lost
// RIGHT:
catch (IOException e) { throw new ServiceException("Failed", e); }
// 5. try-with-resources for anything Closeable — always
// 6. Document with @throws Javadoc
/**
* Withdraws the given amount.
* @throws InsufficientFundsException if balance < amount
* @throws IllegalArgumentException if amount <= 0
*/
public void withdraw(double amount) throws InsufficientFundsException { ... }Exception catches checked exceptions but NOT Error. Error is a sibling of Exception, not a subclass. Catching Exception will not catch OutOfMemoryError or StackOverflowError. To catch everything (rarely correct), catch Throwable.The exception specification and suppressed exceptions
JLS §11 defines the Java exception model. When an exception is thrown, the JVM searches the call stack for a handler — a matching catch block — unwinding frames until one is found or the thread terminates. The try-with-resources statement (JLS §14.20.3) calls close() on resources in reverse declaration order. If both the try body and a close() call throw, the original exception is rethrown and the close() exception is attached as a suppressed exception via Throwable.addSuppressed(). Suppressed exceptions are visible in stack traces and accessible via getSuppressed() — no exception is silently lost.
JEP 358 — Helpful NullPointerExceptions (Java 14)
Before Java 14, a NullPointerException stack trace only showed the line number, leaving you to guess which of multiple possible dereferences caused it. JEP 358 (Java 14, enabled by default in Java 15+) adds a descriptive message: Cannot invoke "String.length()" because "str" is null or Cannot read field "address" because "user" is null. This is implemented by the JVM analysing the bytecode at the throw site to reconstruct which specific operation failed.