学完这篇,你能拿到一套可落地的判断标准:面对一个具体场景,能立刻决定该抛受检异常还是运行时异常,并且知道自定义异常、包装转换和 catch 块该怎么写才不埋雷。
第一步:先分清两者的机制差异
Java 里的 Throwable 分两条线:
Exception 的子类中,除 RuntimeException 分支外,都是受检异常(checked),编译器强制你处理:要么 try-catch,要么在方法签名上 throws 往外抛,否则编译不过。
RuntimeException 及其子类(NullPointerException、IllegalArgumentException、IllegalStateException 等)和 Error 是非受检异常(unchecked),编译器不管,运行时才可能冒出来。
这个差异决定了它们的定位:受检异常是方法契约的一部分,写在签名上就是在告诉调用方「这件事你必须表态」;非受检异常则表达「这是 bug 或者调用方违反了约定」。
第二步:用一个问题做取舍判断
判断标准只有一句话:调用方有没有能力、有没有必要当场采取不同的补救动作?
- 有能力补救 → 用受检异常。典型场景:网络超时后重试、文件不存在时改用默认配置、余额不足时提示充值。调用方拿到异常后确实能走另一条分支。
- 无能力补救,或这本来就是编程错误 → 用运行时异常。典型场景:传了
null 参数、状态机调用顺序错了、数组越界。这种异常的正确处理方式是让程序快速失败、暴露问题、改代码,而不是在每个调用点写 catch。
注意:最常见的坏味道是「受检异常污染签名」——底层一个 throws IOException,一路 throws 传染到 Controller,中间十几层方法其实什么也做不了。这种情况下应尽早包装成运行时异常,在真正能处理的边界(全局异常处理器)统一兜住。
第三步:受检异常的正确写法
自定义受检异常,继承 Exception,并把原始异常带上:
public class ConfigLoadException extends Exception {
public ConfigLoadException(String message, Throwable cause) {
super(message, cause);
}
}
调用方写明确的恢复逻辑,而不是打印一行日志了事:
try {
config = loader.load(path);
} catch (ConfigLoadException e) {
log.warn("加载配置失败,回退默认配置", e);
config = ConfigLoader.defaults();
}
注意:不要为了省事定义 class MyException extends Exception {} 然后到处 throws。受检异常一旦超出调用方实际能处理的范围,就会被写成空的 catch (MyException e) {},比不抛还糟。
第四步:运行时异常的用法边界
参数校验统一用 IllegalArgumentException,状态不对用 IllegalStateException,找不到资源在服务层可以用自定义的 NotFoundException extends RuntimeException:
public User getById(long id) {
if (id <= 0) {
throw new IllegalArgumentException("id 必须为正数: " + id);
}
User u = mapper.selectById(id);
if (u == null) {
throw new NotFoundException("用户不存在: " + id);
}
return u;
}
这样调用方代码干净,异常由全局处理器(@RestControllerAdvice 或 Servlet Filter)翻译成 HTTP 状态码和错误体。
注意:抛运行时异常时,消息里必须带上定位问题所需的关键数据(哪个 id、哪个字段、期望值是什么)。只写「参数错误」的异常,等于给未来的自己挖坑。
第五步:包装与转换,别丢 cause
跨层转换异常时,务必把原异常作为 cause 传进去:
try {
return httpClient.execute(req);
} catch (IOException e) {
throw new RemoteCallException("调用订单服务失败, traceId=" + traceId, e); // 保留 e
}
丢了 cause,堆栈就断在包装点,真正的根因永远查不到。
注意:Java 的 lambda 里不能直接抛受检异常(Function、Supplier 的签名没声明 throws)。要么在 lambda 内 try-catch 包装成运行时异常,要么自己定义一个允许抛异常的 @FunctionalInterface,不要用 sneakyThrow 之类的手段绕过编译器。
第六步:catch 块的几条硬规矩
- 不吞异常:
catch (Exception e) {} 是禁止项,至少记日志。
- catch 粒度尽量小:优先捕获具体类型,
catch (Exception e) 只放在最外层兜底。
- 资源用 try-with-resources:
try (InputStream in = new FileInputStream(f)) { ... },别手写 finally 关流。
- 不要在 finally 里 return 或抛异常,会覆盖掉原有的异常和返回值。
- 日志只打一次:中间层要么记录并转换后往上抛,要么只往上抛,别每层都
log.error 一遍,堆栈刷屏还干扰排查。
小结
- 受检异常表达「调用方应该处理的、可恢复的失败」,是方法契约的一部分;运行时异常表达「编程错误或调用方违约」,让程序快速失败。
- 取舍就看一句话:调用方能不能当场做出不同的补救动作。能,用受检;不能,用运行时。
- 底层受检异常别一路
throws 传染,在合适边界包装成运行时异常,交给全局处理器。
- 包装异常必须保留
cause,异常消息必须带定位数据。
- catch 块不吞异常、粒度要小、资源用 try-with-resources、日志别重复打。