🔥 个人主页:铁皮哥(欢迎关注)
📌 作者简介: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 的执行规则讲准确

看下面这段代码:

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 里的异常

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

看这个例子:

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

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

写在文后

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

更多推荐