The Runtime Theory
RuntimeInternalsexecution

What happens when you throw an exception?

A step-by-step walk of throw and catch: the throw statement, stack unwinding through landing pads, the catch handler, and the real cost of zero-cost exceptions.

The Runtime Theory Team1 min read05 steps

layer stack

Runtime

HWHardware
KKernel
RTRuntime
APPApplication
SYSSystem
CLIClient
NETNetwork
TLSCrypto
SRVServer

adjacent altitudes in this subsystem are still being traced

trace spine

  1. 01 throw begins the unwind
  2. 02 The runtime finds the handler
  3. 03 Frames unwind through landing pads
  4. 04 The catch handler runs
  5. 05 The cost model is asymmetric

throw new TimeoutException() looks like a jump, but it's a search: the runtime must find the nearest matching catch by walking frames that were never designed for this. The mechanism — zero-cost exception handling — is a masterpiece of deferred cost: free on the happy path, expensive exactly when used.

trace stepApplication

The throw evaluates the exception object (one heap allocation — the malloc-trace fast path) and transfers control to the runtime's throw machinery. Critically, the current function does not return normally: no epilogue runs, no locals are destructed by normal flow — the unwind must clean up (C++ destructors, Java finally blocks, JS deferred blocks) as it goes. The compiler has pre-built a map of every cleanup point; that map is the machinery.

trace stepRuntime

The runtime — actually the personality routine in the exception tables (.gcc_except_table in ELF, __cxa_throw in Itanium ABI, JS's built-in walk) — uses the current instruction pointer to look up the frame's action table: which catch types this frame can handle and which cleanup blocks must run. This is a binary search over a sorted table of IP ranges, ~100 ns, entirely data-driven. There is no per-frame "try" flag in registers or on the stack — that's the zero-cost trick: the happy path pays nothing.

trace stepHardware

If the current frame can't handle the exception, the runtime unwinds: restore the frame's registers, jump to its cleanup/landing pad if one exists (which runs destructors, then rethrows implicitly), then pop to the caller and repeat the table lookup at the return address. Each frame is a lookup plus a register restore — ~1–2 µs per frame in the common case, more when cleanup code actually runs. Deep stacks make throws expensive: 50 frames × 1.5 µs = 75 µs just to find the handler.

trace stepApplication

The search lands on a frame whose action table lists a matching catch type; the runtime materializes the handler's frame state (registers, stack pointer) from the table, binds the exception object, and jumps into the catch block. From here it's ordinary code — the exception is just a parameter. The try/catch boundary has cost nothing before, and costs a bounded constant now; the catch itself runs at normal speed.

trace stepRuntime

The cost model is brutally asymmetric. A function with a try block costs zero extra cycles when nothing throws — no setjmp bookkeeping, no checks. A throw costs ~µs per frame unwound plus handler materialization — 1,000–10,000× a normal return. That asymmetry is why "exceptions are slow" is wrong and right at once: they're free until used, and catastrophic when used in a loop. The Java -XX:+PrintCompilation folklore — the JIT recompiling try methods because the throw path defeats C2's inlining — is the visible symptom.

bash
# Java microbenchmark: throw/catch in a loop vs a return-code in a loop
# Expect 2-3 orders of magnitude: ~20ns for the flag path, ~5-20us per throw

What the machine actually does is consult a compiler-built table of handlers, walk frames by restoring registers and running cleanups until one table entry matches, then materialize the handler and continue as if nothing happened. The happy path truly costs nothing — and the throw path costs exactly what it deserves: the search you asked for.