How the JVM Really Handles Exceptions: A Bytecode Tour

When a Java statemant throws, the magic does not happen in the source you wrote but in the Exception table that the compiler quietly appends to the method’s bytecode. Let’s open the hood and watch the gears turn.

1 A minimal throw

public static void demo() {
    int x = 1 / 0;
}

Compiling and disassembling:

$ javac Demo.java
$ javap -v Demo

Among the output we see only four instructions:

0: iconst_1
1: iconst_0
2: idiv
3: istore_1
4: return

No exception table entry exists, so the ArithmeticException bubbles up uncaught and terminates the thread.

2 Adding a catch

public static void demo() {
    try {
        int x = 1 / 0;
    } catch (ArithmeticException ex) {
        ex.printStackTrace();
    }
}

The bytecode is now longer and contains:

Exception table:
   from    to  target type
       0     4      7   Class java/lang/ArithmeticException
  • from and to delimit the protected range [0,4) (note the exclusive upper bound).
  • target is the index of the first instruction inside the catch block.
  • type is the class the handler accepts.

If the exception matches, control jumps to offset 7; otherwise the JVM re-throws to the caller.

2.1 Why the upper bound is exclusive

The JVM spec admits this is a historical mistake: with a 16-bit code length limit of 65535, an instruction at the very end could not be covered. Compilers therefore cap method size at 65534 bytes.

3 Finally demystified

try {
    int x = 1 / 0;
} catch (ArithmeticException ex) {
    ex.printStackTrace();
} finally {
    System.out.println("cleanup");
}

The exception table grows to three rows:

   from    to  target type
       0     4     15   Class java/lang/ArithmeticException
       0     4     31   any
      15    20     31   any

The two any entries cover every path—normal or exceptional—into the finally block. The compiler duplicates the finally code at every exit point, then wires the table so the block always runs.

3.1 Returning inside finally

try {
    int x = 1 / 0;
} finally {
    return;   // BAD IDEA
}

The bytecode ends with return instead of athrow, swallowing the exception entirely.

4 Where uncaught messages come from

If no handler matches, the JVM eventually lands in

java.lang.Thread.dispatchUncaughtException(Throwable e)

wich delegates to the thread’s ThreadGroup or a custom UncaughtExceptionHandler. The default implementation prints the familiar stack trace to System.err.

5 When an outer catch is blind

try {
    ExecutorService pool = Executors.newFixedThreadPool(1);
    pool.submit(() -> { int z = 1 / 0; });
} catch (Exception e) {
    e.printStackTrace();   // never reached
}

The task runs on a worker thread, so the exception is handled by that thread’s uncaught-handler, not the submitter’s catch. Switching to execute instead of submit surfaces the exception because execute propagates it immediately.

In short, the JVM’s Exception table is the invisible switchboard that decides where control goes when things go wrong. Source code never tells the full story; the bytecode does.

Tags: jvm-bytecode exception-handling java-compiler finally-semantics uncaught-exception-handler

Posted on Wed, 13 May 2026 16:06:53 +0000 by weiwei