Exception Handling

The exception hierarchy, checked vs unchecked, and how to handle errors correctly

← Back to Index

What is an Exception?

An exception is an object that represents an abnormal condition during program execution. When something goes wrong β€” a file doesn't exist, a network connection drops, someone passes null where a value was expected β€” the JVM (or your own code) throws an exception object. This interrupts the normal execution flow immediately.

Without exception handling, that interruption crashes the program and the user sees a raw stack trace. With exception handling, you can catch the exception, react to it in a controlled way, and decide whether to recover, report, or propagate it up the call stack.

// Without exception handling: program crashes
int[] numbers = {1, 2, 3};
System.out.println(numbers[10]);  // ← THROWS ArrayIndexOutOfBoundsException
System.out.println("this never runs");

// Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException:
// Index 10 out of bounds for length 3

// With exception handling: you control what happens
try {
    System.out.println(numbers[10]);  // ← throws, jumps to catch immediately
} catch (ArrayIndexOutOfBoundsException e) {
    System.out.println("Index does not exist: " + e.getMessage());
}
System.out.println("this DOES run");  // execution continues normally

Throwing vs catching

Throwing means signalling that something went wrong. You use the throw keyword with an exception object. Execution at the throw site stops immediately β€” no more code in that method runs after a throw.

Catching means intercepting a thrown exception before it crashes the program. You use try { } catch { }. The catch block only runs if an exception of the matching type was thrown inside the try block.

// THROWING β€” you signal the problem
public void validateAge(int age) {
    if (age < 0)
        throw new IllegalArgumentException("Age cannot be negative: " + age);
    // nothing below this line runs if age < 0
    this.age = age;
}

// CATCHING β€” you handle the problem
try {
    validateAge(-5);
} catch (IllegalArgumentException e) {
    System.out.println("Bad input: " + e.getMessage());
    // decide: fix it, log it, re-throw it, show a message to the user...
}

Runtime exceptions vs compile-time exceptions

Java distinguishes two kinds:

  • Checked exceptions β€” the compiler knows this operation can fail and forces you to deal with it. You must either catch it or declare it with throws in the method signature. Examples: reading a file (IOException), querying a database (SQLException). The idea: these are recoverable external failures that the caller should be aware of.
  • Unchecked exceptions (runtime exceptions) β€” the compiler does not force you to handle them. They represent programming bugs or violated preconditions: NullPointerException, IllegalArgumentException, IndexOutOfBoundsException. The idea: if your code is correct, these shouldn't happen β€” so forcing every caller to wrap every method call in try-catch would be noise.
// CHECKED β€” compiler rejects this without try-catch or throws declaration
public void readFile(String path) {
    new FileReader(path);  // compile error: FileNotFoundException not handled
}

// UNCHECKED β€” compiles fine, but may blow up at runtime
public void printLength(String s) {
    System.out.println(s.length());  // NullPointerException if s is null
}
The exception object

An exception is a regular Java object β€” it has fields and methods. The most useful ones: getMessage() returns the human-readable description, getCause() returns the original exception that triggered this one (if any), and printStackTrace() prints the full call stack at the moment of the throw β€” essential for debugging.

The Exception Hierarchy

In Java, everything throwable is a subclass of Throwable. The hierarchy determines what you must handle, what you can ignore, and what you should never catch.

/*
 *  Throwable
 *  β”œβ”€β”€ Error                  ← JVM problems, do NOT catch
 *  β”‚   β”œβ”€β”€ OutOfMemoryError
 *  β”‚   β”œβ”€β”€ StackOverflowError
 *  β”‚   └── AssertionError
 *  β”‚
 *  └── Exception
 *      β”œβ”€β”€ IOException         ← CHECKED: compiler forces you to handle
 *      β”‚   β”œβ”€β”€ FileNotFoundException
 *      β”‚   └── SocketException
 *      β”œβ”€β”€ SQLException        ← CHECKED
 *      β”œβ”€β”€ ClassNotFoundException ← CHECKED
 *      β”‚
 *      └── RuntimeException    ← UNCHECKED: optional to handle
 *          β”œβ”€β”€ NullPointerException
 *          β”œβ”€β”€ IllegalArgumentException
 *          β”œβ”€β”€ IllegalStateException
 *          β”œβ”€β”€ UnsupportedOperationException
 *          β”œβ”€β”€ ArithmeticException
 *          β”œβ”€β”€ ClassCastException
 *          β”œβ”€β”€ IndexOutOfBoundsException
 *          β”‚   └── ArrayIndexOutOfBoundsException
 *          └── NumberFormatException
 */
Category Must handle? Typical cause Examples
Error ❌ Never catch JVM / system failure OutOfMemoryError, StackOverflowError
Checked Exception βœ… Yes (catch or declare) External resources IOException, SQLException
Unchecked (Runtime) Optional Programming bugs NullPointerException, IllegalArgumentException

try / catch / finally

try {
    // code that may throw
    int result = 100 / divisor;
    processResult(result);

} catch (ArithmeticException e) {
    // specific exception first
    log.error("Division failed", e);

} catch (IllegalArgumentException | IllegalStateException e) {
    // multi-catch: same handler for two unrelated exceptions (Java 7+)
    log.warn("Validation error", e);

} catch (Exception e) {
    // general catch last β€” order matters: most specific first
    log.error("Unexpected error", e);
    throw e;  // re-throw if you can't handle it here

} finally {
    // ALWAYS runs β€” with or without exception, even with return in try/catch
    // Only exception: System.exit() or JVM crash
    cleanup();
}
Catch order matters
// ❌ Unreachable catch β€” Exception catches everything above it
catch (Exception e)              { }
catch (IOException e)            { }  // compile error: already caught by Exception

// βœ… Most specific first, most general last
catch (FileNotFoundException e)   { }  // subtype of IOException
catch (IOException e)            { }  // catches remaining IO exceptions
catch (Exception e)              { }  // last resort

try-with-resources β€” automatic close (Java 7+)

Any object implementing AutoCloseable can go in the parentheses. It is closed automatically at the end of the block, even if an exception is thrown.

// ❌ Old way: fragile, easy to forget close in the exception path
Connection conn = null;
try {
    conn = dataSource.getConnection();
    // use conn...
} finally {
    if (conn != null) try { conn.close(); } catch (SQLException ignored) {}
}

// βœ… try-with-resources: clean, guaranteed close, multiple resources OK
try (Connection conn   = dataSource.getConnection();
     PreparedStatement ps = conn.prepareStatement("SELECT 1");
     ResultSet rs         = ps.executeQuery()) {

    while (rs.next()) { ... }

} catch (SQLException e) {
    // conn, ps, rs all closed before we get here β€” in reverse order
    throw new RuntimeException("DB query failed", e);
}

Checked vs Unchecked Exceptions

Checked β€” compiler enforces handling

// Checked exceptions extend Exception (but NOT RuntimeException)
// You MUST either catch them or declare them with throws

// Option 1: catch and handle
public String readFile(String path) {
    try (BufferedReader r = new BufferedReader(new FileReader(path))) {
        return r.lines().collect(Collectors.joining("\n"));
    } catch (IOException e) {
        log.error("Cannot read {}", path, e);
        return "";
    }
}

// Option 2: declare and let the caller decide
public String readFile(String path) throws IOException {
    try (BufferedReader r = new BufferedReader(new FileReader(path))) {
        return r.lines().collect(Collectors.joining("\n"));
    }
}

Unchecked β€” for programming errors and validation failures

// Unchecked = extend RuntimeException
// Use for: invalid arguments, broken preconditions, bugs
// The compiler does NOT force callers to handle them

public void setAge(int age) {
    if (age < 0 || age > 150)
        throw new IllegalArgumentException("Invalid age: " + age);
    this.age = age;
}

// Most useful unchecked exceptions from the JDK:
throw new IllegalArgumentException("bad input");   // invalid parameter value
throw new IllegalStateException("not connected");    // method called at wrong time
throw new NullPointerException("name is null");      // explicit null check (rare)
throw new UnsupportedOperationException("not implemented"); // stub
throw new IndexOutOfBoundsException("index: " + i);  // invalid index

// Objects.requireNonNull β€” standard null guard (throws NullPointerException)
this.name = Objects.requireNonNull(name, "name must not be null");

Custom Exceptions

Create domain-specific exceptions when standard JDK types don't convey enough meaning. The rule: extend RuntimeException for programming errors / unrecoverable situations; extend Exception for recoverable situations where callers should be forced to handle.

// Custom unchecked exception β€” callers don't have to catch it
public class InsufficientFundsException extends RuntimeException {
    private final double available;
    private final double requested;

    public InsufficientFundsException(double available, double requested) {
        super(String.format("Insufficient funds: available=%.2f, requested=%.2f",
                            available, requested));
        this.available = available;
        this.requested = requested;
    }

    // Always provide a cause-accepting constructor for exception chaining
    public InsufficientFundsException(double available, double requested,
                                       Throwable cause) {
        super(String.format("Insufficient funds: available=%.2f, requested=%.2f",
                            available, requested), cause);
        this.available = available;
        this.requested = requested;
    }

    public double getAvailable() { return available; }
    public double getRequested() { return requested; }
}

// Usage β€” caller gets rich context, not just a message string
public void withdraw(double amount) {
    if (amount > balance)
        throw new InsufficientFundsException(balance, amount);
    balance -= amount;
}

try {
    account.withdraw(500);
} catch (InsufficientFundsException e) {
    System.out.printf("Need %.2f more%n", e.getRequested() - e.getAvailable());
}

Exception Chaining

When you catch an exception and throw a different one, always pass the original as the cause. Without it, the root cause disappears from the stack trace β€” a debugging nightmare.

// ❌ BAD: original cause lost β€” the stack trace shows only OrderException,
// you can never find out WHAT SQLException triggered it
try {
    db.save(order);
} catch (SQLException e) {
    throw new OrderException("Failed to save order");  // cause dropped!
}

// βœ… GOOD: wrap and preserve the cause
try {
    db.save(order);
} catch (SQLException e) {
    throw new OrderException("Failed to save order", e);  // e is the cause
}

// When you inspect the exception, the full chain is visible:
// OrderException: Failed to save order
//   Caused by: java.sql.SQLException: Connection refused
//   Caused by: java.net.ConnectException: Connection refused

// Retrieve the cause programmatically:
e.getCause();           // direct cause
ExceptionUtils.getRootCause(e);  // root cause (Apache Commons)

Common Exceptions Reference

Exception Type When it occurs Prevention
NullPointerException Unchecked Method called on null reference Objects.requireNonNull, Optional
IllegalArgumentException Unchecked Parameter fails precondition Validate in method/constructor
IllegalStateException Unchecked Method called in wrong state Guard with state check
IndexOutOfBoundsException Unchecked Invalid array/list index Check size() / length first
ClassCastException Unchecked Invalid cast Use instanceof before casting
NumberFormatException Unchecked parseInt("abc") Validate input before parsing
IOException Checked File / network / stream error try-with-resources
SQLException Checked Database operation failed Wrap in domain exception with cause
StackOverflowError Error Infinite recursion Ensure base case in recursion
OutOfMemoryError Error Heap exhausted Fix memory leaks, tune heap

Common Pitfalls

Swallowing exceptions β€” the silent killer
// ❌ Empty catch: the error disappears, the bug hides forever
try {
    processOrder(order);
} catch (Exception e) {
    // nothing
}

// βœ… At minimum: log. If you truly can't handle it, re-throw.
try {
    processOrder(order);
} catch (OrderException e) {
    log.error("Order processing failed for orderId={}", order.getId(), e);
    throw e;  // or wrap in a higher-level exception
}
Using exceptions for flow control
// ❌ BAD: exceptions are expensive β€” stack trace captured on construction
try {
    int value = map.get("key").intValue();  // may NPE if key absent
} catch (NullPointerException e) {
    value = 0;  // using exception to handle "not found" case
}

// βœ… GOOD: check first, exception-free path for the common case
int value = map.getOrDefault("key", 0);
Catching Error or Throwable
// ❌ NEVER catch Error or raw Throwable in normal application code
catch (Throwable t) { ... }  // catches OutOfMemoryError, StackOverflowError…
catch (Error e) { ... }      // the JVM is broken β€” you can't recover

// βœ… Only catch what you can actually handle
// Exception: frameworks (Spring, JUnit) catch Throwable intentionally β€” that's fine
Losing the cause in exception translation
// ❌ Root cause gone β€” impossible to diagnose in production
catch (SQLException e) {
    throw new ServiceException("DB error");  // no cause!
}

// βœ… Always chain the cause
catch (SQLException e) {
    throw new ServiceException("DB error", e);  // full chain preserved
}

Senior Topics: Exceptions in Modern Java

Exception handling in Streams and lambdas

Lambdas cannot throw checked exceptions β€” functional interface methods don't declare them. This is a real pain with streams over I/O operations.

// ❌ Doesn't compile: Files::readString throws IOException (checked)
List<String> contents = paths.stream()
    .map(Files::readString)   // compile error: unhandled IOException
    .toList();

// Option 1: wrap checked in unchecked inside the lambda
List<String> contents = paths.stream()
    .map(path -> {
        try { return Files.readString(path); }
        catch (IOException e) { throw new UncheckedIOException(e); }
    })
    .toList();

// Option 2: extract to a helper that wraps (reusable)
private static <T, R> Function<T, R> wrap(CheckedFunction<T, R> fn) {
    return t -> {
        try { return fn.apply(t); }
        catch (Exception e) { throw new RuntimeException(e); }
    };
}
// Usage: paths.stream().map(wrap(Files::readString)).toList();

// Option 3 (Java 21+): use vavr or Result types for functional error handling

Suppressed exceptions in try-with-resources

// If both the try body AND close() throw, close()'s exception is "suppressed"
// The primary exception propagates; the suppressed one is attached

try (MyResource r = new MyResource()) {
    r.riskyOperation();  // throws ExceptionA
    // r.close() called automatically β†’ throws ExceptionB
    // ExceptionA propagates; ExceptionB is attached as suppressed
}

// Inspect suppressed exceptions:
try { ... }
catch (Exception primary) {
    for (Throwable suppressed : primary.getSuppressed()) {
        log.error("Suppressed: {}", suppressed.getMessage());
    }
    throw primary;
}

Global exception handling in Spring

// Instead of try-catch in every controller, centralise with @ControllerAdvice
@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(InsufficientFundsException.class)
    public ResponseEntity<ErrorResponse> handle(InsufficientFundsException e) {
        return ResponseEntity
            .status(HttpStatus.UNPROCESSABLE_ENTITY)
            .body(new ErrorResponse("INSUFFICIENT_FUNDS", e.getMessage()));
    }

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ErrorResponse> handle(IllegalArgumentException e) {
        return ResponseEntity.badRequest()
            .body(new ErrorResponse("INVALID_INPUT", e.getMessage()));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnexpected(Exception e) {
        log.error("Unhandled exception", e);
        return ResponseEntity.internalServerError()
            .body(new ErrorResponse("INTERNAL_ERROR", "Please try later"));
    }
}

Interview Questions

πŸŽ“ Junior level

Q: What is the difference between checked and unchecked exceptions?
Checked exceptions extend Exception (but not RuntimeException). The compiler forces callers to either catch them or declare them with throws. Unchecked exceptions extend RuntimeException β€” handling is optional. Use checked for recoverable external failures (file not found, network timeout); use unchecked for programming errors and failed preconditions.

Q: What is the purpose of the finally block?
Code in finally always runs: after normal completion, after a caught exception, and even after an uncaught exception unwinds the stack. The only exceptions: System.exit() and JVM crash. Its primary use is resource cleanup β€” though try-with-resources is the modern replacement.

Q: What does try-with-resources do?
Any AutoCloseable declared in the parentheses is automatically closed at block exit, even on exception. Multiple resources are closed in reverse order of declaration. If both the body and close() throw, the close exception is suppressed and attached to the primary exception via getSuppressed().

πŸ”₯ Senior level

Q: Should you use checked or unchecked exceptions for domain errors?
This is one of Java's great debates. The original intent: checked for recoverable situations the caller must handle. In practice, most modern frameworks and libraries (Spring, Hibernate, JUnit) prefer unchecked because checked exceptions pollute call stacks (every intermediate layer must declare or wrap them), break lambdas (functional interfaces don't declare checked exceptions), and the contract enforcement argument is weak β€” callers often swallow checked exceptions with empty catches. Effective Java Item 71: "use unchecked exceptions for programming errors; consider checked for recoverable conditions only when the caller can reasonably be expected to handle them."

Q: Why is exception swallowing dangerous and how do you detect it?
An empty catch block (or one that only logs without re-throwing or taking corrective action) silently absorbs the error. The system enters an unknown state with no trace of what happened. Tools: SpotBugs / PMD flag empty catches; SonarQube rules DE_MIGHT_IGNORE and REC_CATCH_EXCEPTION; code review. The rule: if you catch an exception and can't fully handle it, re-throw it (as-is, or wrapped).

Q: What are suppressed exceptions and when do they occur?
When try-with-resources closes a resource, if both the try body and close() throw, the close exception is suppressed β€” attached to the primary exception rather than replacing it. Pre-Java 7, the close exception would overwrite the primary one, silently losing the real error. Access them via Throwable.getSuppressed(). This is why try-with-resources is safer than manual finally blocks for resource closing.

Q: How do you handle checked exceptions in lambda expressions?
You can't β€” functional interface methods don't declare checked exceptions, so the compiler rejects them. Options: (1) wrap in unchecked inside the lambda β€” verbose but explicit; (2) create a utility wrap() method that converts any CheckedFunction to Function; (3) use UncheckedIOException for IOException specifically β€” it's a standard JDK wrapper. Libraries like Vavr and Lombok's @SneakyThrows provide more elegant solutions.