Lesson 4 of 71
Exceptions
An exception is an object that a method throws to report that it cannot complete normally. The JVM unwinds the call stack until a matching catch block handles it, or the thread ends. This lesson covers how exceptions are declared, thrown, caught, chained, and designed, and what they cost. It builds on Immutability, Defensive Copying, and Object Lifecycle, which introduced try-with-resources.
| Topic | What it covers |
|---|---|
| Hierarchy | Throwable, Error, Exception, RuntimeException |
| Checked vs unchecked | Which exceptions the compiler forces callers to handle |
try/catch/finally |
Control flow, including return in finally |
| Multi-catch, try-with-resources | Shorter handlers, suppressed exceptions |
| Chaining, custom types | Keeping the cause, adding context |
| Cost | Stack trace capture and how to avoid it |
| Design | Result types, API boundary rules, interruption |
Example 1: the Throwable hierarchy
Only instances of Throwable can be thrown. It has two direct subclasses:
Error: a serious problem the application is not expected to handle, such asOutOfMemoryErrororStackOverflowError.Exception: a condition a program may handle. Its subclassRuntimeExceptionmarks programming errors such asNullPointerExceptionandIllegalArgumentException.
A checked exception is a Throwable that is not an Error or RuntimeException (or a subclass of either). The compiler requires a method that may throw one to declare it with throws, and requires callers to catch or declare it. Unchecked exceptions (RuntimeException, Error, and their subclasses) have no such rule.
import java.io.FileNotFoundException;
import java.io.IOException;
public class Hierarchy {
static void describe(Throwable t) {
boolean unchecked = t instanceof RuntimeException || t instanceof Error;
System.out.println(t.getClass().getSimpleName()
+ " -> " + t.getClass().getSuperclass().getSimpleName()
+ (unchecked ? ", unchecked" : ", checked"));
}
static void open(String path) throws FileNotFoundException {
throw new FileNotFoundException(path);
}
public static void main(String[] args) {
describe(new IOException());
describe(new FileNotFoundException());
describe(new IllegalArgumentException());
describe(new ArithmeticException());
describe(new StackOverflowError());
try {
open("missing.txt");
} catch (IOException e) { // a supertype catches the subtype
System.out.println("caught " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
}IOException -> Exception, checked
FileNotFoundException -> IOException, checked
IllegalArgumentException -> RuntimeException, unchecked
ArithmeticException -> RuntimeException, unchecked
StackOverflowError -> VirtualMachineError, unchecked
caught FileNotFoundException: missing.txtA catch clause matches the declared type and all its subclasses, so catch (IOException e) catches FileNotFoundException. Removing the try around open("missing.txt") is a compile error, because the checked exception is neither caught nor declared by main.
Example 2: try, catch, finally, and return in finally
The finally block runs after the try block and any catch block, whether they complete normally, throw, or return. A return in finally replaces the value returned by try, and it also discards an exception thrown by try, so the exception is lost. javac -Xlint:finally warns about it.
public class FinallyOrder {
static int normal() {
try {
System.out.println(" try");
return 1;
} finally {
System.out.println(" finally runs before the method returns");
}
}
@SuppressWarnings("finally")
static int overrides() {
try {
return 1;
} finally {
return 2;
}
}
@SuppressWarnings("finally")
static int swallows() {
try {
throw new IllegalStateException("lost");
} finally {
return -1;
}
}
static int valueSnapshot() {
int x = 10;
try {
return x; // value 10 is saved here
} finally {
x = 20; // does not change the saved value
}
}
public static void main(String[] args) {
System.out.println("normal: " + normal());
System.out.println("overrides: " + overrides());
System.out.println("swallows: " + swallows());
System.out.println("valueSnapshot: " + valueSnapshot());
}
} try
finally runs before the method returns
normal: 1
overrides: 2
swallows: -1
valueSnapshot: 10valueSnapshot returns 10: the return x expression is evaluated before finally runs, and changing the local afterwards has no effect. finally blocks hold cleanup code only; they do not return or throw.
Example 3: multi-catch and catch order
A multi-catch clause, catch (A | B e), handles several unrelated types with one block (Java 7). The types may not be subclasses of each other, and e is implicitly final. Catch clauses are checked top to bottom, so a subclass must come before its superclass; the reverse order is a compile error because the second clause is unreachable.
import java.util.List;
public class MultiCatch {
static void run(String input) {
try {
int n = Integer.parseInt(input);
int[] data = new int[2];
data[n] = 100 / n;
System.out.println(input + " -> ok");
} catch (NumberFormatException e) {
System.out.println(input + " -> not a number");
} catch (ArithmeticException | ArrayIndexOutOfBoundsException e) {
System.out.println(input + " -> " + e.getClass().getSimpleName());
} catch (RuntimeException e) {
System.out.println(input + " -> other: " + e);
}
}
public static void main(String[] args) {
for (String s : List.of("1", "abc", "0", "5")) {
run(s);
}
}
}1 -> ok
abc -> not a number
0 -> ArithmeticException
5 -> ArrayIndexOutOfBoundsExceptionNumberFormatException extends IllegalArgumentException, which extends RuntimeException, so it must appear before the RuntimeException clause.
Example 4: try-with-resources and suppressed exceptions
Try-with-resources closes every resource declared in its header, in reverse order. When the body throws and close() also throws, the body's exception is the one that propagates, and the exception from close() is attached to it as a suppressed exception, read with getSuppressed(). A hand-written try/finally has the opposite behavior: the exception from finally replaces the original one.
public class Suppressed {
static final class Resource implements AutoCloseable {
private final String name;
Resource(String name) { this.name = name; }
@Override public void close() {
throw new IllegalStateException("close failed: " + name);
}
}
public static void main(String[] args) {
try (Resource a = new Resource("a"); Resource b = new Resource("b")) {
throw new RuntimeException("body failed");
} catch (RuntimeException e) {
System.out.println("primary: " + e.getMessage());
for (Throwable s : e.getSuppressed()) {
System.out.println("suppressed: " + s.getMessage());
}
}
try {
Resource c = new Resource("c");
try {
throw new RuntimeException("body failed");
} finally {
c.close();
}
} catch (RuntimeException e) {
System.out.println("manual finally, primary: " + e.getMessage());
System.out.println("suppressed count: " + e.getSuppressed().length);
}
}
}primary: body failed
suppressed: close failed: b
suppressed: close failed: a
manual finally, primary: close failed: c
suppressed count: 0The manual version loses "body failed", which is usually the exception that explains the problem. Try-with-resources accepts an existing effectively final variable in the header, try (r) { ... }, since Java 9.
Example 5: chaining and custom exceptions
Exception chaining wraps a low-level exception in a higher-level one and keeps the original as its cause. The cause appears in the stack trace after Caused by: and is read with getCause(). A custom exception extends Exception (checked) or RuntimeException (unchecked), passes the cause to the superclass constructor, and can carry fields that describe the failure.
import java.io.IOException;
public class Chaining {
static final class PaymentException extends Exception {
private final String orderId;
PaymentException(String orderId, String message, Throwable cause) {
super(message, cause);
this.orderId = orderId;
}
String orderId() { return orderId; }
}
static void callGateway() throws IOException {
throw new IOException("connection reset");
}
static void pay(String orderId) throws PaymentException {
try {
callGateway();
} catch (IOException e) {
throw new PaymentException(orderId, "payment failed for " + orderId, e);
}
}
public static void main(String[] args) {
try {
pay("ord-42");
} catch (PaymentException e) {
System.out.println(e.getMessage());
System.out.println("order: " + e.orderId());
System.out.println("cause: " + e.getCause());
Throwable root = e;
while (root.getCause() != null) root = root.getCause();
System.out.println("root cause type: " + root.getClass().getSimpleName());
}
}
}payment failed for ord-42
order: ord-42
cause: java.io.IOException: connection reset
root cause type: IOExceptionWrapping without passing e loses the original stack trace, which is the most common reason a production log does not show where a failure started. Custom exceptions are worth adding when callers handle them differently from existing types; for bad arguments or bad state, IllegalArgumentException and IllegalStateException already exist.
Example 6: the cost of stack traces
Creating a Throwable calls fillInStackTrace(), which walks the current thread's stack and records every frame. This walk is the main cost of an exception, and it grows with stack depth. Code that uses an exception for expected control flow, such as a parser rejecting bad input in a hot loop, can skip it in two ways: override fillInStackTrace to return this, or use the Throwable constructor with writableStackTrace = false. The constructor is protected, so a subclass calls it with super.
public class StackTraceCost {
static final class FullException extends RuntimeException {
FullException(String m) { super(m); }
}
static final class OverrideException extends RuntimeException {
OverrideException(String m) { super(m); }
@Override public synchronized Throwable fillInStackTrace() { return this; }
}
static final class LightException extends RuntimeException {
LightException(String m) { super(m, null, false, false); } // no suppression, no stack trace
}
static int depth(int n, java.util.function.IntSupplier atBottom) {
return n == 0 ? atBottom.getAsInt() : depth(n - 1, atBottom);
}
public static void main(String[] args) {
int full = depth(50, () -> new FullException("x").getStackTrace().length);
int overridden = depth(50, () -> new OverrideException("x").getStackTrace().length);
int light = depth(50, () -> new LightException("x").getStackTrace().length);
System.out.println("full trace has more than 50 frames: " + (full > 50));
System.out.println("override frames: " + overridden);
System.out.println("writableStackTrace=false frames: " + light);
LightException shared = new LightException("reused");
System.out.println("reusable message: " + shared.getMessage());
}
}full trace has more than 50 frames: true
override frames: 0
writableStackTrace=false frames: 0
reusable message: reusedThe example passes a small lambda (() -> ...) to run code at the bottom of a deep call; Lambdas and Functional Interfaces teaches the syntax. An exception without a stack trace can be created once and thrown many times, which removes the allocation too. The trade-off is that a log shows no frames, so this is used only for exceptions that are always caught. The JIT also has an optimization, -XX:+OmitStackTraceInFastThrow (on by default), that replaces some frequently thrown built-in exceptions, such as NullPointerException in compiled code, with a preallocated instance without a stack trace.
Example 7: exceptions versus result types
An exception suits failures that the immediate caller cannot handle. For outcomes that are expected and handled at once, such as a validation failure on user input, a result type (a return value that holds either a value or an error) makes the outcome part of the signature and costs no stack walk.
import java.util.ArrayList;
import java.util.List;
public class ResultTypes {
static final class ParseResult {
private final Integer value;
private final String error;
private ParseResult(Integer value, String error) { this.value = value; this.error = error; }
static ParseResult ok(int v) { return new ParseResult(v, null); }
static ParseResult error(String e) { return new ParseResult(null, e); }
boolean isOk() { return error == null; }
int value() { return value; }
String error() { return error; }
}
static ParseResult parseQuantity(String s) {
if (s == null || s.isBlank()) return ParseResult.error("empty");
for (int i = 0; i < s.length(); i++) {
if (!Character.isDigit(s.charAt(i))) return ParseResult.error("not a number: " + s);
}
if (s.length() > 6) return ParseResult.error("too large: " + s);
return ParseResult.ok(Integer.parseInt(s));
}
public static void main(String[] args) {
List<String> errors = new ArrayList<>();
int total = 0;
for (String s : List.of("10", "", "x7", "5", "99999999")) {
ParseResult r = parseQuantity(s);
if (r.isOk()) total += r.value();
else errors.add(r.error());
}
System.out.println("total: " + total);
System.out.println("errors: " + errors);
}
}total: 15
errors: [empty, not a number: x7, too large: 99999999]All errors are collected in one pass instead of stopping at the first throw. Sealed interfaces, taught in Records and Sealed Types, give a shorter form of the same idea. Optional covers the case with no error detail (Optional, Null Handling, and API Design).
Example 8: rules at API boundaries and interruption
A layer boundary, such as a repository called by a service, follows these rules:
| Rule | Reason |
|---|---|
| Translate low-level exceptions into the layer's own types, with the cause | Callers do not depend on the implementation (JDBC, HTTP client) |
| Use checked exceptions for recoverable conditions the caller must decide on | The compiler enforces handling |
| Use unchecked exceptions for programming errors and unrecoverable failures | Callers cannot do anything useful with them |
| Never catch and ignore; log or rethrow, not both | Ignored failures hide bugs; log-and-rethrow logs one failure many times |
Catch InterruptedException and restore the interrupt flag |
Code higher up the stack needs to see the interruption |
InterruptedException is thrown by blocking methods such as Thread.sleep when another thread asks this thread to stop. Catching it clears the thread's interrupt flag, so code that cannot rethrow it calls Thread.currentThread().interrupt() to set the flag again. Threads, synchronized, wait and notify covers interruption in full.
public class BoundaryRules {
static final class RepositoryException extends RuntimeException {
RepositoryException(String m, Throwable cause) { super(m, cause); }
}
static String loadFromDisk(String id) throws java.io.IOException {
throw new java.io.IOException("disk read failed");
}
static String find(String id) {
try {
return loadFromDisk(id);
} catch (java.io.IOException e) {
throw new RepositoryException("cannot load " + id, e);
}
}
static void waitQuietly() {
try {
Thread.sleep(1_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // restore the flag
}
}
public static void main(String[] args) {
try {
find("user-7");
} catch (RepositoryException e) {
System.out.println(e.getMessage() + ", cause: " + e.getCause().getMessage());
}
Thread.currentThread().interrupt(); // simulate an interrupt request
waitQuietly(); // sleep throws at once
System.out.println("flag still set: " + Thread.currentThread().isInterrupted());
System.out.println("interrupted() clears it: " + Thread.interrupted()
+ " then " + Thread.currentThread().isInterrupted());
}
}cannot load user-7, cause: disk read failed
flag still set: true
interrupted() clears it: true then falseThread.sleep throws immediately when the flag is already set, so the program does not wait. Without the interrupt() call in the handler, the flag would be false and callers would not know a stop was requested.
Interview points
Throwablesplits intoErrorandException;RuntimeExceptionandErrorsubclasses are unchecked, everything else is checked.- A
returninfinallyoverrides thetryresult and discards a thrown exception. - The value of a
returnexpression intryis computed beforefinallyruns. - Multi-catch types cannot be related by subclassing; subclass clauses must precede superclass clauses.
- Try-with-resources keeps the body's exception and attaches
close()failures as suppressed; manualfinallyloses the original. - Always pass the cause when wrapping an exception.
- Stack trace capture in
fillInStackTraceis the main cost;writableStackTrace = falseor an override removes it. - Expected, locally handled failures fit result types; exceptions fit failures the caller cannot handle.
- On
InterruptedException, rethrow or restore the flag withThread.currentThread().interrupt().
Summary
- Checked exceptions must be caught or declared; unchecked ones need not be.
- A
catchclause also catches subclasses of its type. finallyruns on normal completion, exception, andreturn.- Resources close in reverse order;
getSuppressed()returns close failures. getCause()walks to the original exception.Throwable(String, Throwable, boolean, boolean)controls suppression and stack trace capture.OmitStackTraceInFastThrowis on by default.- Catching
InterruptedExceptionclears the interrupt flag.
