checked Exception 和 RuntimeException 有什么区别?
简化版
受检异常(checked)在编译期就强制你处理——要么 try-catch,要么 throws 往上抛,不写就编译不过;RuntimeException 及其子类是非受检异常,编译器不管,通常代表程序 bug 或调用方违反了约定。说白了:受检异常是「预期内的外部失败」,运行时异常是「本不该发生的编程错误」。
详细版
Throwable 往下分两支:Error(如 OutOfMemoryError,应用一般无力回天)和 Exception。Exception 里再分两类:RuntimeException 及其子类是非受检的,其余(如 IOException、SQLException)是受检的。
受检异常代表「你调用外部世界,它可能合理地失败」——文件不存在、网络断了、第三方服务超时。编译器强制你面对它,是想让你别忘了处理这些可恢复的情况。
运行时异常代表「调用方违反了 API 契约」——空指针、数组越界、非法参数。这类问题的正解不是 catch,而是修代码:加判空、校验参数、修正逻辑。盲目 catch 反而会掩盖 bug。
处理异常时有条红线:不能吞异常、要保留 cause。
try {
paymentClient.pay(order);
} catch (IOException e) {
// 转成业务异常,但把原始异常作为 cause 传进去
throw new PaymentException("支付服务调用失败,orderId=" + order.getId(), e);
}
把 e 作为 cause 传进去,日志里才能看到真正的底层堆栈。finally 用来释放资源,而对 Closeable 资源更推荐 try-with-resources,它自动关闭且能正确处理关闭时抛出的异常。
完整版教学
一、这套设计到底想解决什么
Java 的受检异常是一个有争议但有明确意图的设计:编译器逼你在写代码时就想清楚「这个操作失败了怎么办」。读文件、连数据库这些操作,失败是正常业务的一部分,不是意外。把它做成受检,就是不让你「假装它永远成功」。
而 NullPointerException 这类,设计者认为它不该发生——发生了就是代码写错了。如果它也受检,那几乎每行代码都要包 try-catch,代码会被淹没。所以把「程序员的错」归为非受检,让它直接抛到顶、暴露出来、然后去改代码。
二、什么时候用哪种
- 可恢复、调用方能合理应对的失败 → 受检异常:比如「配置文件读不到就用默认值」,给调用方一个「必须应对」的信号是合理的。
- 编程错误、约定被违反 → 运行时异常:参数非法就抛
IllegalArgumentException,状态不对就抛IllegalStateException,让它快速失败(fail-fast),别让脏数据流到更深处。
现实里的趋势:Spring 生态大量使用非受检异常(如
DataAccessException),因为受检异常会污染方法签名、逼着中间层写一堆无意义的throws或catch-rethrow。所以现代业务代码常把底层受检异常在边界层转成运行时异常向上抛。
三、异常处理的几个反模式
❌ 吞异常:catch (Exception e) {} 或只 e.printStackTrace() 完事。异常被吃掉,出了问题无从查起。至少要记带堆栈的日志。
❌ 丢 cause:throw new MyException("失败了")——没把原始 e 传进去,底层真正的原因(是超时还是拒绝连接)全丢了。永远用 new MyException(msg, e)。
❌ 用异常做流程控制:靠 catch 一个异常来代替 if 判断。异常创建时要抓取堆栈,开销远大于普通分支,逻辑也难读。
❌ catch 范围过大:catch (Exception e) 把本该暴露的 NullPointerException 一起吞了,掩盖真 bug。按需 catch 具体类型。
四、try-with-resources 为什么更好
老式写法在 finally 里手动 close(),有个隐蔽陷阱:如果 try 块抛了异常 A,finally 里 close() 又抛了异常 B,异常 A 会被 B 覆盖丢失。
try (InputStream in = new FileInputStream(path)) {
return read(in);
} // 自动 close;若 close 抛异常,会作为 suppressed 挂在主异常上,不丢失
try-with-resources 会自动调 close(),并把关闭时的异常作为 suppressed exception 附加到主异常上,两个异常都能在日志里看到。凡是实现了 AutoCloseable 的资源都该用它。
五、异常应该在哪一层翻译
异常不能机械地一路原样抛到最外层。基础设施层抛出的 SQLException、IOException 描述的是技术细节,服务层通常应在有足够上下文的位置把它翻译成领域异常,同时保留 cause;接口层再把领域异常映射成稳定的错误码,而不是把类名和堆栈暴露给客户端。
数据库 SQLException
→ 仓储层 OrderRepositoryException(保留 cause)
→ 服务层 OrderCreateFailed
→ HTTP 409 + 业务错误码 ORDER_CREATE_FAILED
例如 100 次远程调用中有 2 次超时,调用方若具有幂等保证,可以只对超时做有限重试;若是参数非法,则重试 100 次也不会成功。异常分类的价值就在这里:它让上层能够区分“可重试、可降级、应告警、应修代码”四种完全不同的动作。
| 异常含义 | 常见处理 |
|---|---|
| 参数或状态不合法 | 快速失败,修正调用逻辑 |
| 短暂网络超时 | 在幂等前提下有限重试 |
| 资源永久不存在 | 返回明确业务结果,不盲目重试 |
| 系统不可用 | 降级、熔断并告警 |
六、常见误区与追问
- 误区:只要 catch 住异常,系统就更稳定。 没有恢复策略的 catch 只会隐藏故障,应让异常到达真正能处理它的边界。
- 误区:
RuntimeException都不需要记录和处理。 它不受编译器强制,不等于可以忽略;统一异常边界仍要记录上下文并转换响应。 - 误区:重新抛异常时只写新消息。 这样会丢失原始堆栈,必须通过构造器把原异常作为 cause 传入。
- 追问:能否直接
catch (Throwable)? 通常不能,因为它连OutOfMemoryError等严重错误也捕获;业务代码应捕获能够恢复的具体异常。 - 追问:
finally一定执行吗? 正常 JVM 控制流下通常执行,但进程被强制终止、JVM 崩溃等情况不能保证;也不要在finally中return。 - 追问:多个资源按什么顺序关闭? try-with-resources 按声明的逆序关闭,后声明的资源先关闭,类似栈的后进先出。
记忆钩子:异常处理不是“把红字消掉”,而是把失败分类后交给真正有恢复能力的那一层。
try-with-resources 的 suppressed 异常也可以程序化检查。下面的主异常来自业务读取,关闭异常不会覆盖它,而是被挂在主异常上:
try (BrokenResource r = new BrokenResource()) {
throw new IOException("读取失败");
} catch (IOException e) {
System.out.println(e.getMessage()); // 读取失败
System.out.println(e.getSuppressed().length); // 1
System.out.println(e.getSuppressed()[0].getMessage()); // 关闭失败
}
这比手写 finally 更利于定位“先失败还是关闭时失败”,也说明资源管理 API 的价值不仅是少写几行代码,而是保留完整故障因果链。
七、加强记忆
先按编译规则分:除 RuntimeException 体系外的 Exception 通常是受检异常,必须捕获或声明;再按失败语义分:外部、可恢复的失败由调用方决定重试或降级,契约被违反则快速失败并修代码。异常跨层时要在掌握业务语境的位置翻译,始终保留 cause;资源用 try-with-resources,捕获范围尽量具体。这样记住的是一条完整处理链,而不只是 checked 与 unchecked 两个名词。