Java Exception Handling Deep Dive: Checked vs Unchecked, Suppression, and What Exceptions Actually Cost
How Java exceptions really work, measured on JDK 25: cause chaining, checked vs unchecked, try-with-resources suppressed exceptions, JMH numbers for stack-trace capture (with and without writableStackTrace), and why HotSpot sometimes removes the trace for you.
A service logged java.lang.IllegalArgumentException: close failed and nothing else. The real failure — a query that had blown up a moment earlier — was gone, replaced by the exception thrown while cleaning up after it. Somebody had written a hand-rolled try/finally, and in Java the exception that leaves a finally block wins. This article starts from that kind of bug and works outward: what an exception actually is, why Caused by exists, what checked vs unchecked really changes, how try-with-resources keeps both failures, and — measured, not guessed — what throwing an exception costs and why HotSpot sometimes deletes the stack trace for you.
Versions.JDK 25.0.4.1+1 (Temurin, LTS), JMH 1.37, JUnit Jupiter 5.11.0, run on a 2-vCPU x86-64 virtual machine. Every code block and every line of output below comes from the exceptions module of the java-core-examples repository; the timings are indicative of ordering on that machine, not a leaderboard. The repository has no docs folder on purpose — the deeper material is in the collapsible sections of this article.
An exception is an object that unwinds the call stack until somebody catches it
Start with the smallest correct picture. When code does throw, the JVM creates an exception object, stops running the current method, and looks for a matching catch in that method. No match? It discards the method’s frame and looks in the caller, then the caller’s caller, and so on back up the call chain. The first catch that matches handles it; if none does, the thread dies and the JVM prints the trace. That walk is called unwinding, and it is the reason exceptions are good at reporting failure across many layers and bad at being used as a fast if.
The diagram shows the path for the ChainingDemo program: dao() throws, service() catches it only to wrap it, and the wrapper travels on to main. The next section is about that wrapping step, because it is where most information gets lost. The code is in ChainingDemo.java:
static void dao() { throw new IllegalStateException("connection reset by peer"); }
static void serviceGood() {
try { dao(); }
catch (IllegalStateException e) { throw new RuntimeException("could not load order 42", e); }
}
static void serviceBad() {
try { dao(); }
catch (IllegalStateException e) { throw new RuntimeException("could not load order 42: " + e.getMessage()); }
}
Run it and the JVM prints the wrapped exception with its cause underneath (output: 02-chaining.txt):
=== wrapped WITH the cause ===
getMessage(): could not load order 42
getCause(): java.lang.IllegalStateException: connection reset by peer
java.lang.RuntimeException: could not load order 42
at com.ankurm.exceptions.ChainingDemo.serviceGood(ChainingDemo.java:10)
at com.ankurm.exceptions.ChainingDemo.print(ChainingDemo.java:20)
at com.ankurm.exceptions.ChainingDemo.main(ChainingDemo.java:30)
Caused by: java.lang.IllegalStateException: connection reset by peer
at com.ankurm.exceptions.ChainingDemo.dao(ChainingDemo.java:6)
at com.ankurm.exceptions.ChainingDemo.serviceGood(ChainingDemo.java:9)
... 2 more
Read a trace like this from the bottom up. Caused by: is the original failure, at the place it happened (dao, line 6). The lines above it are the wrapper, thrown later. ... 2 more means the last two frames of the cause are identical to frames already printed for the wrapper, so Java does not repeat them.
The fingerprint of a lost cause. The second run in the same transcript shows the common mistake: throw new RuntimeException("could not load order 42: " + e.getMessage()). The message survives, but getCause() prints null and there is no Caused by: section, so the place where the connection actually reset is gone. If a log line has a good message and a stack that starts in the wrong layer, someone copied the message instead of passing the exception.
=== wrapped WITHOUT the cause (message copied as a string) ===
getMessage(): could not load order 42: connection reset by peer
getCause(): null
The rule is one line: pass the original exception as the second constructor argument, every time you wrap. (Output: 02-chaining.txt.)
Going deeper
Checked versus unchecked is a rule for the compiler, not a property of the failure
Java splits Throwable into three families. Errors (OutOfMemoryError, StackOverflowError) mean the JVM itself is in trouble and you normally cannot recover. RuntimeException and its subclasses are unchecked: the compiler does not care whether you catch them. Everything else under Exception is checked: a method that can throw one must either catch it or declare it with throws, and callers inherit the same choice. The program below asks the runtime which is which (CheckedVsUncheckedDemo.java, output 03-checked-vs-unchecked.txt):
The picture has one trick in it: RuntimeException sits underException but flips the rule back to unchecked. So “is it an Exception?” does not tell you whether the compiler will force you to handle it; “does it extend RuntimeException or Error?” does.
The practical consequence beginners hit first is that checked exceptions do not fit inside lambdas, because functional interfaces like Function declare no throws. Here is the real compiler message for that (source: LambdaChecked.java, output 06-lambda-checked-compile-error.txt):
src/broken/LambdaChecked.java:8: error: unreported exception IOException; must be caught or declared to be thrown
Stream.of("a", "b").map(n -> read(n)).forEach(System.out::println);
^
1 error
The standard way out is to wrap the checked exception in an unchecked one at the lambda boundary and keep the original as the cause. UncheckedIOException exists for exactly this. The demo does it and then unwraps on the far side:
=== checked exception inside a stream lambda: wrap in UncheckedIOException ===
contents of a.txt
caught: java.io.UncheckedIOException: java.io.IOException: cannot read bad.txt
original cause: java.io.IOException: cannot read bad.txt
Which should you choose for your own exceptions? This part is opinion, not measurement. Use a checked exception when the caller can realistically do something different depending on it (retry, ask the user, fall back) and you want the compiler to make sure someone thinks about it. Use unchecked for programming errors and for failures that only a top-level handler can deal with. Most application code ends up with an unchecked base type, which is what the custom hierarchy below does. Many modern libraries made the same choice, which is one reason lambdas and streams feel smoother with them.
Reference: the four compile-time rules for checked exceptions (each one has a real compiler message)
These come from the Java Language Specification (chapter 11, Exceptions), and each is backed by a source file in src/broken/ that is compiled on purpose to capture javac’s own words.
1. Catching a checked exception the body can never throw is an error. (UnreachableCatch.java)
src/broken/UnreachableCatch.java:8: error: exception IOException is never thrown in body of corresponding try statement
} catch (IOException e) {
^
1 error
src/broken/MultiCatchSubclass.java:8: error: Alternatives in a multi-catch statement cannot be related by subclassing
try { f(); } catch (FileNotFoundException | IOException e) { }
^
Alternative FileNotFoundException is a subclass of alternative IOException
1 error
3. An overriding method may not declare a checked exception the overridden method does not. (WiderOverride.java)
src/broken/WiderOverride.java:7: error: run() in Impl cannot implement run() in Task
public void run() throws IOException { throw new IOException(); }
^
overridden method does not throw IOException
1 error
This rule is why you cannot add throws IOException to an implementation of Runnable.run(), and why wrapping in an unchecked type is the usual adapter.
Going deeper
All four compile failures, with javac’s exact text: src/broken/ and the captured messages in output/
try-with-resources keeps both failures; try/finally keeps only the second
Anything that must be closed — a connection, a file, a lock wrapper — can fail while it is being used and fail again while it is being closed. The old pattern was try { use } finally { close }. The trouble is what the language does when both parts throw: the exception from finally replaces the one already in flight, and the original is discarded without a trace. Here are both patterns side by side, with a resource whose query() and close() both throw (SuppressedDemo.java):
static class Conn implements AutoCloseable {
final String name;
Conn(String name) { this.name = name; }
void query() { throw new IllegalStateException("query failed on " + name); }
@Override public void close() { throw new IllegalArgumentException("close failed on " + name); }
}
System.out.println("=== try/finally: the close() exception replaces the query() exception ===");
try {
Conn c = new Conn("db-1");
try {
c.query();
} finally {
c.close();
}
} catch (Exception e) {
System.out.println("caught: " + e);
System.out.println("suppressed count: " + e.getSuppressed().length);
}
System.out.println("=== try-with-resources: query() exception wins, close() exception is attached ===");
try (Conn c = new Conn("db-1")) {
c.query();
} catch (Exception e) {
System.out.println("caught: " + e);
System.out.println("suppressed count: " + e.getSuppressed().length);
for (Throwable s : e.getSuppressed()) {
System.out.println(" suppressed: " + s);
}
}
=== try/finally: the close() exception replaces the query() exception ===
caught: java.lang.IllegalArgumentException: close failed on db-1
suppressed count: 0
=== try-with-resources: query() exception wins, close() exception is attached ===
caught: java.lang.IllegalStateException: query failed on db-1
suppressed count: 1
suppressed: java.lang.IllegalArgumentException: close failed on db-1
The diagram is the whole story. With try-with-resources the exception from the body stays primary because that is usually the root cause, and Java attaches the closing failure to it with addSuppressed, readable through getSuppressed(). When you have two resources, they are closed in reverse order and every close failure is attached, as the third block of the transcript shows (01-suppressed.txt):
=== two resources: closed in reverse order, each failure is suppressed ===
caught: java.lang.IllegalStateException: query failed on B
suppressed: java.lang.IllegalArgumentException: close failed on B
suppressed: java.lang.IllegalArgumentException: close failed on A
printStackTrace() prints suppressed exceptions too, in a Suppressed: section indented under the primary. (The demo trims each trace to its first frame before printing so the transcript stays readable; that is the only edit.)
=== what printStackTrace() shows for suppressed exceptions ===
java.lang.IllegalStateException: query failed on db-1
at com.ankurm.exceptions.SuppressedDemo$Conn.query(SuppressedDemo.java:9)
Suppressed: java.lang.IllegalArgumentException: close failed on db-1
at com.ankurm.exceptions.SuppressedDemo$Conn.close(SuppressedDemo.java:10)
A second trap in the same family. A return inside finally silently discards an exception that is in flight. javac warns about it only when you ask for -Xlint:finally, and the caller sees a normal value. FinallyTrapDemo produces this (05-finally-trap.txt):
swallowed() returned -1 - the IllegalStateException never reached the caller
Reference: what try-with-resources guarantees, exactly
The language specification (JLS 14.20.3) defines the statement by translation. The guarantees worth remembering are three; the first two have tests in ExceptionBehaviourTest:
Resources are closed in the reverse order of declaration, before any catch or finally clause of the same statement runs.
A resource expression that evaluates to null is skipped, not dereferenced: no NullPointerException from close().
If the body completes normally and close() throws, that exception becomes the primary one; suppression only applies when something was already in flight. (From the JLS; not separately tested here.)
Throwable.addSuppressed and getSuppressed have existed since Java 7, the same release that introduced try-with-resources. A Throwable can opt out of suppression through its protected four-argument constructor, which matters in the cost section below.
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.105 s -- in com.ankurm.exceptions.ExceptionBehaviourTest
Give your own exceptions a shape callers can catch selectively
Once you write more than a couple of exception types, a small hierarchy pays for itself. One unchecked base class carries a stable error code (something a log search or an API response can use), and subclasses let callers catch narrowly when they care and broadly when they do not. Order matters in the catch chain: the compiler rejects a broad catch placed before a narrow one, so put the most specific first. The code is in CustomHierarchyDemo.java:
/** Base of the application's exceptions. Unchecked: callers that cannot recover need not declare it. */
public static class AppException extends RuntimeException {
private final String code;
public AppException(String code, String message) { super(message); this.code = code; }
public AppException(String code, String message, Throwable cause) { super(message, cause); this.code = code; }
public String code() { return code; }
}
public static class NotFoundException extends AppException {
public NotFoundException(String what, long id) { super("NOT_FOUND", what + " " + id + " does not exist"); }
}
public static class ConflictException extends AppException {
public ConflictException(String message, Throwable cause) { super("CONFLICT", message, cause); }
}
/** No stack trace, no suppression: for signalling inside a hot path where the trace would never be read. */
public static class ControlFlowException extends RuntimeException {
public ControlFlowException(String message) { super(message, null, false, false); }
}
static void handle(Runnable r) {
try { r.run(); }
catch (NotFoundException e) { System.out.println("404-ish -> " + e.code() + ": " + e.getMessage()); }
catch (AppException e) { System.out.println("generic -> " + e.code() + ": " + e.getMessage()); }
}
404-ish -> NOT_FOUND: order 42 does not exist
generic -> CONFLICT: version mismatch
The fourth class, ControlFlowException, uses the four-argument Throwable constructor with its last two flags set to false. That switches off stack-trace capture and suppression for that one type, and the last three lines of the transcript confirm it (04-custom-hierarchy.txt):
Constructor documentation: Throwable(String, Throwable, boolean, boolean) in the Throwable Javadoc
What an exception costs: capturing the stack trace is most of it, and unwinding is the rest
Creating a normal exception calls fillInStackTrace(), which walks the thread’s whole call stack and records every frame. That walk is the expensive part of new RuntimeException(). The benchmark measures four ways of failing across a call stack of 1, 10 and 50 frames: an ordinary exception; one with stack-trace capture disabled; one shared instance thrown every time; and, as a baseline, a plain return code with no exception at all. The four methods are in ExceptionCostBenchmark.java:
// --- variant 1: ordinary exception, stack trace captured -------------------------------
static int throwNormal(int d) {
if (d == 0) throw new RuntimeException("boom");
return throwNormal(d - 1) + 1;
}
// --- variant 2: same, writableStackTrace=false ----------------------------------------
static int throwNoTrace(int d) {
if (d == 0) throw new NoTrace("boom");
return throwNoTrace(d - 1) + 1;
}
// --- variant 3: one shared instance thrown every time ---------------------------------
static int throwShared(int d) {
if (d == 0) throw PREALLOCATED;
return throwShared(d - 1) + 1;
}
// --- variant 4: no exception, a return code --------------------------------------------
static int returnCode(int d) {
if (d == 0) return -1;
int r = returnCode(d - 1);
return r < 0 ? r : r + 1;
}
Two things stand out in the chart. First, capturing the trace is the big step: at depth 1 a normal exception takes about 989 ns while the trace-less one takes about 10 ns, and a return code about 1 ns. Second, unwinding is not free even without a trace: by depth 10 the trace-less and shared exceptions cost about 345 ns, against 11 ns for a return code, and the gap widens with depth. The raw scores are in 11-jmh-raw.txt:
Do not over-read the depth-1 row. At depth 1 the JIT can probably inline the whole call and turn throw plus catch into a jump (I did not inspect the compiled code, but about 3 ns is far below the cost of any real unwinding). Real code rarely gets that. The depth-10 and depth-50 rows are closer to what a service with a few layers sees, and there the trace costs roughly one to two and a half microseconds extra per exception on this machine. Sharing one instance did not beat the trace-less type, because both skip the capture and the remaining cost is unwinding.
Reference: how this was measured and how to reproduce it
One fork, three warm-up and five measurement iterations of one second each, average-time mode, on a 2-vCPU virtual machine. The annotation-processor setting in the module’s pom.xml is required on JDK 25, which no longer discovers processors implicitly; without it the JMH jar builds but contains no benchmarks. Regenerate everything with scripts/run-all.sh (needs JDK25_HOME). Expect the scores to move by tens of percent between runs while the ordering stays put; the Error column in the output above is JMH’s own 99.9% confidence half-interval.
Going deeper
The stack trace that vanishes: HotSpot’s fast-throw optimisation
Here is a production mystery that has a measurable cause. A NullPointerException (or ArithmeticException, or ArrayIndexOutOfBoundsException) appears in the logs with a full trace for the first few hours after deployment; later the same exception shows up with no stack trace at all, just the message. Nothing changed in your code. What changed is that the method got hot, and HotSpot’s compiler, when it sees the same implicit exception thrown from the same place over and over, replaces it with a preallocated instance that carries no trace. The flag is OmitStackTraceInFastThrow and it is on by default. The demo divides by zero in a loop until the trace disappears (OmitStackTraceDemo.java):
int firstEmpty = -1;
for (int i = 1; i <= 300_000; i++) {
try {
divide(i, 0);
} catch (ArithmeticException e) {
if (e.getStackTrace().length == 0) { firstEmpty = i; break; }
}
}
if (firstEmpty < 0) {
System.out.println("every ArithmeticException still carried a stack trace in 300000 throws");
} else {
System.out.println("the ArithmeticException at iteration " + firstEmpty + " had an EMPTY stack trace");
}
$ java OmitStackTraceDemo
the ArithmeticException at iteration 1127 had an EMPTY stack trace
$ java -XX:-OmitStackTraceInFastThrow OmitStackTraceDemo
every ArithmeticException still carried a stack trace in 300000 throws
The iteration where the trace disappears varies from run to run (it depended on JIT timing; it was around 1,100 in every run here), but with the flag off it never disappears. Only exceptions the JVM itself throws are affected; one you create with new always has its trace.
How to recognise it. An exception whose message you recognise, getStackTrace().length == 0, and a service that has been up for a while. Look earlier in the same log file: the same exception will have a full trace from when the code was still interpreted. To keep traces on in an environment where you need them, run with -XX:-OmitStackTraceInFastThrow; the price is the cost of capturing them on hot paths.
Reference: related JVM flags and the 1,024-frame cap
Three flags on this JDK, with their real defaults from -XX:+PrintFlagsFinal (14-jvm-flags.txt):
MaxJavaStackTraceDepth is why a runaway recursion never prints a million lines. DeepTraceDemo recurses until StackOverflowError and then counts the frames in the trace (12-deep-trace.txt):
frames the program actually descended: more than 1024 (true)
getStackTrace().length: 1024
Should you disable stack traces for speed? Almost never
Honest advice.writableStackTrace=false saves roughly one to two microseconds per exception on this machine, and it costs you every debugging clue that exception would have carried. That trade is only sensible for an exception used as internal control flow on a path you have measured as hot, that never reaches a log, and that is always caught within the same component. If an exception is thrown often enough for the trace cost to matter, first ask whether it should be an exception at all: a return value, an Optional, or a result type is cheaper still (the return-code rows above) and makes the failure path visible in the signature. For everything that represents a real fault, keep the trace, chain the cause, and let try-with-resources keep the rest.
The short checklist that falls out of the measurements: always pass the cause when wrapping; prefer try-with-resources to hand-written finally for anything closeable; never return from finally; keep stack traces on, and know that a missing trace in production usually means the JIT removed it, not that your code did.
No Comments yet!