☕ Java

Exception Basics

An exception is an event that disrupts the normal flow of a program during execution. Java's exception system is a structured mechanism for signalling, propagating, and handling error conditions — a significant improvement over the older approach of returning special error codes that callers could silently ignore. Understanding exceptions means understanding the class hierarchy that categorises them, the distinction between checked and unchecked exceptions, what the JVM does when an exception is thrown, how the call stack is unwound, and what information an exception object carries. This entry covers the full exception hierarchy, the checked vs unchecked distinction and the reasoning behind it, exception propagation through the call stack, and the information model of exception objects.

The Exception Hierarchy

Every throwable entity in Java is an instance of java.lang.Throwable. Throwable has two direct subclasses: Error and Exception. This three-tier hierarchy — Throwable, Error, Exception — is the foundation of Java's entire exception system, and understanding it is essential for using exceptions correctly. Error represents serious problems that arise in the JVM itself or its environment — conditions so severe that recovery is generally impossible. OutOfMemoryError indicates that the JVM cannot allocate enough memory. StackOverflowError indicates that the call stack has grown beyond its limit (usually from infinite recursion). VirtualMachineError, AssertionError, and ExceptionInInitializerError are other examples. Application code should almost never catch Errors, and should certainly never throw them. Errors are signals that the JVM is in a state where continued execution is unsafe or impossible. Exception is the class application code works with. It has two important branches. Checked exceptions are direct subclasses of Exception that are not RuntimeException or its subclasses. The compiler enforces that checked exceptions must either be caught or declared in the method's throws clause — hence "checked." Unchecked exceptions are RuntimeException and its subclasses. The compiler does not enforce handling of unchecked exceptions — they can propagate freely without declaration. This distinction reflects the intended semantics: checked exceptions represent anticipated conditions the caller should handle; unchecked exceptions represent programming errors that indicate bugs.
Java
// ── The Exception Hierarchy ─────────────────────────────────────────
//
//  Throwable
//  ├── Error                           (JVM problems — almost never catch)
//  │   ├── OutOfMemoryError
//  │   ├── StackOverflowError
//  │   ├── VirtualMachineError
//  │   ├── AssertionError
//  │   └── ExceptionInInitializerError
//  │
//  └── Exception                       (application problems — handle these)
//      ├── IOException                 (checked)
//      │   ├── FileNotFoundException   (checked)
//      │   └── SocketException         (checked)
//      ├── SQLException                (checked)
//      ├── CloneNotSupportedException  (checked)
//      │
//      └── RuntimeException            (unchecked — compiler does not enforce)
//          ├── NullPointerException
//          ├── IllegalArgumentException
//          ├── IllegalStateException
//          ├── IndexOutOfBoundsException
//          │   ├── ArrayIndexOutOfBoundsException
//          │   └── StringIndexOutOfBoundsException
//          ├── ArithmeticException
//          ├── ClassCastException
//          ├── NumberFormatException
//          ├── UnsupportedOperationException
//          └── ConcurrentModificationException

// ── Checked vs unchecked ──────────────────────────────────────────────
// Checked — compiler REQUIRES handling (catch or declare in throws):
void readFile(String path) throws IOException {   // must declare
    Files.readAllBytes(Path.of(path));            // throws IOException
}

// Unchecked — compiler does NOT require handling:
void processNumber(String s) {
    int n = Integer.parseInt(s);   // may throw NumberFormatException
    // No throws declaration required
}

// ── instanceof checks ─────────────────────────────────────────────────
try {
    throw new FileNotFoundException("file.txt not found");
} catch (Throwable t) {
    System.out.println(t instanceof FileNotFoundException); // true
    System.out.println(t instanceof IOException);           // true
    System.out.println(t instanceof Exception);             // true
    System.out.println(t instanceof Throwable);             // true
    System.out.println(t instanceof RuntimeException);      // false
}

Checked vs Unchecked — The Design Philosophy

The checked/unchecked distinction embodies a design philosophy about which failures are the caller's responsibility. Checked exceptions model recoverable conditions that any reasonable caller might need to handle: a file may not exist (FileNotFoundException), a network connection may fail (IOException), a database query may fail (SQLException). The compiler's enforcement ensures that callers are aware of these possibilities and make a conscious decision — either handle the exception or propagate it. Without this enforcement, it would be easy to ignore failure modes that are genuinely recoverable. Unchecked exceptions model programming errors — bugs in the calling code. NullPointerException means code tried to use a null reference. IndexOutOfBoundsException means code accessed an array or list with an invalid index. ClassCastException means code tried to cast an object to an incompatible type. IllegalArgumentException means a method received an argument violating its preconditions. These should not happen in correct code, and when they do, the right response is to fix the bug, not to catch the exception and somehow "handle" it at runtime. The boundary is not perfectly clean in practice. Many developers and frameworks (including Spring) prefer unchecked exceptions for all application errors because checked exceptions create significant API surface area — every method that might fail transitively must either catch the checked exception or add it to its throws clause, which propagates viral declarations throughout the call chain. This led to widespread use of RuntimeException wrappers and the practice of translating checked exceptions into unchecked ones at architectural boundaries.
Java
// ── Checked exception — compiler enforces awareness ──────────────────
// The compiler WILL NOT compile this without handling IOException:
void readConfig() {                         // compile error — unhandled IOException
    Files.readAllBytes(Path.of("app.cfg")); // throws IOException (checked)
}

// Must either catch:
void readConfig_catching() {
    try {
        Files.readAllBytes(Path.of("app.cfg"));
    } catch (IOException e) {
        System.err.println("Config not found: " + e.getMessage());
    }
}

// Or declare:
void readConfig_declaring() throws IOException {
    Files.readAllBytes(Path.of("app.cfg"));  // propagates to caller
}

// ── Unchecked exception — caller has a bug ────────────────────────────
void processAge(int age) {
    if (age < 0) {
        throw new IllegalArgumentException(
            "Age cannot be negative: " + age);  // no throws needed
    }
}

// ── The viral declaration problem with checked exceptions ─────────────
// If a low-level method throws a checked exception:
void parseData() throws ParseException { ... }

// Every method in the call chain must declare or catch it:
void processData()     throws ParseException { parseData(); }
void handleRequest()   throws ParseException { processData(); }
void dispatch()        throws ParseException { handleRequest(); }
void serve()           throws ParseException { dispatch(); }
// Checked exception forces its way into every signature up the stack

// ── Common pattern: wrap checked as unchecked at boundary ─────────────
// Libraries often do this to avoid polluting the entire call stack
public class DataProcessingException extends RuntimeException {
    public DataProcessingException(String message, Throwable cause) {
        super(message, cause);
    }
}

void processData_wrapped() {
    try {
        parseData();
    } catch (ParseException e) {
        throw new DataProcessingException("Failed to parse data", e);
    }
}

Exception Propagation — The Call Stack

When an exception is thrown and not caught in the method where it occurs, it propagates up the call stack. The JVM abandons the current method's execution and looks for a matching catch block in the calling method. If none is found there, it propagates further up the stack, method by method, until either a matching catch block is found or the exception reaches the top of the thread's call stack. If it reaches the top without being caught, the thread terminates and the JVM prints the exception and its stack trace to standard error. This propagation mechanism is powerful because it separates the place where an error occurs from the place where it is handled. A low-level file reading utility does not need to know what the application should do when a file is missing — it throws FileNotFoundException and lets the business logic layer decide. The handler can be arbitrarily far up the stack from the thrower. The stack trace is the most valuable debugging tool an exception carries. It records the exact chain of method calls, file names, and line numbers from the point where the exception was thrown back up to the catch site (or to the thread's top). Reading a stack trace from bottom to top gives the call sequence that led to the problem; reading from top to bottom leads directly to the line of code that threw the exception.
Java
// ── Exception propagation through the call stack ────────────────────
public class PropagationDemo {

    public static void main(String[] args) {
        try {
            methodA();
        } catch (Exception e) {
            System.out.println("Caught in main: " + e.getMessage());
            e.printStackTrace();
        }
    }

    static void methodA() {
        methodB();   // exception propagates up from methodB
    }

    static void methodB() {
        methodC();   // exception propagates up from methodC
    }

    static void methodC() {
        throw new IllegalStateException("Something went wrong in C");
        // Not caught here — propagates to methodB
        // Not caught in methodB — propagates to methodA
        // Not caught in methodA — propagates to main
        // Caught in main's try-catch
    }
}

// ── Stack trace output ────────────────────────────────────────────────
// Exception in thread "main" java.lang.IllegalStateException:
//     Something went wrong in C
//   at PropagationDemo.methodC(PropagationDemo.java:24)   ← thrown here
//   at PropagationDemo.methodB(PropagationDemo.java:20)   ← called methodC
//   at PropagationDemo.methodA(PropagationDemo.java:16)   ← called methodB
//   at PropagationDemo.main(PropagationDemo.java:7)       ← called methodA

// ── Exception object information ─────────────────────────────────────
try {
    int[] arr = new int[5];
    arr[10] = 1;
} catch (ArrayIndexOutOfBoundsException e) {
    System.out.println(e.getMessage());    // "Index 10 out of bounds for length 5"
    System.out.println(e.getClass().getName());         // java.lang.ArrayIndexOutOfBoundsException
    System.out.println(e.getClass().getSimpleName());   // ArrayIndexOutOfBoundsException

    // Stack trace as an array of StackTraceElement
    StackTraceElement[] trace = e.getStackTrace();
    System.out.println(trace[0].getClassName());   // class where thrown
    System.out.println(trace[0].getMethodName());  // method where thrown
    System.out.println(trace[0].getLineNumber());  // line number

    // Print stack trace to a string (useful for logging)
    StringWriter sw = new StringWriter();
    e.printStackTrace(new PrintWriter(sw));
    String stackTraceString = sw.toString();
}

Creating Custom Exceptions

Custom exception classes communicate domain-specific failure conditions precisely. A method throwing OrderNotFoundException is more informative than one throwing RuntimeException with a message string — the exception type is part of the API contract. Custom exceptions also allow callers to catch specific failure types without catching broader exception classes that might obscure other errors. The decision of whether to extend Exception (checked) or RuntimeException (unchecked) follows the general principle: extend Exception when the failure is a recoverable condition that any reasonable caller should handle; extend RuntimeException when the failure indicates a programming error or a non-recoverable condition. Most modern Java code and frameworks prefer RuntimeException-based custom exceptions to avoid the viral declaration problem. Custom exceptions should provide constructors that accept a cause (another Throwable). Exception chaining preserves the original cause so that when a low-level exception is wrapped and rethrown as a domain exception, the root cause is still visible in the stack trace. This is essential for debugging — without the cause, the stack trace stops at the wrapping point and loses the information about what originally went wrong.
Java
// ── Custom checked 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 InsufficientFundsException(String message, Throwable cause) {
        super(message, cause);
        this.amount  = 0;
        this.balance = 0;
    }

    public double getAmount()  { return amount;  }
    public double getBalance() { return balance; }
}

// ── Custom unchecked exception ────────────────────────────────────────
public class OrderNotFoundException extends RuntimeException {

    private final Long orderId;

    public OrderNotFoundException(Long orderId) {
        super("Order not found: " + orderId);
        this.orderId = orderId;
    }

    // Cause constructor — preserves root cause in chain
    public OrderNotFoundException(Long orderId, Throwable cause) {
        super("Order not found: " + orderId, cause);
        this.orderId = orderId;
    }

    public Long getOrderId() { return orderId; }
}

// ── Domain exception hierarchy ────────────────────────────────────────
public class AppException extends RuntimeException {
    public AppException(String message)                  { super(message);        }
    public AppException(String message, Throwable cause) { super(message, cause); }
}

public class ValidationException extends AppException {
    private final String field;
    private final Object rejectedValue;

    public ValidationException(String field, Object rejectedValue, String msg) {
        super(msg);
        this.field         = field;
        this.rejectedValue = rejectedValue;
    }

    public String getField()         { return field;         }
    public Object getRejectedValue() { return rejectedValue; }
}

public class ServiceException  extends AppException {
    public ServiceException(String msg)                  { super(msg);        }
    public ServiceException(String msg, Throwable cause) { super(msg, cause); }
}

Related Topics in Exception Handling

try-catch
The try-catch statement is Java's primary mechanism for handling exceptions. Code that might throw an exception is placed in the try block; one or more catch blocks follow, each specifying the exception type to handle and providing the handling logic. When an exception is thrown inside the try block, execution of the try block immediately stops and the JVM searches the catch blocks in order for the first one whose type matches the thrown exception. Understanding the control flow precisely — what runs, what stops, and in what order — is essential for writing correct exception handling code. This entry covers try-catch control flow, exception type matching, catching by supertype, variable scope, and the key principles for writing good catch blocks.
Multiple catch
A single try block can be followed by multiple catch blocks, each handling a different exception type. Multiple catch blocks allow code to respond differently to different kinds of failures — recovering from an expected missing file, but reporting and rethrowing an unexpected database error. Java 7 introduced multi-catch syntax, allowing a single catch block to handle multiple unrelated exception types, eliminating the duplicate code that arises when different exceptions require the same handling. This entry covers the ordering rules, multi-catch syntax, the type of the caught variable in multi-catch, and the design patterns for structuring multiple exception handlers.
finally
The finally block contains code that must execute regardless of whether the try block completed normally, threw an exception that was caught, or threw an exception that was not caught. It is the mechanism for guaranteed cleanup — closing files, releasing locks, returning connections to a pool, rolling back transactions. The finally block runs in all scenarios except two: if the JVM exits (System.exit()) or if the thread is killed by an Error like StackOverflowError. Understanding when exactly finally runs, the interaction between finally and return statements, exception suppression when finally itself throws, and the modern alternative of try-with-resources is essential for writing correct resource management code in Java.
throw
The throw statement explicitly throws an exception object, immediately terminating normal execution and initiating exception propagation. throw is used to signal that a method cannot fulfil its contract because a precondition is violated, an invalid state has been detected, or an operation has failed. Understanding when to throw, what to throw, how to create informative exception objects, and how throwing interacts with the call stack is the foundation of using exceptions as a design tool rather than just an error mechanism. This entry covers the throw statement mechanics, throwing at the right abstraction level, creating informative exception messages, exception chaining, and the principles for deciding when to throw versus returning a special value.