Exception Handling
Everything that can be thrown extends Throwable. Java splits it into two conceptually different branches, and further splits Exception into checked and unchecked.
🎯 What you'll learn
- The
ThrowableHierarchy - Checked vs Unchecked — a Compile-Time Distinction
try/catch/finally- Multi-Catch
- try-with-resources
1The Throwable Hierarchy
Everything that can be thrown extends Throwable. Java splits it into two conceptually different branches, and further splits Exception into checked and unchecked.
Throwable
├── Error — serious, not meant to be caught (OutOfMemoryError, StackOverflowError)
└── Exception
├── RuntimeException — UNCHECKED (NullPointerException, IllegalArgumentException, ...)
└── (everything else) — CHECKED (IOException, SQLException, ...)try {
riskyIO();
} catch (java.io.IOException e) {
// handle it
}2Checked vs Unchecked — a Compile-Time Distinction
Checked exceptions must be either caught or declared with throws in the method signature — the compiler enforces this. Unchecked exceptions (RuntimeException and its subclasses, plus all Errors) require no such declaration; they can propagate silently through any number of method signatures with no compiler involvement.
void readFile() throws java.io.IOException { // MUST declare — IOException is checked
java.nio.file.Files.readString(java.nio.file.Path.of("data.txt"));
}
void divide(int a, int b) {
if (b == 0) throw new ArithmeticException("/ by zero"); // unchecked — no `throws` needed
}This is a real, intentional Java design decision with no equivalent in JavaScript/TypeScript (where every thrown value is effectively "unchecked" from the type system's point of view) — checked exceptions are meant for conditions a well-behaved caller could reasonably anticipate and recover from (a missing file), while unchecked exceptions signal programming errors (a null reference, an illegal argument) that indicate a bug rather than an expected condition.
3try / catch / finally
try {
doSomething();
} catch (java.io.FileNotFoundException e) {
System.out.println("specific: " + e.getMessage());
} catch (java.io.IOException e) {
System.out.println("more general IO problem: " + e.getMessage());
} finally {
System.out.println("always runs — cleanup goes here");
}Catch clauses are checked top to bottom, and the first assignable match wins — so subclasses must be listed before their superclasses, or the more general catch shadows the specific one and the compiler rejects unreachable catch blocks entirely. finally runs whether the try block succeeds, throws, or even executes a return — it is the one block Java guarantees will execute (barring System.exit() or a JVM crash).
4Multi-Catch
When multiple exception types need identical handling, combine them with | instead of duplicating the catch body:
try {
process();
} catch (java.io.IOException | InterruptedException e) {
System.out.println("failed: " + e.getMessage());
}The caught variable's static type is the least upper bound of the listed types, and (unlike separate catch blocks) it is implicitly final — you cannot reassign it.
5try-with-resources
Any object implementing AutoCloseable can be declared in the try(...) parentheses; its close() is called automatically when the block exits, in reverse declaration order, even if an exception was thrown.
try (var reader = new java.io.BufferedReader(new java.io.FileReader("data.txt"))) {
System.out.println(reader.readLine());
} // reader.close() called automatically here, no finally needed6Throwing Custom Exceptions and Exception Chaining
class InsufficientFundsException extends Exception {
InsufficientFundsException(String message, Throwable cause) {
super(message, cause); // preserves the ORIGINAL exception as the "cause"
}
}
void withdraw(double amount) throws InsufficientFundsException {
try {
// ... some lower-level operation that might throw ...
} catch (java.sql.SQLException e) {
throw new InsufficientFundsException("withdrawal failed", e); // chain, don't swallow
}
}getCause() retrieves the original exception; printed stack traces show Caused by: chains, which is essential for debugging — swallowing the original exception loses exactly the information you need most.
7Custom Exception Design: Checked or Unchecked?
class ValidationException extends RuntimeException { // unchecked — a caller bug (bad input)
ValidationException(String message) { super(message); }
}
class ConfigFileMissingException extends Exception { // checked — an expected, recoverable condition
ConfigFileMissingException(String message) { super(message); }
}Extend RuntimeException for programmer errors and invariant violations; extend Exception directly only when you genuinely want to force callers to handle or explicitly propagate the condition.
Under the HoodHow it works internally▾
There's no setjmp/longjmp-style control transfer — the JVM uses a per-method exception table. Every compiled method that contains a try block gets a compact table in its class file: rows of (start_pc, end_pc, handler_pc, catch_type) describing which bytecode-offset ranges are protected by which handler, for which exception type. When a throw (bytecode athrow) executes, the JVM does not search source code or unwind via a signal mechanism — it consults the current method's exception table for a range covering the current program counter with a matching (or superclass-matching) catch_type. If none matches, the JVM pops the current stack frame entirely (this is "stack unwinding") and repeats the same table lookup in the caller's frame, continuing outward until either a handler matches or the stack is exhausted, at which point the thread terminates (or, for the main thread, the exception is printed and the JVM exits).
The expensive part of throwing is capturing the stack trace, not the throw/catch mechanism itself. Throwable's constructor calls the native method fillInStackTrace(), which walks the entire current call stack at the moment of construction and records it — this is genuinely costly (proportional to stack depth) compared to the near-free table lookup used for propagation. This is why "exceptions for control flow" is a real, measurable performance anti-pattern in Java specifically: constructing a new Exception() pays the stack-capture cost even if it's caught one frame up, and heavily-used exceptions in hot paths sometimes override fillInStackTrace() to a no-op specifically to avoid this (a real, if controversial, optimization technique).
try-with-resources desugars to an ordinary try/finally with suppressed-exception tracking. The compiler rewrites try (Resource r = ...) { body } into roughly Resource r = ...; try { body } finally { if (r != null) r.close(); }, with one added refinement: if both the body and the close() call throw, the close()'s exception is not lost — it's attached to the body's exception via addSuppressed() and retrievable later with getSuppressed(), rather than silently replacing the original (more informative) exception the way a naive hand-written finally block historically would.
⚠️ Common Pitfalls
- Swallowing exceptions with an empty catch block —
catch (Exception e) {}silently discards the failure, turning a debuggable crash into a mysterious later symptom with no trace of the real cause. - Catching
ExceptionorThrowabletoo broadly — masks genuine programming errors (NullPointerException,ArrayIndexOutOfBoundsException) alongside the specific recoverable condition you meant to handle, and can even catchErrors that generally should not be handled at all. - A
finallyblock with its ownreturnorthrow— unconditionally overrides/discards whatever thetry/catchwas about to return or throw, silently losing the original outcome; this is legal Java and a genuinely confusing trap. - Using exceptions for ordinary, expected control flow — e.g., throwing to signal "not found" in a hot loop — pays the
fillInStackTrace()cost (see Under the Hood) for something that isn't exceptional at all; returnOptional/a sentinel/a boolean instead. - Not closing resources without try-with-resources — a hand-written
finally { conn.close(); }is easy to get subtly wrong (e.g., forgetting the null check, or losing the original exception whenclose()itself throws); try-with-resources handles both correctly by construction.
📋 Quick Reference
try {
risky();
} catch (SpecificException e) { // most specific first
handle(e);
} catch (IOException | SQLException e) { // multi-catch, implicitly final
handleEither(e);
} finally {
cleanup(); // always runs
}
try (var res = openResource()) { // AutoCloseable — close() called automatically
use(res);
}
throw new CustomException("message", causeException); // chain, don't swallow
class MyUnchecked extends RuntimeException { ... } // programmer error, no `throws` needed
class MyChecked extends Exception { ... } // recoverable, forces `throws` or catch🎤 Interview Q&A
Q1Checked vs unchecked exceptions?core▾
Checked exceptions (subclasses of Exception, not RuntimeException) must be declared or caught, e.g. IOException; unchecked (RuntimeException) don't, e.g. NullPointerException, IllegalArgumentException. Errors (OutOfMemoryError) shouldn't be caught.
- Modern trend? Many libraries prefer unchecked exceptions.
Q2What is try-with-resources?core▾
try (var in = Files.newInputStream(p)) { … } automatically closes AutoCloseable resources in reverse order, even on exceptions, with suppressed exceptions recorded.
- Why better than finally? Less code and correct handling of close() failures.
Q3What are best practices for exceptions?core▾
Catch specific exceptions, don't swallow them, wrap with context preserving the cause, use exceptions for exceptional conditions not control flow, and translate low-level exceptions at layer boundaries.
- Logging and rethrowing? Avoid logging at every layer.
Q4How do you handle exceptions in a Spring REST API?senior▾
@RestControllerAdvice with @ExceptionHandler methods mapping exceptions to HTTP responses (ProblemDetail in Spring 6).
- Why centralize? Consistent error responses.
Q5What happens to exceptions in other threads?core▾
Uncaught exceptions in threads go to the thread's UncaughtExceptionHandler; in executors they're captured in the Future and rethrown wrapped in ExecutionException on get(); CompletableFuture uses exceptionally/handle.
- Common bug? Never calling get(), so failures vanish.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.