文章目录
- 前言
- 一、先把 finally 的执行规则讲准确
- 1.1 finally 修改局部变量,不一定影响返回值
- 1.2 返回对象时,finally 修改“对象内容”会生效
- 1.3 finally 中重新给引用赋值,不会改变返回对象
- 1.4 finally 里写 return,会覆盖 try 里的 return
- 1.5 finally 里的 return 可能吞掉异常
- 1.6 finally 里抛异常,会覆盖 try 里的异常
- 二、finally 真的一定执行吗?先把边界说清楚
- 2.1 正常离开 try,finally 通常会执行
- 2.2 System.exit:JVM 都要退出了,finally 没机会继续执行
- 2.3 死循环:不是 finally 不执行,而是根本没离开 try
- 2.4 进程被强杀、JVM 崩溃、机器断电:finally 不是崩溃恢复机制
- 2.5 守护线程里的 finally,不一定来得及执行
- 2.6 shutdown hook 和 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 的执行规则讲准确
看下面这段代码:
publicstaticinttest(){inti=1;try{returni;}finally{i=2;}}这段代码的返回值是多少?
很多人第一眼会觉得是2,因为finally会在return之后执行,i被改成了2。
但实际返回值是1。
这就说明一个问题:“finally 在 return 之后执行”这个说法并不严谨。
更准确的说法应该是:
try/catch里的return会先计算返回值,但方法不会立刻真正返回;在真正返回之前,会先执行finally。
也就是说,finally不是在“方法已经返回之后”执行的。方法一旦真的返回,后面的代码就不可能再执行了。
它真正发生的位置是:
计算 return 表达式 ↓ 保存返回值 ↓ 执行 finally ↓ 真正返回之前保存的结果所以刚才那个例子里,执行过程大概是这样:
publicstaticinttest(){inti=1;try{returni;// 先把 i 当前的值 1 作为返回结果保存起来}finally{i=2;// 再执行 finally,但这时改的是局部变量 i}}最终返回的还是之前已经保存好的1。
1.1 finally 修改局部变量,不一定影响返回值
我们再看一个更完整的例子。
publicclassFinallyDemo{publicstaticvoidmain(String[]args){System.out.println(test());}publicstaticinttest(){inti=1;try{returni;}finally{i=2;System.out.println("finally 中的 i = "+i);}}}输出结果是:
finally 中的 i = 2 1这段输出其实很关键。
它说明两件事:
第一,finally确实执行了。
第二,finally修改了局部变量i,但最终返回值没有被改成2。
原因就是前面说的:return i执行时,返回值已经先被计算并保存起来了。后面finally里再改i,改的是局部变量本身,不是之前保存好的返回值。
1.2 返回对象时,finally 修改“对象内容”会生效
上面的例子是基本类型,那如果返回的是对象呢?
看下面这段代码:
importjava.util.ArrayList;importjava.util.List;publicclassFinallyDemo{publicstaticvoidmain(String[]args){System.out.println(test());}publicstaticList<String>test(){List<String>list=newArrayList<>();try{list.add("try");returnlist;}finally{list.add("finally");}}}输出结果是:
[try, finally]这次finally中的修改生效了。
是不是和前面的结论矛盾?
其实不矛盾。
关键在于,return list保存的是对象引用。这个引用指向的是堆内存中的那个ArrayList对象。
执行return list时,先把这个引用保存起来。然后进入finally,执行:
list.add("finally");这行代码并没有让list指向一个新对象,而是在修改原来那个ArrayList对象的内容。
最终方法返回的,还是之前保存的那个引用。只不过这个引用指向的对象,内容已经被finally改过了。
1.3 finally 中重新给引用赋值,不会改变返回对象
再看一个容易误判的例子:
importjava.util.ArrayList;importjava.util.List;publicclassFinallyDemo{publicstaticvoidmain(String[]args){System.out.println(test());}publicstaticList<String>test(){List<String>list=newArrayList<>();try{list.add("try");returnlist;}finally{list=newArrayList<>();list.add("finally");}}}你觉得输出是什么?
答案是:
[try]不是[finally],也不是[try, finally]。
原因也很好理解。
在try中执行:
returnlist;此时返回值已经保存了原来那个ArrayList对象的引用。
到了finally里:
list=newArrayList<>();list.add("finally");这两行代码只是让局部变量list指向了一个新的ArrayList对象。
但是,方法真正要返回的引用,早就在return list那一步保存好了。你后面让局部变量list指向新对象,并不会改变之前保存的返回引用。
1.4 finally 里写 return,会覆盖 try 里的 return
前面几个例子里,finally只是修改变量。
那如果finally自己也写了return呢?
publicclassFinallyDemo{publicstaticvoidmain(String[]args){System.out.println(test());}publicstaticinttest(){try{return1;}finally{return2;}}}输出结果是:
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 可能吞掉异常
看下面这段代码:
publicclassFinallyDemo{publicstaticvoidmain(String[]args){test();System.out.println("程序继续执行");}publicstaticvoidtest(){try{thrownewRuntimeException("try 中的异常");}finally{return;}}}输出结果是:
程序继续执行try里明明抛出了异常:
thrownewRuntimeException("try 中的异常");但这个异常并没有传出去。
原因就是finally中的:
return;把原本的异常流程覆盖掉了。
这就很危险了。
因为异常最重要的作用,是告诉调用方:这里出问题了。而finally中的return会让方法看起来像是“正常结束”,导致真正的错误被悄悄吞掉。
如果这类代码出现在真实项目里,排查起来会非常难受。
比如:
publicvoidhandleOrder(){try{createOrder();deductStock();sendMessage();}finally{return;}}这段代码看起来只是做了一个收尾,但实际上,只要finally里有return,前面任何业务异常都可能被吞掉。
订单创建失败了?
库存扣减失败了?
消息发送失败了?
调用方可能完全感知不到。
所以在实际开发里,finally中应该避免出现任何改变控制流的语句,比如:
return;thrownewRuntimeException();break;continue;尤其是return,基本可以认为是finally里的高危写法。
1.6 finally 里抛异常,会覆盖 try 里的异常
除了return,finally中重新抛异常也很危险。
看这个例子:
publicclassFinallyDemo{publicstaticvoidmain(String[]args){test();}publicstaticvoidtest(){try{thrownewRuntimeException("业务异常");}finally{thrownewRuntimeException("资源释放异常");}}}最终你在控制台看到的异常通常是:
Exception in thread "main" java.lang.RuntimeException: 资源释放异常而不是:
业务异常也就是说,try中原本的异常被finally中的新异常覆盖了。
在真实业务场景中,很多资源释放代码可能长这样:
Connectionconnection=null;try{connection=dataSource.getConnection();doBusiness(connection);}finally{connection.close();}如果doBusiness(connection)抛了一个业务异常,接着connection.close()又抛了一个关闭异常,那么最终暴露出来的可能是关闭异常,而不是最开始真正导致业务失败的异常。
二、finally 真的一定执行吗?先把边界说清楚
2.1 正常离开 try,finally 通常会执行
先看最普通的情况:
publicstaticvoidtest(){try{System.out.println("try");}finally{System.out.println("finally");}}输出结果很好理解:
try finally这说明try正常执行结束时,finally会执行。
如果try里抛异常呢?
publicstaticvoidtest(){try{System.out.println("try");thrownewRuntimeException("业务异常");}finally{System.out.println("finally");}}输出会先看到:
try finally然后异常继续往外抛。
2.2 System.exit:JVM 都要退出了,finally 没机会继续执行
最经典的反例就是System.exit()。
publicclassFinallyExitDemo{publicstaticvoidmain(String[]args){try{System.out.println("try");System.exit(0);}finally{System.out.println("finally");}}}通常输出结果只有:
tryfinally没有打印。
原因也不复杂。
System.exit(0)不是普通的return,它表示请求终止当前 JVM。执行到这里时,程序不会再按照普通 Java 控制流继续往下走。
2.3 死循环:不是 finally 不执行,而是根本没离开 try
再看一个更容易被误解的例子:
publicstaticvoidtest(){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,不一定来得及执行
还有一种容易被忽略的情况:守护线程。
先看代码:
publicclassDaemonFinallyDemo{publicstaticvoidmain(String[]args){Threadthread=newThread(()->{try{System.out.println("daemon start");Thread.sleep(5000);}catch(InterruptedExceptione){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。
比如:
publicclassShutdownHookDemo{publicstaticvoidmain(String[]args){Runtime.getRuntime().addShutdownHook(newThread(()->{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(newThread(()->{threadPool.shutdown();dataSource.close();logSystem.flush();}));它关注的是整个应用进程要结束了,能不能做一些统一收尾。
写在文后
期待您的一键三连!如果有什么问题或建议欢迎在评论区交流!