【后端开发】finally 真的一定执行吗?从字节码、异常覆盖到线上资源泄漏讲透 Java finally
2026/5/11 6:32:42 网站建设 项目流程

文章目录

  • 前言
    • 一、先把 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里修改变量,为什么有时候影响返回结果,有时候又不影响?
  • tryfinally同时抛异常,最后到底会保留哪个异常?
  • 为什么很多线上问题不是因为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 里的异常

除了returnfinally中重新抛异常也很危险。

看这个例子:

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");}}}

通常输出结果只有:

try

finally没有打印。

原因也不复杂。

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();}));

它关注的是整个应用进程要结束了,能不能做一些统一收尾。

写在文后

期待您的一键三连!如果有什么问题或建议欢迎在评论区交流!

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询