「Java 进阶之路」系列 Day27
写在前面
模块三集合框架收官了,从这篇开始进入模块四"异常与IO"。Checked异常和Unchecked异常的区别背起来不难,但真到自己设计一个业务异常时,很多人还是拿不准该继承哪个------这篇把这个判断标准讲清楚,顺带聊聊为什么Checked异常在实际项目里争议不小。
一、是什么:异常体系的分类
Error代表JVM层面的严重问题(比如OutOfMemoryError),一般不建议捕获处理,程序基本没有恢复的余地;真正需要关心的是Exception这一支,又分两类:
- Checked异常(受检异常) :
Exception的直接子类(不包括RuntimeException),比如IOException、SQLException。编译器强制要求处理 ------要么用try-catch捕获,要么在方法签名上用throws声明往外抛,不处理编译都过不去。 - Unchecked异常(非受检异常) :
RuntimeException及其子类,比如NullPointerException、IllegalArgumentException。编译器不做任何强制要求,可以处理也可以不处理,代码照样能编译通过。
二、为什么要分两类:编译器要不要强制"关注失败情况"
这套设计的初衷是:Checked异常代表"可以预见、调用方也有能力应对"的失败场景 (比如读文件时文件不存在、发请求时网络超时),编译器强制你在写代码的时候就正视这些可能失败的情况,不能假装它们不存在;Unchecked异常代表编程错误或者调用方根本无力恢复的场景 (比如传了个null进去、数组下标越界),这类问题的正确做法是修复代码逻辑,而不是在业务代码里到处捕获它们,所以编译器不做强制要求。
但这套设计在实际工程中争议不小 。最常见的抱怨是:Checked异常一旦出现在底层方法里,会像滚雪球一样一路往上传染------每一层调用方法都被迫要么捕获处理、要么继续往外throws声明,很多时候中间这些层根本不知道该怎么"处理"这个异常,只能机械地继续往上抛,throws声明写得又臭又长,代码可读性和维护成本都受影响。这也是为什么Spring这类主流框架,在设计数据访问层的时候,把JDBC抛出的SQLException(Checked)统一包装成了DataAccessException(继承自RuntimeException)------不再强迫每一层业务代码都去处理这个底层的数据库异常,而是让它自然地往上冒泡,交给统一的异常处理入口去兜底,这是业界对Checked异常滥用问题的一种主流应对方式。
三、怎么用:自定义异常该继承谁,看这一个问题
核心判断标准只有一句话:调用方拿到这个异常之后,有没有一个合理、具体的后续处理动作?
- 如果有(比如"库存不足"这类异常,调用方可以据此提示用户"库存不足,换个数量再试",是一个明确、有意义的业务分支),用Checked异常,强制调用方必须考虑并处理这种情况:
java
public class InsufficientStockException extends Exception {
public InsufficientStockException(String message) {
super(message);
}
public InsufficientStockException(String message, Throwable cause) {
super(message, cause); // 带cause的构造方法,方便追踪异常链的根源
}
}
- 如果没有(比如"参数校验失败"这类由调用方自己传参错误导致的问题,正确的应对方式是修好调用代码本身,而不是运行时捕获它做点什么),用Unchecked异常 (通常直接继承
RuntimeException或者它的常见子类比如IllegalArgumentException),不强迫每一层调用方都要写一段try-catch:
java
public class InvalidOrderParamException extends RuntimeException {
public InvalidOrderParamException(String message) {
super(message);
}
}
一个容易被忽视的细节:自定义异常一定要提供带 cause 的构造方法
java
try {
doDbOperation();
} catch (SQLException e) {
throw new ServiceException("查询用户信息失败", e); // 把原始异常e作为cause传进去
}
如果不把原始的SQLException作为cause传进新抛出的异常里,排查问题时e.printStackTrace()只能看到"查询用户信息失败"这句话和这次抛出的堆栈,根本原因(原始的SQLException到底是连接超时还是SQL语法错误)就彻底丢失了 。规范的做法永远是把捕获到的原始异常通过cause链传递下去,保留完整的异常链条。
另一个常见误区:不要用异常控制正常的业务流程
java
// 不好的写法:用异常来判断"找不到"这种正常业务分支
try {
return findUserOrThrow(id);
} catch (UserNotFoundException e) {
return null;
}
// 更合适的写法:用返回值表达"可能不存在"这种正常情况
Optional<User> findUser(id);
异常对象在创建时会执行fillInStackTrace()填充完整的调用栈信息,这个操作本身是有性能开销的。如果一个场景(比如"根据id查用户,查不到")属于业务逻辑里完全正常、经常发生的分支,不应该用抛异常这种偏"意外情况"语义、且有额外开销的方式去表达,用Optional或者返回null(配合清晰的文档说明)会更合适、性能也更好。
四、面试追问
Q1:Checked异常和Unchecked异常最核心的区别是什么?
Checked异常是Exception的直接子类(不含RuntimeException),编译器强制要求要么用try-catch捕获、要么在方法签名用throws声明,否则编译不通过;Unchecked异常是RuntimeException及其子类,编译器不做任何强制要求,可以处理也可以不处理。设计初衷是Checked异常代表调用方有能力应对的可预见失败,Unchecked异常代表编程错误或者调用方无力恢复的场景。
Q2:为什么很多框架(比如Spring)倾向于把底层的 Checked 异常包装成 Unchecked 异常?
因为Checked异常在实际使用中容易"传染"------底层抛出的Checked异常会强迫上层每一个调用方法都要处理或者继续声明抛出,很多中间层根本不知道该怎么处理,只能机械地往上抛,导致方法签名的throws声明越写越长,代码可读性和维护成本都变差。Spring把SQLException包装成DataAccessException(继承RuntimeException),就是不再强迫每一层业务代码处理底层数据库异常,让异常自然冒泡到统一的异常处理入口去集中处理。
Q3:自定义一个业务异常,应该继承 Exception 还是 RuntimeException?
核心看"调用方拿到这个异常后,有没有一个合理具体的后续处理动作"。如果有明确的业务分支可以应对(比如库存不足、余额不足这类可以据此给用户提示的场景),继承Exception做成Checked异常,强制调用方处理;如果这个异常本质上代表调用方自身传参错误或者编程bug、调用方唯一的正确做法就是修代码而不是运行时处理,继承RuntimeException做成Unchecked异常。
Q4:自定义异常为什么要提供带 cause 参数的构造方法?
因为在捕获底层异常、重新抛出一个更贴合业务语义的新异常时,如果不把原始异常作为cause传进去,排查问题时就只能看到新抛出的这句提示信息和这次的调用栈,原始异常的根本原因(比如具体是哪种数据库错误)会彻底丢失,给排查问题带来很大困难。规范做法是通过cause把原始异常保留在异常链里。
Q5:为什么不建议用异常来控制正常的业务流程?
因为异常对象创建时会执行fillInStackTrace()填充完整的调用栈信息,这个操作本身有性能开销。如果某种情况(比如"查询数据不存在")在业务里是完全正常、经常发生的分支,而不是意外情况,用抛异常的方式表达既不符合异常"意外情况"的语义定位,又会带来不必要的性能损耗,这种场景更适合用返回值(比如Optional或者约定好含义的null)来表达。
下一篇预告
Day28 讲 try-catch-finally 执行顺序里的一个经典坑------当 try 和 finally 里都有 return 语句时,最终返回的到底是哪个值,以及为什么不建议在 finally 里写 return。