Language Runtimes · Virtual Machines
Exception Tables and Frame Unwinding in Bytecode
How a thrown exception finds its handler using a table of protected ranges, and why the table is separate from the code rather than encoded as instructions.
An exception transfers control from the point of failure to a handler that may be several frames up the call chain, without the intervening code returning. In native code the mechanism is a stack walk driven by tables generated by the compiler. In bytecode the same information has to be shipped alongside the code, because the loader may never see the source and the runtime cannot afford to derive it. The JVM’s solution is an exception table: a list of ranges, each naming a start offset, an end offset, a handler offset, and the type of exception caught.
A table, not instructions
The obvious alternative is to encode handlers as instructions. A try region would begin with a marker instruction and end with a jump over the handler, and the handler body would follow. That is how a source-level try block looks, and it is how some bytecode formats represent it before lowering.
The JVM deliberately does not. The exception table is a separate structure in the code attribute, and the bytecode contains no marker for the protected region’s boundaries. The reason is that the region’s boundaries are not instruction boundaries in any useful sense: an exception can be raised in the middle of an instruction that calls into the runtime, and the handler must apply to the whole instruction. A table expressed as half-open offset ranges covers exactly that, without requiring the compiler to insert markers that the interpreter would have to check.
The table’s entries are not required to be sorted, because the format gives table order a meaning that sorting would destroy. When more than one entry covers the throwing offset, the first matching entry in table order wins, which is how a compiler expresses a catch chain: the specific type is placed earlier in the table than the general one. A runtime may build a sorted index over the same data for fast lookup, but the decision about which handler applies must be made in table order.
The CLI format uses the same shape with different field names: each method carries a list of exception-handling clauses, each with a try offset and length, a handler offset and length, and either a metadata token naming the caught type or a flag selecting a filter or a finally handler. The variant worth noting is that the CLI gives the protected ranges a length rather than an end offset, which removes the question of whether the end may fall inside an instruction, at the cost of an addition at lookup time.
typedef struct {
uint16_t start_pc; /* inclusive */
uint16_t end_pc; /* exclusive */
uint16_t handler_pc;
uint16_t catch_type; /* constant pool index; 0 means "any" */
} ExceptionEntry;
/* First entry in table order that covers pc and matches the thrown class. */
static const ExceptionEntry *find_handler(const Code *c, uint16_t pc,
Class *thrown)
{
for (size_t i = 0; i < c->n_entries; i++) {
const ExceptionEntry *e = &c->entries[i];
if (pc < e->start_pc || pc >= e->end_pc) continue;
if (e->catch_type == 0) return e; /* finally / any */
Class *caught = resolve_class(c, e->catch_type);
if (is_subclass(thrown, caught)) return e;
}
return NULL;
}
The linear scan is acceptable because a method has few entries and the lookup happens only on the throw path, not on the normal path. Implementations that care about throw-heavy workloads build a side index keyed by offset, but the on-disk format stays the linear table.
The handler is a normal instruction offset
When an entry matches, the runtime does three things before jumping to handler_pc. It clears the operand stack, because the handler’s code assumes an empty stack apart from the exception. It pushes the exception object onto that now-empty stack, so the handler’s first instruction can store it to a local. And it sets the instruction pointer to the handler offset.
This is why the exception table needs no separate calling convention for handlers. A handler is ordinary bytecode at a normal offset, verified like any other code, and the verifier requires that the operand stack is exactly one reference deep at that offset. The exception object arrives the same way an argument would: as a value on the stack, waiting to be consumed.
Clearing the stack is a semantic requirement, not an optimization. The verifier’s stack map for the handler declares depth one, so if the runtime did not clear, the operand stack would contain leftover values from the interrupted expression and the handler would read the wrong slot. Because the verifier has already checked the depth at the handler, the runtime’s clearing is a fixed operation, not a search.
The catch_type field being a constant pool index rather than a class pointer is what makes the table position-independent. A class file can be loaded anywhere and the handler resolution happens against the loaded class. An entry with catch_type zero catches everything, which is how a compiler lowers a finally block: the block is compiled as a handler that runs the cleanup and rethrows.
Unwinding across frames
If no entry in the current frame matches, the frame is discarded and the search continues in the caller. Discarding means restoring the caller’s frame pointer and instruction pointer from the header, and popping the callee’s frame. That walk repeats until a frame with a matching entry is found, or the base of the stack is reached and the exception is reported as uncaught.
The walk is cheap because the frame header holds the caller pointer. Each step is one load plus the table lookup in that frame’s code. A deep recursion of a thousand frames costs a thousand table lookups, each scanning a handful of entries, which is why an uncaught exception thrown from deep recursion is noticeably slower than a normal return but still bounded.
The correctness requirement is that no frame-local state is left inconsistent. Because the stack pointer is restored from the header, the callee’s operand stack and locals vanish with the frame; nothing needs to be unwound field by field. This is the advantage of a contiguous stack over individually allocated frames: unwinding is a pointer move, not a destructor call per frame. Languages with finally blocks or destructors need more, and that need is handled by compiling cleanup code into the handler rather than by making the unwinder call back into the frame.
A finally block that must run on both normal and exceptional exit is compiled twice, or once with a shared subroutine. The JVM historically provided jsr and ret for this, which pushed a return address and jumped to the subroutine; the pair was removed in class file version 51 in favor of duplicating the cleanup code, because subroutine calls made verification harder. Duplicating the code costs bytes but keeps every instruction’s stack effect statically known.
Costs on the normal path
The exception table’s main virtue is that it costs nothing when no exception is thrown. There is no flag to set on entry to a protected region, no cleanup of that flag on exit, and no branch to check whether an exception is pending. A native implementation that used the same approach would have the same property; implementations that instead set a per-frame handler pointer at each try boundary pay two stores per protected region on the normal path.
The trade appears when exceptions are frequent. If a handler lookup happens per throw and the throw rate is high, the linear scan and the class-subclass test dominate. Runtimes that expect frequent exceptions, such as implementations of languages where exceptions signal ordinary conditions, often add a per-throw cache or precompute a dispatch table per offset. The JVM’s format does not provide for such a cache, so it lives in the runtime’s own side tables keyed by method and offset.
There is also a verification cost. The handler’s stack map must declare a stack of depth one holding the exception type, and the verifier must check that this declared type is assignable from the entry’s catch_type, so a handler cannot claim to receive something narrower than the entry can deliver. A handler that catches Throwable and immediately reads it as a String is rejected at load time rather than failing at the throw site.
Interaction with the collector and with debuggers
An exception in flight holds a reference to the thrown object, and that reference must be visible to the collector during the walk. The runtime keeps it in a dedicated root slot for the duration, because the operand stack of the frames being discarded is about to be overwritten. A collector that runs during unwinding, which can happen if discarding a frame triggers finalization, must not treat the abandoned stack slots as roots.
Debuggers need the inverse mapping: given a code offset, which source line and which frame is it in. The exception table is not enough, because it only says where handlers are. The line number table and local variable table answer the rest, and they are the subject of debug metadata and single stepping.
One more edge case matters for correctness: the end_pc of a range is exclusive, and the specification requires that end_pc either equals the code length or be the offset of an instruction. A range that ends in the middle of an instruction is rejected, because the runtime’s lookup is by offset and an offset inside an instruction would be ambiguous. Similarly, a handler offset that lies inside another protected range is legal, and the entries for that range then apply to exceptions raised inside the handler; an entry that covers its own handler and rethrows unconditionally loops forever, and nothing in the format prevents it.
The set of type facts the verifier needs at a handler offset is part of the larger verification problem, which bytecode verification and stack map frames covers.
References
- The Java Virtual Machine Specification, Java SE 8 Edition — Oracle
- Itanium C++ ABI: Exception Handling — Itanium C++ ABI project
- ECMA-335: Common Language Infrastructure (CLI) — Ecma International