Exception Handling

Difficulty

Java splits exceptions into two categories, enforced differently by the compiler:

  • Checked exceptions: subclasses of Exception that are not RuntimeException. The compiler forces every caller to either catch them or declare them in a throws clause — the idea being they represent recoverable, expected failure conditions (a file not found, a network timeout) that calling code should consciously plan for.
void readFile(String path) throws IOException {
    Files.readString(Path.of(path)); // IOException must be declared or caught
}
  • Unchecked exceptions: subclasses of RuntimeException (NullPointerException, IllegalArgumentException, IndexOutOfBoundsException) — and all Errors. The compiler does not require handling them; they typically represent programming bugs or conditions you're not expected to recover from gracefully.
void divide(int a, int b) {
    return a / b; // ArithmeticException is unchecked — no throws needed
}

This design is genuinely controversial in the Java community: checked exceptions can lead to noisy throws clauses that propagate through many layers, or to lazy catch (Exception e) {} blocks that swallow errors just to satisfy the compiler. Many modern Java libraries and frameworks (Spring, most of the JDK's newer APIs) deliberately favor unchecked exceptions even for "recoverable" conditions, leaving the checked-vs-unchecked decision to the developer's judgment about the calling API's ergonomics rather than following the strict recoverable/unrecoverable distinction dogmatically.

Throwable
├── Error                     (unchecked — serious, usually unrecoverable)
│   ├── OutOfMemoryError
│   ├── StackOverflowError
│   └── ...
└── Exception
    ├── RuntimeException      (unchecked — programming errors)
    │   ├── NullPointerException
    │   ├── IllegalArgumentException
    │   ├── IndexOutOfBoundsException
    │   └── ...
    └── (everything else)     (checked — recoverable conditions)
        ├── IOException
        ├── SQLException
        └── ...
  • Throwable: the root of the entire hierarchy — only Throwable (and its subclasses) can be passed to throw or caught by catch.
  • Error: represents serious problems typically caused by the environment/JVM itself rather than application logic — OutOfMemoryError, StackOverflowError, NoClassDefFoundError. Applications generally shouldn't try to catch or recover from these; they usually indicate the JVM is in an unstable state.
  • Exception: represents application-level conditions. Splits further into RuntimeException (unchecked — see the checked/unchecked question) and all other checked exceptions.

Common gotcha: catch (Exception e) does not catch Errors (they're siblings under Throwable, not related by inheritance) — you'd need catch (Throwable t) for that, which is almost always a code smell since it also swallows things like OutOfMemoryError that you generally shouldn't attempt to handle.

Before Java 7, closing a resource safely required verbose, error-prone manual finally blocks:

FileReader fr = new FileReader("file.txt");
try {
    // use fr
} finally {
    fr.close(); // could itself throw, masking the original exception
}

try-with-resources (Java 7+) automates this: any resource declared in the parentheses is automatically closed when the block exits, in either the normal or exceptional path:

try (FileReader fr = new FileReader("file.txt");
     BufferedReader br = new BufferedReader(fr)) {
    System.out.println(br.readLine());
} // both br and fr are closed automatically, in reverse declaration order

The resource type must implement AutoCloseable (which declares void close() throws Exception), or its more specific subtype Closeable (void close() throws IOException, used by most I/O classes).

Closing order and suppressed exceptions: resources are closed in the reverse order they were declared (last-declared, first-closed). If the try block itself throws an exception, and closing a resource also throws, the close-time exception isn't lost — it's attached to the original exception as a suppressed exception, retrievable via Throwable.getSuppressed(), so no failure information is silently discarded.

Since Java 9, you can also use an effectively final variable declared outside the try directly in the parentheses, without redeclaring it:

BufferedReader br = new BufferedReader(new FileReader("file.txt"));
try (br) {
    System.out.println(br.readLine());
}

finally always runs, but it can also override whatever the try/catch was about to do — which is a classic source of subtle bugs:

1. An exception thrown in finally masks the original exception:

static void risky() {
    try {
        throw new RuntimeException("original");
    } finally {
        throw new IllegalStateException("from finally"); // this one wins
    }
}
// Caller only ever sees IllegalStateException — "original" is silently lost

(Note: this differs from try-with-resources, where a close-time exception is suppressed and attached rather than discarding the original — plain finally blocks give you no such safety net.)

2. A return in finally overrides any pending return value or exception:

static int test() {
    try {
        return 1;
    } finally {
        return 2; // this value is returned instead — 1 is discarded
    }
}
// test() returns 2

static int test2() {
    try {
        throw new RuntimeException();
    } finally {
        return 42; // swallows the exception entirely — no exception propagates!
    }
}
// test2() returns 42, and the RuntimeException never reaches the caller

Because of these surprises, returning (or throwing) from within a finally block is considered a serious anti-pattern and is flagged by most linters/static analyzers — finally should be reserved purely for cleanup (closing resources, releasing locks), never for controlling the method's outcome.

Exception chaining (or "exception wrapping") means catching a low-level exception and re-throwing a different, higher-level exception that wraps it as its cause:

class DataAccessException extends RuntimeException {
    DataAccessException(String message, Throwable cause) {
        super(message, cause); // preserves the original exception
    }
}

void loadUser(String id) {
    try {
        connection.query(id);
    } catch (SQLException e) {
        throw new DataAccessException("Failed to load user " + id, e); // chained
    }
}

Why it matters:

  • Abstraction boundaries: a repository layer shouldn't leak SQLException (a JDBC implementation detail) up through a service API — translating it to a domain-specific exception keeps layers decoupled, while chaining preserves the original detail for debugging.
  • No lost information: e.getCause() (and the printed stack trace, which shows "Caused by: ...") still exposes the original SQLException and its full stack trace, so you don't lose diagnostic detail just because you translated the exception type.
  • Root-cause analysis: Throwable.getCause() can be walked recursively to find the ultimate underlying failure when exceptions are chained multiple layers deep.

Nearly every Throwable subclass supports this via a (String message, Throwable cause) constructor, or by calling initCause(Throwable) after construction if the class predates that constructor pattern (pre-Java 1.4 style exceptions).