ElIauk

Java 异常处理:try-catch-finally 返回值坑

异常体系先理清 Java 的异常都继承自 Throwable,往下分两支: 类型 举例 要不要处理 Error OutOfMemoryError、StackOverflowError 程序层面处理不了,不要 catch RuntimeException NullPointerException、Il

评论
Java 异常处理:try-catch-finally 返回值坑

异常体系先理清

Java 的异常都继承自 Throwable,往下分两支:

类型 举例 要不要处理
Error OutOfMemoryError、StackOverflowError 程序层面处理不了,不要 catch
RuntimeException NullPointerException、IllegalArgumentException 非受检,编译期不强制
其他 Exception IOException、SQLException 受检,必须 try 或 throws

很多新手把“受检”理解成“必须 catch 然后吞掉”,结果 catch 块里只写一句打印堆栈,线上出问题时日志文件里什么都看不到。异常处理的目标不是让编译通过,而是让问题可定位、可恢复。

受检异常适合“调用方有办法恢复”的场景,比如文件不存在可以提示用户换个路径;非受检异常通常代表参数错误或程序 bug,让它在最外层统一处理更合适。新写业务代码时,优先用非受检的业务异常,避免每层方法都写一长串 throws。

还有一点常被忽略:创建异常对象时会记录堆栈,开销比普通对象大。不要用异常来控制循环里的普通分支,该用 if 判断的地方就判断。异常只留给真正的异常状态。

自定义异常时保留两个信息就够了:错误码和可读的提示语。错误码给程序判断,提示语给人看,不要把 SQL、类名这些内部细节写进提示语。

try-catch-finally 的执行顺序

正常情况:执行 try,再执行 finally。 try 里抛异常:中断 try,进入匹配的 catch,最后执行 finally。 catch 里又抛异常:先执行 finally,再把异常抛给调用方。

finally 几乎一定会执行,只有 System.exit()、进程被 kill、JVM 崩溃这几种情况例外。所以关闭资源、释放锁的代码放 finally 里是有道理的,只是 JDK 7 之后更推荐 try-with-resources。

finally 里的 return 会吞掉返回值

这是笔试题高频题,也是真实代码里的隐患。看下面三个方法:

public class FinallyReturn {
    static int normal() {
        int x = 1;
        try {
            return x;      // 返回值先被保存到临时变量
        } finally {
            x = 2;         // 改的是局部变量,不影响已保存的返回值
        }
    }

    static int override() {
        try {
            return 1;
        } finally {
            return 2;      // 直接覆盖返回值
        }
    }

    static int swallow() {
        try {
            throw new IllegalStateException("业务异常");
        } finally {
            return -1;     // 异常被吞掉,方法正常返回 -1
        }
    }

    public static void main(String[] args) {
        System.out.println(normal());   // 1
        System.out.println(override()); // 2
        System.out.println(swallow());  // -1,异常消失了
    }
}

normal() 返回 1 是因为基本类型的返回值在 finally 执行前就复制了一份;override() 和 swallow() 则说明 finally 里的 return 会覆盖前面的返回值,甚至把异常吃掉。如果返回的是对象引用,finally 里修改对象内容,调用方拿到的对象也会跟着变,因为引用指向的是同一个对象。

结论很简单:不要在 finally 里写 return、break、continue,也不要抛新异常。IDEA 会把这种写法标黄,不是没有原因的。

try-with-resources

只要资源实现了 AutoCloseable,就可以交给 try-with-resources 自动关闭,不用手写 finally:

import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

public class ReadDemo {
    public static String firstLine(Path path) throws IOException {
        try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
            return reader.readLine();
        }
    }
}

多个资源用分号隔开,关闭顺序和声明顺序相反。如果 try 块和 close() 都抛异常,close 的异常会作为 suppressed 挂在主异常上,不会丢失。

常见坑

1. catch 之后什么都不做

空 catch 是线上问题最难查的原因之一:接口返回成功,数据却没写进去。至少写 log.error("保存订单失败, orderId={}", orderId, e),或者包装成业务异常继续往上抛。

2. 把堆栈直接打到控制台

堆栈输出到标准错误流,生产环境通常不会重定向到日志文件,而且拿不到 traceId、用户 ID 这些上下文。统一用日志框架,把异常对象作为最后一个参数传给 log.error,堆栈才会完整打印。

3. 丢掉了异常链

throw new RuntimeException(e.getMessage()) 只保留了消息,原始堆栈和 cause 全丢了。正确写法是 throw new RuntimeException("创建订单失败", e),排查时顺着 Caused by 就能找到根因。

4. catch Exception 一把梭

catch (Exception e) 会把空指针、类型转换错误这类编码问题也一起吞掉,问题被掩盖到更晚才爆发。先捕获具体的 IOException、IllegalArgumentException,确实需要兜底时再统一处理。

5. 用异常控制正常流程

“查不到就抛异常”当普通分支用,会让代码又慢又难读。查不到返回 Optional.empty() 或空集合,只有真正的异常状态才抛。

排查步骤

  1. 看日志里第一个 Caused by,那才是根因;
  2. 确认异常有没有在中途被空 catch 或 finally 吞掉;
  3. 搜索 finally 块里是否存在 return;
  4. 确认资源是否通过 try-with-resources 关闭;
  5. 补上关键参数和业务 ID 再复现一次,避免“日志里只有一句 null”。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者