【后端开发】finally 真的一定执行吗?从字节码、异常覆盖到线上资源泄漏讲透 Java finally
🔥 个人主页:铁皮哥(欢迎关注)
📌 作者简介:28届校招生,后端开发/Agent 方向在学
📚 学习内容:Java、Python、计算机视觉、大语言模型、Agent开发
📝 专栏内容:从零开始的Claude Code零代码生活(持续更新中)
✨不只背八股,更想搞懂为什么这样设计
前言
finally中的代码一定会被执行吗?
这个问题应该算是 Java 面试里的经典题了。很多人的第一反应是:
“不一定,System.exit() 的时候不会执行。”
这个答案当然没错,但如果只回答到这里,其实有点可惜。
因为面试官问 finally,通常不是想听你背几个特殊情况,而是想看你对 Java 异常处理机制、方法返回过程、资源释放语义 到底理解到什么程度。
比如下面这几个问题,就比“finally 会不会执行”更值得深挖:
try里已经return了,为什么finally还能执行?finally里修改变量,为什么有时候影响返回结果,有时候又不影响?try和finally同时抛异常,最后到底会保留哪个异常?- 为什么很多线上问题不是因为
finally没执行,而是因为finally执行了,反而把真正的异常覆盖掉了?
我后来写后端代码多了之后才发现,finally 真正容易出问题的地方,往往不是那些极端场景,比如 JVM 崩溃、进程被 kill、System.exit(),而是一些看起来很正常的业务代码:
try {
doBusiness();
} finally {
releaseResource();
}
这段代码看起来很安全,对吧?
但如果 doBusiness() 抛了异常,releaseResource() 也抛了异常,最后日志里看到的可能不是业务异常,而是资源释放失败的异常。也就是说,finally 确实执行了,但它把真正有价值的现场信息覆盖掉了。
这类问题在线上排查时非常恶心。你以为问题出在关闭连接、关闭流、释放锁,结果真正的根因可能早就被覆盖了。
一、先把 finally 的执行规则讲准确
看下面这段代码:
public static int test() {
int i = 1;
try {
return i;
} finally {
i = 2;
}
}
这段代码的返回值是多少?
很多人第一眼会觉得是 2,因为 finally 会在 return 之后执行,i 被改成了 2。
但实际返回值是 1。
这就说明一个问题:“finally 在 return 之后执行”这个说法并不严谨。
更准确的说法应该是:
try/catch里的return会先计算返回值,但方法不会立刻真正返回;在真正返回之前,会先执行finally。
也就是说,finally 不是在“方法已经返回之后”执行的。方法一旦真的返回,后面的代码就不可能再执行了。
它真正发生的位置是:
计算 return 表达式
↓
保存返回值
↓
执行 finally
↓
真正返回之前保存的结果
所以刚才那个例子里,执行过程大概是这样:
public static int test() {
int i = 1;
try {
return i; // 先把 i 当前的值 1 作为返回结果保存起来
} finally {
i = 2; // 再执行 finally,但这时改的是局部变量 i
}
}
最终返回的还是之前已经保存好的 1。
1.1 finally 修改局部变量,不一定影响返回值
我们再看一个更完整的例子。
public class FinallyDemo {
public static void main(String[] args) {
System.out.println(test());
}
public static int test() {
int i = 1;
try {
return i;
} finally {
i = 2;
System.out.println("finally 中的 i = " + i);
}
}
}
输出结果是:
finally 中的 i = 2
1
这段输出其实很关键。
它说明两件事:
第一,finally 确实执行了。
第二,finally 修改了局部变量 i,但最终返回值没有被改成 2。
原因就是前面说的:return i 执行时,返回值已经先被计算并保存起来了。后面 finally 里再改 i,改的是局部变量本身,不是之前保存好的返回值。
1.2 返回对象时,finally 修改“对象内容”会生效
上面的例子是基本类型,那如果返回的是对象呢?
看下面这段代码:
import java.util.ArrayList;
import java.util.List;
public class FinallyDemo {
public static void main(String[] args) {
System.out.println(test());
}
public static List<String> test() {
List<String> list = new ArrayList<>();
try {
list.add("try");
return list;
} finally {
list.add("finally");
}
}
}
输出结果是:
[try, finally]
这次 finally 中的修改生效了。
是不是和前面的结论矛盾?
其实不矛盾。
关键在于,return list 保存的是对象引用。这个引用指向的是堆内存中的那个 ArrayList 对象。
执行 return list 时,先把这个引用保存起来。然后进入 finally,执行:
list.add("finally");
这行代码并没有让 list 指向一个新对象,而是在修改原来那个 ArrayList 对象的内容。
最终方法返回的,还是之前保存的那个引用。只不过这个引用指向的对象,内容已经被 finally 改过了。
1.3 finally 中重新给引用赋值,不会改变返回对象
再看一个容易误判的例子:
import java.util.ArrayList;
import java.util.List;
public class FinallyDemo {
public static void main(String[] args) {
System.out.println(test());
}
public static List<String> test() {
List<String> list = new ArrayList<>();
try {
list.add("try");
return list;
} finally {
list = new ArrayList<>();
list.add("finally");
}
}
}
你觉得输出是什么?
答案是:
[try]
不是 [finally],也不是 [try, finally]。
原因也很好理解。
在 try 中执行:
return list;
此时返回值已经保存了原来那个 ArrayList 对象的引用。
到了 finally 里:
list = new ArrayList<>();
list.add("finally");
这两行代码只是让局部变量 list 指向了一个新的 ArrayList 对象。
但是,方法真正要返回的引用,早就在 return list 那一步保存好了。你后面让局部变量 list 指向新对象,并不会改变之前保存的返回引用。
1.4 finally 里写 return,会覆盖 try 里的 return
前面几个例子里,finally 只是修改变量。
那如果 finally 自己也写了 return 呢?
public class FinallyDemo {
public static void main(String[] args) {
System.out.println(test());
}
public static int test() {
try {
return 1;
} finally {
return 2;
}
}
}
输出结果是:
2
这次 finally 真的改变了返回结果。
原因也很直接:finally 自己发起了新的返回动作,它会覆盖掉 try 中原本准备返回的结果。
这段代码的执行过程大概是:
try 中准备返回 1
↓
进入 finally
↓
finally 中直接 return 2
↓
方法最终返回 2
所以这里要非常明确地提醒一句:
不要在 finally 中写 return。
这不是代码风格问题,而是很容易制造隐藏 bug。
因为读代码的人看到 try 里有一个 return 1,第一反应会以为方法返回 1。但真正的返回结果却被 finally 改成了 2。
更糟糕的是,finally 里的 return 不只会覆盖返回值,还可能覆盖异常。
1.5 finally 里的 return 可能吞掉异常
看下面这段代码:
public class FinallyDemo {
public static void main(String[] args) {
test();
System.out.println("程序继续执行");
}
public static void test() {
try {
throw new RuntimeException("try 中的异常");
} finally {
return;
}
}
}
输出结果是:
程序继续执行
try 里明明抛出了异常:
throw new RuntimeException("try 中的异常");
但这个异常并没有传出去。
原因就是 finally 中的:
return;
把原本的异常流程覆盖掉了。
这就很危险了。
因为异常最重要的作用,是告诉调用方:这里出问题了。而 finally 中的 return 会让方法看起来像是“正常结束”,导致真正的错误被悄悄吞掉。
如果这类代码出现在真实项目里,排查起来会非常难受。
比如:
public void handleOrder() {
try {
createOrder();
deductStock();
sendMessage();
} finally {
return;
}
}
这段代码看起来只是做了一个收尾,但实际上,只要 finally 里有 return,前面任何业务异常都可能被吞掉。
订单创建失败了?
库存扣减失败了?
消息发送失败了?
调用方可能完全感知不到。
所以在实际开发里,finally 中应该避免出现任何改变控制流的语句,比如:
return;
throw new RuntimeException();
break;
continue;
尤其是 return,基本可以认为是 finally 里的高危写法。
1.6 finally 里抛异常,会覆盖 try 里的异常
除了 return,finally 中重新抛异常也很危险。
看这个例子:
public class FinallyDemo {
public static void main(String[] args) {
test();
}
public static void test() {
try {
throw new RuntimeException("业务异常");
} finally {
throw new RuntimeException("资源释放异常");
}
}
}
最终你在控制台看到的异常通常是:
Exception in thread "main" java.lang.RuntimeException: 资源释放异常
而不是:
业务异常
也就是说,try 中原本的异常被 finally 中的新异常覆盖了。
在真实业务场景中,很多资源释放代码可能长这样:
Connection connection = null;
try {
connection = dataSource.getConnection();
doBusiness(connection);
} finally {
connection.close();
}
如果 doBusiness(connection) 抛了一个业务异常,接着 connection.close() 又抛了一个关闭异常,那么最终暴露出来的可能是关闭异常,而不是最开始真正导致业务失败的异常。
二、finally 真的一定执行吗?先把边界说清楚
2.1 正常离开 try,finally 通常会执行
先看最普通的情况:
public static void test() {
try {
System.out.println("try");
} finally {
System.out.println("finally");
}
}
输出结果很好理解:
try
finally
这说明 try 正常执行结束时,finally 会执行。
如果 try 里抛异常呢?
public static void test() {
try {
System.out.println("try");
throw new RuntimeException("业务异常");
} finally {
System.out.println("finally");
}
}
输出会先看到:
try
finally
然后异常继续往外抛。
2.2 System.exit:JVM 都要退出了,finally 没机会继续执行
最经典的反例就是 System.exit()。
public class FinallyExitDemo {
public static void main(String[] args) {
try {
System.out.println("try");
System.exit(0);
} finally {
System.out.println("finally");
}
}
}
通常输出结果只有:
try
finally 没有打印。
原因也不复杂。
System.exit(0) 不是普通的 return,它表示请求终止当前 JVM。执行到这里时,程序不会再按照普通 Java 控制流继续往下走。
2.3 死循环:不是 finally 不执行,而是根本没离开 try
再看一个更容易被误解的例子:
public static void test() {
try {
while (true) {
// 一直循环
}
} finally {
System.out.println("finally");
}
}
这段代码里的 finally 也不会执行。
但它和 System.exit() 的原因不一样。
System.exit() 是执行流被 JVM 退出打断了。
死循环则是执行流一直卡在 try 里面,根本没有走到离开 try 的那一步。
finally 的语义是:
当控制流准备离开 try 时,先执行 finally。
但现在的问题是,控制流一直没有离开 try。
2.4 进程被强杀、JVM 崩溃、机器断电:finally 不是崩溃恢复机制
再往外看一层。
如果程序运行过程中,进程被直接强杀,比如 Linux 下执行:
kill -9 <pid>
或者 JVM 自身崩溃,甚至机器直接断电,那么 finally 也不能保证执行。
因为这些情况已经不是 Java 代码内部的正常控制流变化了,而是运行环境直接中断。
finally 再特殊,它也只是 Java 语言层面的机制。它没有能力在进程已经被操作系统强制杀掉之后,继续执行一段 Java 代码。
2.5 守护线程里的 finally,不一定来得及执行
还有一种容易被忽略的情况:守护线程。
先看代码:
public class DaemonFinallyDemo {
public static void main(String[] args) {
Thread thread = new Thread(() -> {
try {
System.out.println("daemon start");
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println("daemon finally");
}
});
thread.setDaemon(true);
thread.start();
System.out.println("main end");
}
}
这段代码里,子线程被设置成了守护线程:
thread.setDaemon(true);
主线程启动它之后,很快打印:
main end
然后主线程结束。
如果 JVM 中已经没有其他非守护线程了,那么 JVM 可以直接退出。这个时候,守护线程可能还在 sleep,根本没来得及走到 finally。
所以你可能只看到:
main end
daemon start
但不一定能看到:
daemon finally
这不是因为 finally 在守护线程里语义变了,而是因为 JVM 不会为了守护线程继续存活。
普通线程还没结束,JVM 会等。
只剩守护线程时,JVM 可以退出。
所以守护线程里的 finally 不适合承担关键资源释放。
比如异步日志刷新、临时文件清理、消息补偿、连接关闭,如果这些动作真的重要,就不应该只依赖守护线程里的 finally。
更稳妥的做法是设计明确的关闭流程,比如应用停止时主动关闭线程池、等待任务完成、刷新缓冲区、释放连接池。
finally 可以作为线程内部的收尾逻辑,但不能保证在 JVM 退出时一定跑完。
2.6 shutdown hook 和 finally 不是一回事
讲到 JVM 退出,很多人会想到 shutdown hook。
比如:
public class ShutdownHookDemo {
public static void main(String[] args) {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("shutdown hook");
}));
System.out.println("main end");
}
}
shutdown hook 是 JVM 关闭时触发的一类回调。它可以用于做一些应用级别的收尾动作,比如关闭线程池、刷新日志、释放连接池、通知注册中心下线。
但它和 finally 不是一回事。
finally 是当前线程、当前代码块退出前的收尾逻辑。shutdown hook 是 JVM 进入关闭流程时执行的回调逻辑。
一个是局部代码块级别。
一个是 JVM 进程级别。
举个例子:
try {
MDC.put("traceId", traceId);
doBusiness();
} finally {
MDC.clear();
}
这里的 finally 适合清理当前请求线程里的上下文。它关注的是这一小段业务代码退出时,该把线程变量清掉。
而 shutdown hook 更像这样:
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
threadPool.shutdown();
dataSource.close();
logSystem.flush();
}));
它关注的是整个应用进程要结束了,能不能做一些统一收尾。
写在文后
期待您的一键三连!如果有什么问题或建议欢迎在评论区交流!
更多推荐
所有评论(0)