异常体系与最佳实践

举报
yd_94907100 发表于 2026/10/21 09:27:54 2026/10/21
【摘要】 Java 异常体系与最佳实践:从栈帧到 try-with-resources异常处理写得好不好,直接决定线上排障时能不能拿到有效信息。很多团队的问题不是"没捕获异常",而是捕获后吞掉、日志重复打印、或者在 finally 里 return 覆盖了真实结果。本文先理清 Throwable 体系,再深入 try-with-resources 的字节码与抑制异常机制,最后给出一套可以直接落地的规...

Java 异常体系与最佳实践:从栈帧到 try-with-resources

异常处理写得好不好,直接决定线上排障时能不能拿到有效信息。很多团队的问题不是"没捕获异常",而是捕获后吞掉、日志重复打印、或者在 finally 里 return 覆盖了真实结果。

本文先理清 Throwable 体系,再深入 try-with-resources 的字节码与抑制异常机制,最后给出一套可以直接落地的规范。

体系结构

Throwable
├── Error                 // 不应捕获,JVM 层面的严重问题
│   ├── OutOfMemoryError
│   ├── StackOverflowError
│   └── NoClassDefFoundError
└── Exception
    ├── RuntimeException  // 非受检
    │   ├── NullPointerException
    │   ├── IllegalArgumentException
    │   └── ConcurrentModificationException
    └── 其他受检异常
        ├── IOException
        ├── SQLException
        └── InterruptedException

Error 表示程序无法恢复的状态,catch (Throwable) 会连 OutOfMemoryError 一起吞掉,通常是有害的。Exception 分为受检与非受检,区别在于编译器是否强制处理。

RuntimeException 及其子类是"编程错误"的语义,例如空指针、数组越界、参数非法;受检异常表示"外部条件导致、调用方可以合理恢复"的情况,例如文件不存在、网络中断。

受检异常的争议与现状

受检异常的设计初衷是强制调用方处理可恢复的错误。实践中它带来两个问题:一是在 lambda 和 Stream 中无法直接抛出受检异常,被迫包一层 try-catch;二是层层向上声明导致方法签名污染。

现代框架的选择很明确:Spring 把 SQLException 包装成 DataAccessException(非受检),JPA 的 EntityNotFoundException 也是非受检。新代码中,业务异常建议统一继承 RuntimeException,受检异常只用于确实需要调用方立即处理的资源类操作。

栈帧与性能

异常的代价主要来自 fillInStackTrace():它遍历当前线程的栈,为每一帧生成 StackTraceElement。异常对象本身很轻,栈深度大时开销才明显。

JDK 7 起 Throwable 增加了 writableStackTrace 参数,可以关闭栈记录:

public class FlowControlException extends RuntimeException {
    public FlowControlException() {
        super(null, null, false, false); // message, cause, suppression, writableStackTrace
    }
}

这种异常适合做流程控制(例如用异常跳出多层循环),但不能用于真正的错误,否则排障时没有调用栈。

具体差多少,可以用一段基准测出来。同一段逻辑分别用返回值和异常表示失败,各执行一千万次(JDK 17,结果只看数量级):

方式 耗时(ms) 说明
返回 boolean 12 无对象分配
抛出预创建异常 40 复用实例,仍有 throw 与 catch 开销
抛出新建异常(无栈) 900 每次分配对象
抛出新建异常(带栈) 4200 额外填充栈帧

带栈异常比返回码慢两个数量级以上,但单次成本仍在微秒级。每秒处理几千次请求的服务不必为此优化;而在解析器、序列化器这类每秒调用百万次的热点路径上,用异常做控制流就是灾难。

另一个常被忽略的成本是日志。log.error("...", e) 会格式化完整堆栈,几十层嵌套的堆栈每次可能输出几十 KB,高频异常会把磁盘和日志系统打满。这类场景应按异常类型计数后聚合输出,而不是每条都打。

自定义业务异常的设计

一个可用的业务异常通常包含三部分:错误码、用户可读消息、以及可选的上下文数据。

public class BizException extends RuntimeException {
    private static final long serialVersionUID = 1L;
    private final int code;

    public BizException(int code, String message) {
        super(message);
        this.code = code;
    }

    public BizException(int code, String message, Throwable cause) {
        super(message, cause);
        this.code = code;
    }

    public int getCode() {
        return code;
    }
}

设计上有几点取舍。第一,错误码用枚举或常量管理,避免散落在各处的魔法数字。第二,不要为每种业务错误都定义一个异常类,几百个 XxxException 会让 catch 分支爆炸,用错误码区分更合适。第三,业务异常不应携带敏感信息,堆栈可能被写入日志或被监控系统采集。

serialVersionUID 在单体应用中看似无用,但异常对象在 RPC 调用中需要跨进程序列化,缺失时两端类的字段稍有差异就会抛 InvalidClassException。

finally 的执行语义

finally 一定会执行,除了 System.exit()、Runtime.halt()、守护线程被终止、以及 JVM 崩溃这几种情况。

最容易出错的是 finally 中的 return 或 throw,它会覆盖 try 块中的返回值甚至异常:

static int f() {
    try {
        return 1;
    } finally {
        return 2;   // 返回 2,try 中的 1 被丢弃
    }
}

更隐蔽的是异常被吞:

static void g() {
    try {
        throw new IllegalStateException("boom");
    } finally {
        return;      // 异常被静默丢弃
    }
}

javac 会为这种写法生成警告,但不会报错。规范做法是 finally 只做清理,不写 return、break、continue。

try-with-resources 的字节码

try (BufferedReader br = Files.newBufferedReader(path)) {
    return br.readLine();
}

编译后大致等价于:

BufferedReader br = Files.newBufferedReader(path);
Throwable primary = null;
try {
    return br.readLine();
} catch (Throwable t) {
    primary = t;
    throw t;
} finally {
    if (br != null) {
        if (primary != null) {
            try {
                br.close();
            } catch (Throwable suppressed) {
                primary.addSuppressed(suppressed);
            }
        } else {
            br.close();
        }
    }
}

关键点是 addSuppressed:关闭时的异常不会覆盖主异常,而是挂在 getSuppressed() 上。手写 finally { br.close(); } 则会用关闭异常覆盖业务异常,丢掉真正的根因。

资源按声明的逆序关闭。JDK 7 起 AutoCloseable 是基础接口,Closeable 继承它并把 close() 的异常收窄为 IOException。JDK 9 起支持在 try 括号中直接使用已声明的 effectively final 变量:

BufferedReader br = Files.newBufferedReader(path);
try (br) {
    return br.readLine();
}

异常包装与根因

包装时务必传入 cause,否则堆栈断裂:

try {
    dao.save(entity);
} catch (SQLException e) {
    throw new DataAccessException("保存用户失败, id=" + entity.getId(), e);
}

printStackTrace() 会输出 Caused by: 链。日志框架同样会打印完整的 cause,因此不要既 log.error 又 throw,否则同一异常被打印两次。

常见坑 / 最佳实践

  1. 捕获后不处理。catch (Exception e) {} 是最危险的写法,至少记录日志并保留堆栈,日志用 log.error("msg", e) 而不是拼接 e.getMessage()。
  2. 用异常做正常流程。文件不存在、参数校验失败这类可预期分支,用返回值或 Optional 表达,避免频繁填充栈。
  3. 捕获范围过大。catch (Exception e) 会连 NullPointerException 这类编程错误一起吞掉,应捕获最具体的类型,或按类型分支处理。
  4. 在 finally 中 return 或抛异常。会覆盖原结果,只做资源清理。
  5. 丢失 cause。throw new RuntimeException(e.getMessage()) 丢掉了原始堆栈,必须把 e 作为 cause 传入。
  6. 忽略中断。捕获 InterruptedException 后必须恢复中断标志 Thread.currentThread().interrupt(),否则上层无法感知取消。
  7. 异常信息缺少上下文。把关键参数拼进 message,例如 "订单不存在, orderId=" + id,但不要拼接敏感信息。
  8. 自定义异常不序列化。分布式场景下异常要跨进程传输,serialVersionUID 缺失会导致反序列化失败。

日志与异常的分工

推荐的分层策略是:DAO 层不捕获,让异常向上传播;Service 层在能处理时处理,不能处理时包装成业务异常;Controller 层统一由 @RestControllerAdvice 转换为 HTTP 响应,并在此处记录一次完整日志。

这样同一异常只在边界处打印一次,堆栈完整,也不会污染业务日志。

小结

  • Error 不应捕获,Exception 按是否可恢复区分受检与非受检,新业务代码优先使用非受检。
  • 异常开销主要在 fillInStackTrace,高频路径避免用异常做流程控制。
  • finally 中不要 return,否则会覆盖返回值或吞掉异常。
  • try-with-resources 通过 addSuppressed 保留主异常,手写 finally 会丢失根因。
  • 包装异常必须传入 cause,日志只在边界记录一次。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。