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:
- Evaluate the expression (
x) and store the result in a hidden local variable - Run the
finallyblock - 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_1copies the value ofxinto a separate slot.finallywrites to slot 0 (x), andireturnreads from slot 1. They never meet. - The
finallycode (bipush 99; istore_0) appears twice. There's no "finally instruction" in the JVM.javacsimply copies the finally block in front of every exit point: once for the normalreturn, once for the exception path (theanyhandler 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 beforefinallyruns, so reassigning a primitive infinallydoesn'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 areturninsidefinallyoverrides 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.