skip to content
everything.coreApi.tryCatchFinally

try, catch, finally

how an exception unwinds the stack, frame by frame.

chapter
chapter 04.05
reading time
8 min read

when something throws, the JVM unwinds the stack: it abandons the current method, pops frames one by one, runs any finally blocks it passes, and stops at the first catch whose type matches. if nothing matches, the thread dies and prints the stack trace.

finally runs on every way out of a try: normal end, return, break, or an exception. (almost every way. there's a short list.)

the picture

an exception is a hot potato handed back down the line of people who called you. each person either catches it, or steps out of the line and passes it on. anyone holding a finally gets to tidy their desk on the way out, whether they caught the potato or not.

no frame "returns" during this. nobody gets a return value, no line after the call runs. frames are just thrown away, newest first, until someone has a matching catch.

watch it unwind

main calls a, which calls b, which calls c. c throws. the frames come from a real run under the site's JDI tracer. the exception object is drawn in by hand, because no variable holds it while it's in flight, and you should see it travel.

Main.java next to run
  1. 1static int c(int n) {
  2. 2 if (n > 2)
  3. 3 throw new IllegalStateException("big");
  4. 4 return n * 10;
  5. 5}
  6. 6
  7. 7static int b(int n) {
  8. 8 int r = c(n + 1);
  9. 9 IO.println("b got " + r);
  10. 10 return r;
  11. 11}
  12. 12
  13. 13static int a(int n) {
  14. 14 try {
  15. 15 return b(n + 1);
  16. 16 } finally {
  17. 17 IO.println("a's finally");
  18. 18 }
  19. 19}
  20. 20
  21. 21void main() {
  22. 22 try {
  23. 23 IO.println(a(1));
  24. 24 } catch (IllegalStateException e) {
  25. 25 IO.println("caught: " + e.getMessage());
  26. 26 }
  27. 27 IO.println("done");
  28. 28}
stdout
·
stack
main
no locals yet
heap
nothing allocated yet.

main enters its try and calls a(1).

step 1/13
step through it. the exception is an ordinary object: it's created in c, rides down the stack while c and b are popped (line 9 never runs), waits for a's finally, and stops at main's catch.

notice what didn't happen: b never printed, a never returned, main never printed a number. the only code that ran between the throw and the catch was a finally.

catch: the first match wins

a try can have several catch clauses. the JVM checks them top to bottom and runs the first one where the exception instanceof the declared type. the rest are skipped.

Main.java
import java.io.IOException;
import java.nio.file.*;
 
void main() {
    for (String name : new String[] {"missing.txt", "", "x\0y"}) {
        try {
            Files.readString(Path.of(name));
        } catch (NoSuchFileException e) {                         // the specific one first
            IO.println("no such file: " + e.getMessage());
        } catch (IOException e) {                                 // then its parent
            IO.println("other I/O: " + e.getClass().getSimpleName());
        } catch (InvalidPathException | UnsupportedOperationException e) {   // multi-catch
            IO.println("bad path: " + e.getClass().getSimpleName());
        }
    }
}
output, from jdk 25
no such file: missing.txt
other I/O: IOException
bad path: InvalidPathException

this one needs a real JVM (files, the network or a database), so it doesn't run in the browser. copy it into java Main.java to try it.

("" is the current directory, which you can't read as a file. "x\0y" has a NUL byte, which no path can contain.)

two compile-time rules come out of "first match wins":

  • specific before general. put catch (IOException e) above catch (NoSuchFileException e) and javac refuses, because the second clause could never run.
  • multi-catch alternatives can't be related. FileNotFoundException | IOException is an error too: the parent already covers the child.
text
error: exception NoSuchFileException has already been caught
error: Alternatives in a multi-catch statement cannot be related by subclassing

in a multi-catch, e is implicitly final and its static type is the closest common supertype (here RuntimeException).

finally: on every way out

risky() =
Job.javarunning
  1. 1try {
  2. 2 open();
  3. 3 risky();
  4. 4 save();
  5. 5} catch (IOException e) {
  6. 6 recover(e);
  7. 7} finally {
  8. 8 close();
  9. 9}
  10. 10next();
  1. 1.line 2
  2. 2.line 3
  3. 3.line 6
  4. 4.line 8
  5. 5.line 10
…

open() runs.

step 1/5
pick how risky() ends and step through. the current line moves; lines that never ran fade at the end. close() on line 8 runs in all four.

that's why finally is where cleanup goes: releasing a lock, closing a file, restoring some state. (for anything AutoCloseable there's something better, try-with-resources.)

the list of ways finally doesn't run is short: System.exit() (or Runtime.halt()) inside the try, the JVM crashing or being killed (kill -9, the container's OOM killer, power), the try never finishing (an infinite loop, a deadlock), and daemon threads, which are simply abandoned when the JVM exits.

predict the output
java
static int f() {
    try {
        return 1;
    } finally {
        return 2;
    }
}
predict the output
java
static int g() {
    int x = 1;
    try {
        return x;
    } finally {
        x = 2;
    }
}

don't swallow

don'tthe silent catch
java
try {
    config = load(path);
} catch (Exception e) {
    // TODO
}

the program keeps going with config == null, and the NPE shows up an hour later, in some other class, with no hint of the real cause.

dohandle it, or let it go
java
try {
    config = load(path);
} catch (NoSuchFileException e) {
    config = Config.defaults();   // a real decision
}
// anything else: don't catch it here.

catch what you can actually do something about. everything else should keep unwinding to a level that can (and usually that's the top, which logs it).

one level deeper:how the JVM finds the catch: the exception table+

there's no "try" instruction in bytecode. instead each method has an exception table: rows of "if something is thrown between instruction from and to, and it's an instance of type, jump to target". here's javap -c (javac 25) for this method:

java
static void h() {
    try {
        work();
    } catch (IllegalStateException e) {
        log(e);
    } finally {
        cleanup();
    }
}

and what's in the .class file:

text
 0: invokestatic  #7    // Method work:()V
 3: invokestatic  #12   // Method cleanup:()V        ← finally, copy 1 (normal path)
 6: goto          26
 9: astore_0                                         ← the catch block
10: aload_0
11: invokestatic  #17   // Method log:(Ljava/lang/Exception;)V
14: invokestatic  #12   // Method cleanup:()V        ← finally, copy 2 (after the catch)
17: goto          26
20: astore_1                                         ← "any": everything else
21: invokestatic  #12   // Method cleanup:()V        ← finally, copy 3
24: aload_1
25: athrow                                           ← rethrow it
26: return
Exception table:
   from    to  target type
       0     3     9   Class java/lang/IllegalStateException
       0     3    20   any
       9    14    20   any

two things to see. finally isn't a jump target, it's copied onto every exit path (three times here). and when a method throws, the JVM searches that method's table for a matching row. no match: pop the frame, and search the caller's table at the call site. that's the unwinding from the stepper above, one table lookup per frame. it costs nothing when nothing is thrown.

keylines
  • a throw abandons the method. frames are popped until a catch matches.
  • catches are checked top to bottom. specific before general, or javac complains.
  • finally runs on every way out: normal, return, break, exception.
  • never return or throw from a finally. it replaces whatever was in flight.
  • catch what you can handle. let the rest unwind.