Course contents

Exceptions: the must-knows

A thorough revision sheet of everything interviewers ask about Java exceptions: the hierarchy, checked vs unchecked, stack unwinding and its cost, finally and try-with-resources rules, custom exceptions, lambdas, threads and CompletableFuture, production handling, overriding rules and StackOverflowError vs OutOfMemoryError.

FreeMust know for interviewsLesson24 min readCoreReviewed Oct 2026 · Java 21
On this page
  1. Picture it first: the hierarchy decides everything
  2. Checked vs unchecked: when to use each
  3. Under the hood: how a throw unwinds the stack
  4. try, catch, finally: the exact rules and the output puzzles
  5. try-with-resources, close order and suppressed exceptions
  6. Custom exceptions and the best practices interviewers check
  7. Exceptions in lambdas and streams
  8. Exceptions in threads, executors and CompletableFuture
  9. Production handling: global handlers, error codes, retries and idempotency
  10. Overriding rules for throws, and the two Errors everyone asks about
  11. How to say it in the interview

🧯 What is this sheet?

It's every exception question I'd expect in a Java interview loop, on one page you can revise in about two hours. Exceptions look like a junior topic, but they're one of the best senior filters: the same interviewer who opens with "checked vs unchecked?" will follow up with "what happens to an exception thrown inside an executor task?" and "how do you stop a retried payment from charging twice?". You need the language rules, the JVM mechanics and the production habits, and this sheet covers all three.

For this sheet, we'll start with the hierarchy and the checked vs unchecked decision, then go under the hood to stack unwinding and its cost, then the exact rules of try, catch, finally and try-with-resources (the home of most output puzzles). After that we cover custom exceptions, lambdas and streams, threads and CompletableFuture, production handling, overriding rules, and the two Errors everyone gets asked about. Every item links to the full lesson. Tick the boxes as you go; your progress is saved.

Below the line (out of scope)

The 30-second answer

Everything throwable extends Throwable, which splits into Error (JVM-level problems like OutOfMemoryError that you don't catch) and Exception. RuntimeException and Error are unchecked; every other exception is checked, so the compiler forces you to catch or declare it. When you throw, the JVM captures the stack trace (the expensive part), then searches each method's exception table from the top frame down, popping frames until a handler matches; if none does, the thread dies and its UncaughtExceptionHandler runs. finally always runs except on System.exit, halt or a dead JVM, and a return in finally silently discards the exception. Try-with-resources closes in reverse order and attaches close failures as suppressed exceptions. In practice I use unchecked exceptions for most failures, wrap with the cause when translating between layers, log once at the boundary, handle async failures through Future.get or CompletableFuture.handle, and only retry transient errors on idempotent operations.

What you’ll learn

  • Draw the Throwable hierarchy and choose between checked and unchecked exceptions with a reason
  • Explain how a throw unwinds the stack through the exception table, and what fillInStackTrace() costs
  • Predict finally and try-with-resources puzzles: overridden returns, System.exit, close order and suppressed exceptions
  • Write custom exceptions that wrap the cause, and translate them at layer boundaries without log-and-throw
  • Handle failures in lambdas, threads, executors and CompletableFuture without losing them
  • Design production handling (global handlers, error codes, retries with idempotency) and tell StackOverflowError from OutOfMemoryError

Interview radar

Asked very often: Checked vs unchecked? Error vs Exception? Does finally always run? What does try-with-resources do? final vs finally vs finalize? throw vs throws? How do you write a custom exception? Senior rounds probe: A return inside finally, suppressed exceptions and close order, exception table and the cost of stack traces, overriding rules for throws, exceptions lost in ExecutorService.submit and CompletableFuture, checked exceptions inside lambdas, InterruptedException handling. Production angle: Swallowed exceptions that hide outages, the same stack trace logged five times per request, NPEs with no stack trace after warm-up, retries that double-charge a customer, and a service that keeps running after OutOfMemoryError in a half-broken state.

01Picture it first: the hierarchy decides everything

Think of the hierarchy as the routing table for every rule that follows. Whether the compiler nags you, whether catch (Exception e) catches it, and whether you should catch it at all all depend on which branch a type sits on.

The tree below shows the three zones: Error (red, don't catch), RuntimeException (amber, unchecked, usually a bug or an unrecoverable condition), and the rest of Exception (blue, checked).

Fig. 1

The Throwable hierarchy: Throwable splits into Error, with VirtualMachineError, OutOfMemoryError, StackOverflowError and AssertionError beneath it, and Exception, which splits into RuntimeException with NullPointerException, IllegalArgumentException, IllegalStateException, ClassCastException and ArithmeticException, and checked exceptions such as IOException, SQLException, InterruptedException, TimeoutException and ExecutionException

The Throwable hierarchy: Throwable splits into Error, with VirtualMachineError, OutOfMemoryError, StackOverflowError and AssertionError beneath it, and Exception, which splits into RuntimeException with NullPointerException, IllegalArgumentException, IllegalStateException, ClassCastException and ArithmeticException, and checked exceptions such as IOException, SQLException, InterruptedException, TimeoutException and ExecutionException
  • Throwable is the root: only Throwable instances can be thrown or caught. It carries a message, a cause, a stack trace and a list of suppressed exceptions. Full lesson
  • Error: serious problems the application isn't expected to recover from: OutOfMemoryError, StackOverflowError (both VirtualMachineError), NoClassDefFoundError, ExceptionInInitializerError, AssertionError. Errors are unchecked.
  • Exception: conditions a program might reasonably handle. Its subclass RuntimeException (and everything below it) is unchecked; every other Exception subclass is checked.
  • Unchecked = RuntimeException + Error: the compiler doesn't force you to catch or declare them. Checked = the rest, including Exception itself. That one sentence answers half the hierarchy questions.
  • throw vs throws: throw is the statement that raises an exception object; throws is the method-signature clause that declares checked exceptions the method may let escape.

02Checked vs unchecked: when to use each

The language rule is mechanical. The design question ("which should my exception be?") is where interviewers listen for judgement.

Checked Unchecked (RuntimeException)
Compiler Must catch or declare in throws No requirement
Typical meaning An expected failure of an external operation the caller can act on A programming error (bad argument, illegal state, null) or a failure the caller can't fix locally
JDK examples IOException, SQLException, InterruptedException, TimeoutException NullPointerException, IllegalArgumentException, IllegalStateException, UnsupportedOperationException
Works with lambdas Poorly: java.util.function types don't declare them Yes
Cost to callers Every layer must catch or re-declare Callers can ignore it until the boundary that handles it
  • The textbook rule: use checked exceptions for recoverable conditions the immediate caller can reasonably handle (retry another file, prompt the user), unchecked for programming errors. Full lesson
  • The modern practice: most frameworks (Spring, JPA, the java.time and java.util.function APIs) lean unchecked, because checked exceptions leak implementation details through every signature and break lambdas. If the caller three layers up is the only one who can act, a checked exception forces two layers of boilerplate.
  • My default: unchecked domain exceptions for business code, checked only at a narrow API where the caller genuinely must decide (and can). Whichever you pick, keep the cause when you convert.
Where candidates slip

Many candidates answer "checked exceptions are for things outside your control, unchecked for bugs," and stop. The follow-up is "then why does Spring wrap SQLException in an unchecked DataAccessException?" The answer that scores: the service layer can't do anything specific with a SQL state code, so forcing it to catch or declare SQLException only couples it to JDBC. Translation into an unchecked, meaningful exception at the boundary is the point.

03Under the hood: how a throw unwinds the stack

This is where senior rounds separate people who use exceptions from people who understand them. The mechanism has three steps: create, search, unwind.

  • Creating the exception captures the stack: the Throwable constructor calls fillInStackTrace(), which walks every frame of the current thread and records it. That walk is the main cost, and it's proportional to stack depth. The StackTraceElement[] objects are built lazily, only when you call getStackTrace() or print the trace. Full lesson
  • The exception table: every compiled method with a try has an exception table in its Code attribute, with rows of from, to, target, type. Entering a try executes no instructions at all, so the happy path is free. On athrow (or an exception the JVM raises, like an NPE), the JVM scans the current method's table in order for a row whose range covers the current instruction and whose type is a supertype of the thrown one.
  • Unwinding: if no row matches, the JVM pops the frame (its locals are gone) and repeats the search in the caller, at the instruction that made the call. When a row matches, the operand stack is cleared, the exception is pushed, and execution jumps to the handler. If no frame matches, the thread terminates and its UncaughtExceptionHandler runs.
  • How finally is compiled: modern javac copies the finally body onto every exit path (normal completion, each return, each catch), plus a catch-any table row (type = any) that runs the copy and rethrows. The old jsr/ret instructions aren't allowed in class files from Java 7 on.
  • Catch order: catch blocks are checked top to bottom, so a subclass must come before its superclass; the reverse is a compile error ("exception has already been caught").

Here's one throw travelling down four frames, checking each frame's table on the way.

Fig. 2

Stack unwinding: parseAmount throws NumberFormatException and fillInStackTrace records four frames; parseAmount has no handler so its frame is popped; charge has a catch for IOException, which doesn't match, so its finally block runs and the frame is popped; handleRequest catches IllegalArgumentException, which matches because NumberFormatException is a subclass, so execution continues there; main is never involved

Stack unwinding: parseAmount throws NumberFormatException and fillInStackTrace records four frames; parseAmount has no handler so its frame is popped; charge has a catch for IOException, which doesn't match, so its finally block runs and the frame is popped; handleRequest catches IllegalArgumentException, which matches because NumberFormatException is a subclass, so execution continues there; main is never involved
Performance angle

Orders of magnitude from JMH-style measurements on a laptop (your numbers will differ): returning a value costs about 1 to 5 ns. Creating a new exception is dominated by fillInStackTrace(): roughly 1 to 3 µs at 20 frames, and over 10 µs at the 150-plus frames common in framework-heavy services. A preallocated, stackless exception costs tens of nanoseconds to throw, and when the JIT inlines thrower and catcher it can become a plain jump. Logging is the bigger bill: printing a 150-frame trace is around 10 KB per log line. So don't use exceptions for expected outcomes on hot paths (validate input instead of catching NumberFormatException 50,000 times a second), and if you must, use the protected Throwable(message, cause, enableSuppression, writableStackTrace) constructor (Java 7) with writableStackTrace = false.

In the real world

A service starts logging java.lang.NullPointerException with no stack trace at all after running for an hour. That's HotSpot's OmitStackTraceInFastThrow optimisation: once C2 sees the same implicit exception (NPE, ArithmeticException, ArrayIndexOutOfBoundsException, ClassCastException, ArrayStoreException) thrown often enough from one spot, it swaps in a preallocated exception without a trace. Search the logs for the first occurrences after startup, which still have the trace, or run with -XX:-OmitStackTraceInFastThrow. Since Java 14 (JEP 358, on by default from Java 15), helpful NPE messages also name the exact null expression, such as Cannot invoke "Order.total()" because "order" is null.

04try, catch, finally: the exact rules and the output puzzles

This section produces most of the "what does this print?" questions. Learn the table, then do the puzzle.

Situation Does finally run? What the caller sees
try completes or returns normally Yes The return value is evaluated before finally; reassigning a local in finally doesn't change it (mutating a returned object does)
finally contains return Yes That value wins, and any in-flight exception is discarded silently
finally throws Yes The new exception replaces the original, which is lost unless you addSuppressed it
System.exit(...) in try or catch No The JVM starts shutdown; shutdown hooks run, finally doesn't
Runtime.halt, kill -9, JVM crash, power loss No Nothing more runs
Daemon thread still in try when the JVM exits No The thread is abandoned
  • finally always runs, except when the JVM stops (System.exit, halt, crash) or the thread never leaves the try (infinite loop, deadlock). That's the complete answer to "does finally always run?". Full lesson
  • Never return or throw from finally: both silently replace whatever was happening, including an exception that explained an outage. Most linters flag it.
  • Multi-catch (Java 7): catch (IOException | SQLException e) shares one handler. The alternatives can't be subclasses of each other (compile error), and e is implicitly final.
  • Precise rethrow (Java 7): in catch (Exception e) { log(e); throw e; }, if e is effectively final, the compiler knows it can only be the checked types the try can throw, so the method declares those, not Exception.
  • Unreachable catch: catching a checked exception the try block can't throw is a compile error (Exception and Throwable are exempt, because they also cover unchecked types).

Try it yourself first

Predict the return value of each method before reading the answers below.

static int a() {
    int x = 1;
    try { return x; }
    finally { x = 2; }
}

static int b() {
    try { return 1; }
    finally { return 2; }
}

static int c() {
    try { throw new IllegalStateException("payment failed"); }
    finally { return 42; }
}

static StringBuilder d() {
    StringBuilder sb = new StringBuilder("a");
    try { return sb; }
    finally { sb.append("b"); }
}

a() returns 1: the value of x was copied into the return slot before finally ran, and changing the local afterwards doesn't touch the copy. b() returns 2, because a return in finally overrides the earlier one. c() returns 42 and the IllegalStateException disappears without a trace, which is why this pattern is dangerous. d() returns a builder containing "ab": the copied value is a reference, and finally mutated the object it points to (the same pass-by-value rule from the Core Java sheet).

05try-with-resources, close order and suppressed exceptions

Try-with-resources (Java 7) is the default for anything with a close() method: streams, JDBC connections, locks wrapped in a resource, HTTP responses. Interviewers ask about it because the old try/finally version had a real bug that this fixed.

  • What it needs: any resource implementing AutoCloseable (Closeable is the I/O subinterface whose close throws IOException and should be idempotent). Since Java 9 you can list an existing effectively final variable: try (conn) { ... }. Full lesson
  • Close order: resources are closed in reverse order of declaration, and they are closed before any catch or finally block of the same statement runs. A resource that failed to initialise (its expression threw, or it's null) isn't closed.
  • Suppressed exceptions: if the body throws and then close() also throws, the body's exception is the one propagated, and the close failure is attached with addSuppressed; read it with getSuppressed(). If only close() throws, its exception is the primary one.
  • Why the old way was broken: with manual try/finally, an exception from close() in finally replaced the original exception from the body, so you'd see "stream closed" instead of the real failure.

Try it yourself first

Two resources, both fail on close, and the body fails too. Predict every printed line, in order.

record Res(String name) implements AutoCloseable {
    Res { System.out.println("open " + name); }
    @Override public void close() {
        System.out.println("close " + name);
        throw new IllegalStateException("close " + name);
    }
}

try (var a = new Res("A"); var b = new Res("B")) {
    throw new RuntimeException("body");
} catch (RuntimeException e) {
    System.out.println("caught " + e.getMessage()
        + ", suppressed " + e.getSuppressed().length);
} finally {
    System.out.println("finally");
}

It prints open A, open B, close B, close A, caught body, suppressed 2, finally. The resources close in reverse order, both close failures are attached to the body's exception instead of replacing it, and the catch runs only after both resources are closed. If you write try/finally by hand instead, an exception thrown by close() in finally replaces body, and the real failure is gone.

06Custom exceptions and the best practices interviewers check

Custom exceptions are where your production habits show. The interviewer is less interested in the class than in what you put in it and where you catch it.

  • Extend RuntimeException by default (or Exception if you deliberately want a checked one). Provide a constructor that takes a cause and passes it to super(message, cause). Add fields for the context a handler needs (an order ID, a reason code), never secrets or full card numbers. Full lesson
  • Wrap with the cause, always: throw new OrderLookupException(orderId, e) keeps the original trace under "Caused by:". Throwing a new exception without the cause destroys the evidence.
  • Don't swallow: an empty catch (or one that only prints) turns an outage into silence. If you really expect and ignore an exception, name the variable ignored and write a comment explaining why.
  • Don't log and throw: logging and then rethrowing makes every layer log the same failure, so one error shows up as five stack traces. Either handle it (log and recover) or propagate it, and log once at the boundary that handles it.
  • Translate at layer boundaries: the repository turns SQLException into a data-access exception, the service turns that into a domain exception (OrderNotFoundException), the web layer turns that into an HTTP 404. Each layer speaks its own language, with the cause chained underneath.
  • Don't use exceptions for control flow: expected outcomes like "not found" on a lookup are often better as Optional or a result type; reserve exceptions for the exceptional path.
  • InterruptedException is special: if you catch it and can't propagate it, restore the flag with Thread.currentThread().interrupt(), otherwise code higher up never learns the thread was asked to stop.

Here's a custom exception and a translation at a boundary, the way I'd write them:

public class PaymentUnavailableException extends RuntimeException {
    private final String orderId;

    public PaymentUnavailableException(String orderId, Throwable cause) {
        super("Payment provider unavailable for order " + orderId, cause);
        this.orderId = orderId;
    }

    public String orderId() { return orderId; }
}

// in the payment service: translate the vendor's exception, keep the cause
public Receipt charge(String orderId, Money amount) {
    try {
        return gateway.charge(orderId, amount);
    } catch (GatewayTimeoutException e) {
        throw new PaymentUnavailableException(orderId, e);   // no logging here
    }
}

Notice three things. The constructor takes the cause, so the gateway's stack trace survives as "Caused by:". The service doesn't log, because the global handler will log it once with the request context. And the message carries the order ID (useful for support) but not the card details.

Where candidates slip

Many candidates write catch (Exception e) { throw new ServiceException(e.getMessage()); }. It looks like wrapping, but passing only the message drops the cause and its stack trace, so the log says "Connection refused" with a trace that starts in your service instead of the socket. Pass e itself as the cause, every time.

07Exceptions in lambdas and streams

This comes up as soon as the interviewer sees you write a stream. The functional interfaces in java.util.function (Function, Consumer, Supplier) don't declare any checked exceptions, so a lambda body can't throw one.

  • The compile error: paths.stream().map(Files::readString) doesn't compile, because readString throws IOException and Function.apply doesn't declare it. Full lesson
  • The standard fix: catch inside the lambda and wrap in an unchecked exception, usually UncheckedIOException (Java 8) for I/O, then unwrap at the boundary if a caller needs the original.
  • Other options: write your own functional interface with a throws clause (ThrowingFunction<T, R, E extends Exception>), or use a plain for loop when the checked exception matters. "Sneaky throw" tricks that hide a checked exception from the compiler work, but they surprise every reader.
  • What a stream does on failure: an exception thrown by any stage stops the pipeline and propagates out of the terminal operation; elements after the failure aren't processed. In a parallel stream, the exception is rethrown in the calling thread, and other subtasks may still be running briefly when it arrives.
List<String> bodies = paths.stream()
    .map(p -> {
        try {
            return Files.readString(p);
        } catch (IOException e) {
            throw new UncheckedIOException("Failed to read " + p, e);
        }
    })
    .toList();

08Exceptions in threads, executors and CompletableFuture

This is the most common senior follow-up, because it's where exceptions silently disappear in real systems. An exception unwinds only its own thread's stack: it never jumps to the thread that started the work.

  • Plain threads: an uncaught exception terminates that thread only. The JVM calls the thread's UncaughtExceptionHandler, then its ThreadGroup, then the default handler set with Thread.setDefaultUncaughtExceptionHandler; with none set, the trace is printed to System.err. The main thread and others keep running.
  • ExecutorService.execute(runnable): the exception reaches the worker thread's uncaught handler (so it's printed), the worker dies, and the pool replaces it.
  • ExecutorService.submit(task): the exception is captured in the returned Future and nothing is printed. You only see it when you call future.get(), which throws ExecutionException with the original as getCause(). Never calling get() means never seeing the failure.
  • ScheduledExecutorService.scheduleAtFixedRate: if one run throws, all later runs are silently cancelled. Wrap the task body in try/catch and log.
  • CompletableFuture: exceptionally(ex -> fallback) recovers with a value; handle((value, ex) -> ...) always runs and can transform success or failure; whenComplete observes without changing the result. join() throws unchecked CompletionException, get() throws checked ExecutionException, and the throwable your callback receives is often wrapped in CompletionException, so unwrap getCause(). Module 05: Multithreading

Try it yourself first

Three tasks fail. Which failures show up in the console without any further code?

ExecutorService pool = Executors.newFixedThreadPool(2);

pool.execute(() -> { throw new IllegalStateException("A"); });
Future<?> f = pool.submit(() -> { throw new IllegalStateException("B"); });
CompletableFuture<String> cf = CompletableFuture
    .supplyAsync(() -> { throw new IllegalStateException("C"); });

try {
    f.get();
} catch (ExecutionException e) {
    System.out.println("B via get: " + e.getCause().getMessage());
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
}

String result = cf
    .handle((value, ex) -> ex == null ? value : "fallback after " + ex.getCause().getMessage())
    .join();

Only "A" is printed automatically, by the default uncaught exception handler of the worker thread. "B" is invisible until f.get() throws ExecutionException, and "C" is invisible until a stage looks at it: here handle receives a CompletionException whose cause is the IllegalStateException, so result is "fallback after C". Remove the get() and the handle, and two of three failures vanish without a log line.

Where candidates slip

Many candidates switch from execute to submit "because it returns a Future" and then never call get(). That converts loud failures into silent ones. If nobody waits on the result, use execute, or wrap the task body so it logs its own failures, or attach whenComplete/exceptionally to a CompletableFuture.

09Production handling: global handlers, error codes, retries and idempotency

In system-flavoured Java interviews, "how do you handle exceptions in your service?" is really a question about boundaries. Here's the shape I'd describe.

  • One global handler per entry point: in a web app, a single @RestControllerAdvice (Spring) or exception mapper maps exception types to HTTP statuses and a consistent body, logs once with the request or trace ID, and never leaks stack traces to clients. Spring 6 ships a ProblemDetail type for the RFC 9457 (formerly RFC 7807) problem format. Full lesson
  • Stable error codes: return a machine-readable code (PAYMENT_DECLINED, ORDER_NOT_FOUND) plus a human message. Clients branch on the code; the message can change.
  • Status mapping: validation and domain rule failures become 4xx (400, 404, 409, 422) and are logged at WARN or lower; unexpected failures become 500 and are logged at ERROR with the full trace. Alert on 5xx rate, not on every 4xx.
  • Retry only transient failures: timeouts, connection resets, 503 and 429 are worth retrying; a 400 or a validation error will fail the same way every time. Use exponential backoff with jitter (for example 100 ms, 200 ms, 400 ms, plus random spread) and cap attempts at around 3, so a struggling dependency isn't hit by a retry storm.
  • Retries need idempotency: a timed-out "charge card" call may have succeeded on the server. Send an idempotency key with the request so the server can return the original result instead of charging twice; only retry non-idempotent operations when that protection exists.
  • Fail fast on Errors: after an OutOfMemoryError, the process may be in an inconsistent state. Run with -XX:+HeapDumpOnOutOfMemoryError and -XX:+ExitOnOutOfMemoryError so the orchestrator restarts a clean instance and you keep the evidence.

Here's the shape of a retry with the two rules built in: retry only what's transient, and reuse one idempotency key across attempts.

Receipt chargeWithRetry(ChargeRequest req) throws InterruptedException {
    String idempotencyKey = req.orderId();          // same key on every attempt
    long backoffMs = 100;
    for (int attempt = 1; ; attempt++) {
        try {
            return gateway.charge(req, idempotencyKey);
        } catch (GatewayTimeoutException | ServiceUnavailableException e) {
            if (attempt == 3) throw new PaymentUnavailableException(req.orderId(), e);
            Thread.sleep(backoffMs + ThreadLocalRandom.current().nextLong(backoffMs));
            backoffMs *= 2;
        }
        // CardDeclinedException is not caught: retrying a decline can't succeed
    }
}

10Overriding rules for throws, and the two Errors everyone asks about

Two short topics close the sheet. Both are quick to state and easy to get backwards under pressure.

  • Overriding and throws: an overriding method may declare the same, fewer or narrower checked exceptions, or none, and any unchecked exceptions it likes. It may not add a new or broader checked exception, because code written against the parent type wouldn't be prepared for it. The same rule applies when implementing an interface method. Full lesson
  • Constructors are the reverse case: a subclass constructor must declare (at least) the checked exceptions its super(...) call can throw, because it can't wrap super(...) in a try.
  • StackOverflowError: the current thread's stack is full, almost always from unbounded or very deep recursion (a classic is toString or hashCode recursing through a bidirectional JPA relationship). The default thread stack (-Xss) is typically 1 MB on 64-bit Linux and macOS, enough for roughly 10,000 to 20,000 simple frames; it depends on frame size. The stack unwinds when it's thrown, so the JVM is usually healthy afterwards, but fix the recursion rather than catching it.
  • OutOfMemoryError: an allocation failed after the GC couldn't free enough space. The message tells you which region: Java heap space, Metaspace (too many classes, often a class loader leak), unable to create native thread (thread or OS limits), Direct buffer memory, Requested array size exceeds VM limit, or GC overhead limit exceeded (Parallel GC spending over 98% of time in GC while recovering under 2% of the heap). Any thread can be the one that hits it, often not the one leaking. Java memory management
StackOverflowError OutOfMemoryError
What ran out One thread's stack Heap, Metaspace, native memory or thread limits
Typical cause Unbounded recursion A leak (growing cache or collection), undersized heap, too many threads
Scope One thread Usually the whole process
Tuning flag -Xss -Xmx, -XX:MaxMetaspaceSize, -XX:MaxDirectMemorySize
What to do Fix the recursion, or convert to iteration Heap dump, find the dominator, fix the leak; restart the instance
Where candidates slip

Many candidates write catch (Throwable t) or catch (Exception e) "to be safe" deep in business code. Throwable also catches OutOfMemoryError and StackOverflowError, and keeping a damaged process running is worse than crashing it. Catch the narrowest type you can actually handle; broad catches belong only at the outermost boundary (a request handler or a task wrapper), where you log and fail the unit of work.

11How to say it in the interview

When I ask about exceptions, I'm listening for four things: that you know checked vs unchecked is defined by the hierarchy (RuntimeException and Error) and you have an opinion on when to use each; that you can explain unwinding and why stack traces cost something; that you know the finally and try-with-resources edge cases without guessing; and that you've handled failures in async code, where they get lost. Senior candidates volunteer the boundary rules (wrap with the cause, translate per layer, log once) and connect retries to idempotency without being prompted.

In the interview

"Everything thrown is a Throwable. Error is for JVM-level failures like OutOfMemoryError that I don't catch; RuntimeException and Error are unchecked, and every other exception is checked, so the compiler forces callers to handle or declare it. I use unchecked exceptions for most failures and checked ones only where the immediate caller can actually recover. When something throws, the JVM fills in the stack trace, which costs microseconds and grows with depth, then searches each frame's exception table and pops frames until a handler matches; if none matches, the thread dies and the uncaught handler runs. finally always runs unless the JVM stops, and I never return from it because it swallows exceptions. Try-with-resources closes in reverse order and attaches close failures as suppressed. Across layers I wrap with the cause and translate, and I log once at the boundary in a global handler that maps to error codes. In async code I make sure every Future is read or every CompletableFuture has a handle, and I only retry transient failures, with backoff and an idempotency key."

Here's how this might look if you sketch it on the whiteboard:

On the whiteboard

Exceptions

  • Throwable → Error (don't catch) | Exception → RuntimeException (unchecked) + rest (checked)
  • unchecked = RuntimeException + Error · throw statement vs throws clause
  • throw: fillInStackTrace (µs, ∝ depth) → exception table per frame → pop until match → else uncaught handler
  • try entry is free · finally copied onto every exit path
  • finally: always, except exit/halt/crash · return in finally swallows exception
  • TWR: close reverse order, before catch · close failures → getSuppressed()
  • wrap with cause · translate per layer · log once at boundary · no empty catch
  • lambdas: wrap checked → UncheckedIOException
  • execute → uncaught handler · submit → only via get() (ExecutionException) · CF: handle/exceptionally, unwrap CompletionException
  • retry transient only, backoff + jitter, ≤ 3, idempotency key
  • override: checked exceptions same or narrower · SOE = one stack · OOM = process, dump and restart

Interview questions for this topicComing soon

Further reading (8)