You write try/catch every day. But when a throw runs, what does the JVM actually do? There is no magic: there is a table lookup, a stack unwinding procedure written down in the JVM specification, and - in one genuinely surprising case - a hardware trap and a kernel signal. Let's take it apart with real bytecode and real runs on JDK 21.
What an exception is, per the spec
The JVM spec (JVMS 2.11.9) says: "An exception is thrown programmatically using the athrow instruction. Exceptions can also be thrown by various Java Virtual Machine instructions if they detect an abnormal condition."
So two sources: your explicit throw (compiled to one bytecode instruction, athrow), and the JVM itself (a bad cast, an array index out of range, a null dereference). Either way, the exception is an ordinary object - allocated on the heap like any other - whose class extends Throwable. What makes it special is what the constructor records. From the Throwable JavaDoc: "Fills in the execution stack trace. This method records within this Throwable object information about the current state of the stack frames for the current thread."
That recording happens at construction time, before anything is thrown. The stack trace you print later is a snapshot taken the moment the object was born.
Here is the whole throw-and-catch journey, animated:
The catch block is a table entry
Here is the part most people never see. A simple try/catch:
public class ExTable { static int parse(String s) { try { return Integer.parseInt(s); } catch (NumberFormatException e) { return -1; } } public static void main(String[] args) { System.out.println(parse("42")); System.out.println(parse("oops")); } } Running it prints 42 and -1. Now javap -c on the compiled class - this is the real output:
static int parse(java.lang.String); Code: 0: aload_0 1: invokestatic #7 // Method java/lang/Integer.parseInt:(Ljava/lang/String;)I 4: ireturn 5: astore_1 6: iconst_m1 7: ireturn Exception table: from to target type 0 4 5 Class java/lang/NumberFormatException Notice what is not there: any instruction for try. The try block compiles to nothing. All that exists is:
- the normal code path (offsets 0-4: call parseInt, return its result),
- the handler code (offsets 5-7: store the exception, push -1, return), and
- one exception table row: "if an exception of type NumberFormatException (or a subclass) is raised by any instruction at offsets 0 up to 4, jump to offset 5."
That is exactly the rule the spec states (JVMS 2.10): "An exception matches an exception handler if the offset of the instruction that caused the exception is in the range of offsets of the exception handler and the exception type is the same class as or a subclass of the class of exception that the exception handler handles." A catch clause is data, not code that runs on the happy path.
Unwinding: what happens when nobody catches
What if the current method has no matching handler? The spec describes the cascade (JVMS 2.10): "If no such exception handler is found in the current method, the current method invocation completes abruptly. On abrupt completion, the operand stack and local variables of the current method invocation are discarded, and its frame is popped, reinstating the frame of the invoking method. The exception is then rethrown in the context of the invoker's frame and so on, continuing up the method invocation chain."
One honest caveat, as always with the JVMS: this is the conceptual model. The spec's own Chapter 2 says it "specifies an abstract machine", and frames/operand stacks are its vocabulary. A JIT-compiled HotSpot method may have inlined these calls and keep values in registers, so no physical frame literally pops. The observable behavior - which handlers run, in what order, what state survives - is what the spec guarantees.
Watch the observable behavior for real:
public class Unwind { static void level3() { throw new IllegalStateException("boom from level3"); } static void level2() { level3(); System.out.println("level2 after level3 - never prints"); } static void level1() { level2(); System.out.println("level1 after level2 - never prints"); } public static void main(String[] args) { try { level1(); } catch (IllegalStateException e) { System.out.println("caught in main: " + e.getMessage()); StackTraceElement[] st = e.getStackTrace(); System.out.println("recorded stack depth: " + st.length); for (int i = 0; i < st.length; i++) System.out.println(" frame " + i + ": " + st[i].getMethodName()); } System.out.println("main continues after catch"); } } Real output:
caught in main: boom from level3 recorded stack depth: 4 frame 0: level3 frame 1: level2 frame 2: level1 frame 3: main main continues after catch Three things to read there. The two "never prints" lines indeed never printed - each frame was abandoned the moment the exception passed through, mid-method. The recorded stack depth is 4 even though the catch is in main: the snapshot was taken at construction inside level3, when all four frames were live. And after the catch, main simply continues - unwinding is a transfer of control, not a crash.
What a throw costs
The unwinding itself is cheap. The expensive part is that snapshot: fillInStackTrace has to walk the current thread's frames and record them. One small benchmark, one machine, one run - real numbers from this JDK 21 (Temurin 21.0.13) sandbox, nothing more:
plain return x1M: 8,655,307 ns throw+catch x1M: 473,695,890 ns ratio: 54.7x slower (this machine, this run) Fifty times slower here - your number will differ with stack depth, JIT state and hardware, so treat the exact figure as a single sample. The direction is the point: creating and throwing an exception costs orders of magnitude more than returning a value. That is why the classic advice exists: exceptions are for exceptional situations, not for ordinary flow control.
Where the kernel genuinely appears
An ordinary throw is pure user-space JVM work: allocate an object, record frames, consult the exception table, branch. No system call, no kernel involvement at all.
But remember the spec's second source: "exceptions... thrown by various Java Virtual Machine instructions if they detect an abnormal condition." Some of those detections go through real hardware. When JIT-compiled code dereferences a reference that is null, many JVMs on Linux deliberately let the bad memory access happen: the CPU traps, and the kernel delivers a signal to the faulting thread. From signal(7): "A signal may be thread-directed because it was generated as a consequence of executing a specific machine-language instruction that triggered a hardware exception (e.g., SIGSEGV for an invalid memory access...)." HotSpot installs a handler for that signal, recognizes that the fault came from code where it omitted an explicit null check on purpose, and converts it into the NullPointerException your catch block sees. The kernel was genuinely in the loop - hardware trap, signal delivery, handler - between your bad dereference and your NPE.
Two cautions, both worth more than the trick. That mechanism is a HotSpot-on-Linux implementation detail, not a language guarantee - a JVM may instead emit explicit null checks and never touch a signal, and the JLS promises only that you get an NPE, never how. And most exceptions you will ever write (throw new ...) travel nowhere near the kernel. The honest picture is: user space by default, hardware and signals as an optimization path a particular JVM may choose for a particular check.
Habits worth keeping
- Catch the specific type. The table matches by class-or-subclass; catching
Exceptioneverywhere hides bugs you meant to see. - Return values for expected outcomes, exceptions for exceptional ones. The 54x sample above is why.
- Don't swallow. An empty catch discards the snapshot the constructor paid to record.
- Read stack traces top-down. Frame 0 is where the object was constructed, which is where the problem started - exactly what the Unwind output shows.
Sources
- JVMS 2.10: Exceptions
- JVMS 2.11.9: Throwing Exceptions
- Java 21 API: java.lang.Throwable
- Linux man-pages: signal(7)
Written with AI assistance; every bytecode listing, stack trace and timing above comes from real compiles and runs on JDK 21 (Temurin 21.0.13), and the timing ratio is one machine's single run, not a general bound. The animation is an illustration of the spec's conceptual model - JIT-compiled execution may inline calls and keep values in registers rather than literal frames.


