Guide contents

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 as OutOfMemoryError or StackOverflowError.
  • Exception: a condition a program may handle. Its subclass RuntimeException marks programming errors such as NullPointerException and IllegalArgumentException.

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.

Java
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());
        }
    }
}
Output
IOException -> Exception, checked
FileNotFoundException -> IOException, checked
IllegalArgumentException -> RuntimeException, unchecked
ArithmeticException -> RuntimeException, unchecked
StackOverflowError -> VirtualMachineError, unchecked
caught FileNotFoundException: missing.txt

A 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.

Java
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());
    }
}
Output
  try
  finally runs before the method returns
normal: 1
overrides: 2
swallows: -1
valueSnapshot: 10

valueSnapshot 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.

Java
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);
        }
    }
}
Output
1 -> ok
abc -> not a number
0 -> ArithmeticException
5 -> ArrayIndexOutOfBoundsException

NumberFormatException 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.

Java
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);
        }
    }
}
Output
primary: body failed
suppressed: close failed: b
suppressed: close failed: a
manual finally, primary: close failed: c
suppressed count: 0

The 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.

Java
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());
        }
    }
}
Output
payment failed for ord-42
order: ord-42
cause: java.io.IOException: connection reset
root cause type: IOException

Wrapping 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.

Java
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());
    }
}
Output
full trace has more than 50 frames: true
override frames: 0
writableStackTrace=false frames: 0
reusable message: reused

The 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.

Java
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);
    }
}
Output
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.

Java
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());
    }
}
Output
cannot load user-7, cause: disk read failed
flag still set: true
interrupted() clears it: true then false

Thread.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

  • Throwable splits into Error and Exception; RuntimeException and Error subclasses are unchecked, everything else is checked.
  • A return in finally overrides the try result and discards a thrown exception.
  • The value of a return expression in try is computed before finally runs.
  • 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; manual finally loses the original.
  • Always pass the cause when wrapping an exception.
  • Stack trace capture in fillInStackTrace is the main cost; writableStackTrace = false or 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 with Thread.currentThread().interrupt().

Summary

  • Checked exceptions must be caught or declared; unchecked ones need not be.
  • A catch clause also catches subclasses of its type.
  • finally runs on normal completion, exception, and return.
  • 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.
  • OmitStackTraceInFastThrow is on by default.
  • Catching InterruptedException clears the interrupt flag.