Does finally Run Before return? What javac Actually Does

Here's a small Java snippet. Before reading on, decide what it returns:

static int a() {
    int x = 1;
    try {
        return x;
    } finally {
        x = 99;
    }
}

If you said 99, you're in good company. I used to think the same thing: "finally runs before return, so x becomes 99 first, then gets returned."

The actual answer is 1.

finally does run before the method exits. But that's not the same as running before return. Let's see why.

return is two steps, not one

When the JVM executes return x; inside a try that has a finally, it does this:

  1. Evaluate the expression (x) and store the result in a hidden local variable
  2. Run the finally block
  3. Return the stored value

So conceptually, the compiler turns our method into this:

static int a() {
    int x = 1;
    int tmp;
    tmp = x;      // 1. value captured: tmp = 1
    x = 99;       // 2. finally runs: only x changes
    return tmp;   // 3. returns 1
}

By the time finally touches x, the return value has already been copied somewhere else. It's no different from:

int x = 1;
int tmp = x;
x = 99;
// tmp is still 1

Don't trust me, trust the bytecode

Compile the class and run javap -c. The relevant part looks like this (slightly trimmed):

 0: iconst_1
 1: istore_0      // x = 1
 2: iload_0
 3: istore_1      // tmp = x   <-- the hidden variable (slot 1)
 4: bipush 99
 6: istore_0      // x = 99    <-- finally
 7: iload_1
 8: ireturn       // return tmp
 9: astore_2      // exception path starts here
10: bipush 99
12: istore_0      // x = 99    <-- finally, again
13: aload_2
14: athrow
Exception table:
   from  to  target  type
     2    4     9    any

Two things stand out:

  • istore_1 copies the value of x into a separate slot. finally writes to slot 0 (x), and ireturn reads from slot 1. They never meet.
  • The finally code (bipush 99; istore_0) appears twice. There's no "finally instruction" in the JVM. javac simply copies the finally block in front of every exit point: once for the normal return, once for the exception path (the any handler in the exception table).

With catch, it's the same rule

static int demo(boolean fail) {
    int x = 1;
    try {
        if (fail) throw new RuntimeException();
        return x;
    } catch (RuntimeException e) {
        x = 2;
        return x;
    } finally {
        x = 99;
    }
}

Roughly desugared:

static int demo(boolean fail) {
    int x = 1;
    int tmp;
    try {
        if (fail) throw new RuntimeException();
        tmp = x;
        x = 99;          // finally copy #1
        return tmp;      // 1
    } catch (RuntimeException e) {
        x = 2;
        tmp = x;
        x = 99;          // finally copy #2
        return tmp;      // 2
    } catch (Throwable t) {
        x = 99;          // finally copy #3
        throw t;
    }
}

demo(false) returns 1, demo(true) returns 2. finally runs on every path and changes the result on none of them.

Objects behave differently (sort of)

The hidden variable stores whatever return evaluates to. For objects, that's a reference, not a copy of the object:

static StringBuilder b() {
    StringBuilder sb = new StringBuilder("hi");
    try {
        return sb;
    } finally {
        sb.append("!");
    }
}
// returns "hi!"

tmp and sb point to the same object, so mutating it in finally is visible to the caller.

But reassigning the variable is not:

finally {
    sb = new StringBuilder("new");
}
// still returns "hi" (tmp points to the old object)

This is exactly the same rule as the int case. Only the variable changes, not what was already captured.

The one that actually bites: return inside finally

static String c() {
    try {
        throw new IllegalStateException("important!");
    } finally {
        return "finally";
    }
}

This returns "finally", and the exception disappears. No stack trace, no log, nothing. A return in finally overrides both the try's return value and any exception in flight.

In production code, this means silently swallowed errors. Most static analysis tools flag it (Sonar rule java:S1143), and you should treat it as a bug.

Where this matters in real code

1. Cleanup that must always happen

lock.lock();
try {
    updateBalance();   // might throw
} finally {
    lock.unlock();
}

If updateBalance() throws and unlock() is not in finally, the lock is never released, and every other thread waiting on it blocks forever. finally fixes this because javac copies it onto every exit path, as we saw in the bytecode.

2. try-with-resources: Java 7's fix for resource leaks

Before Java 7, closing a resource correctly meant writing the finally yourself:

Connection c = ds.getConnection();
try {
    return query(c);
} finally {
    c.close();
}

It was easy to forget, and with several resources, it quickly turned into nested try/finally blocks. Forgotten close() calls meant leaked connections and file handles.

Java 7 introduced try-with-resources (and the AutoCloseable interface), so the compiler writes this for you:

try (Connection c = ds.getConnection()) {
    return query(c);
}

Under the hood, it's the same finally we've been looking at, with the same order of operations:

String tmp = query(c);   // 1. result captured while the connection is still open
c.close();               // 2. connection closed
return tmp;              // 3. result returned

That's why returning a query result from inside try-with-resources is safe.

As a bonus, if both query(c) and close() throw, try-with-resources keeps the original exception and attaches the close() failure to it as a suppressed exception. A hand-written finally would let the close() exception replace the original one.

3. Beyond a single method: Spring's @PreDestroy

finally only covers the lifetime of one method call. Some resources persist for the lifetime of the application, such as a Kafka producer or a connection pool. Those need to be cleaned up when the application shuts down, not when a method returns.

Spring handles this with @PreDestroy. When the application context closes, Spring destroys its beans and calls their @PreDestroy methods:

import jakarta.annotation.PreDestroy;

@Component
public class EventPublisher {

    private final KafkaProducer producer = ...;

    @PreDestroy
    public void cleanup() {
        producer.flush();   // send anything still buffered
        producer.close();
    }
}

Same idea as finally, "always clean up", just at a different scope: per method vs. per application.

Note that neither of these runs if the JVM is killed outright (for example, System.exit() skips finally, and kill -9 skips everything). "Finally always runs" holds as long as the method actually exits.

The 30-second interview answer

If you get this question in an interview, here's a compact answer:

"The return value is evaluated and stored before finally runs, so reassigning a primitive in finally doesn't affect it. Mutating an object does, because only the reference was captured. Under the hood, javac inlines the finally block before every exit point. And a return inside finally overrides everything, including exceptions, which is why it's considered a bug."

Summary

In finally you... Effect on the returned value
reassign a primitive (x = 99) none, value already captured
mutate an object (sb.append("!")) visible, same object
reassign a reference (sb = new ...) none, old reference captured
return something overrides everything, swallows exceptions

return captures its value before finally, and the method exits after finally.

For the formal version, see JLS §14.20.2: Execution of try-finally and try-catch-finally.

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论