自定义异常该继承 Exception 还是 RuntimeException,就看这一个问题

「Java 进阶之路」系列 Day27

写在前面

模块三集合框架收官了,从这篇开始进入模块四"异常与IO"。Checked异常和Unchecked异常的区别背起来不难,但真到自己设计一个业务异常时,很多人还是拿不准该继承哪个------这篇把这个判断标准讲清楚,顺带聊聊为什么Checked异常在实际项目里争议不小。


一、是什么:异常体系的分类

flowchart TB A[Throwable] --> B[Error 严重错误<br/>不建议catch] A --> C[Exception] C --> D[RuntimeException<br/>及其子类 Unchecked 非受检异常] C --> E[其他Exception子类<br/>Checked 受检异常]

Error代表JVM层面的严重问题(比如OutOfMemoryError),一般不建议捕获处理,程序基本没有恢复的余地;真正需要关心的是Exception这一支,又分两类:

  • Checked异常(受检异常)Exception的直接子类(不包括RuntimeException),比如IOExceptionSQLException编译器强制要求处理 ------要么用try-catch捕获,要么在方法签名上用throws声明往外抛,不处理编译都过不去。
  • Unchecked异常(非受检异常)RuntimeException及其子类,比如NullPointerExceptionIllegalArgumentException编译器不做任何强制要求,可以处理也可以不处理,代码照样能编译通过。

二、为什么要分两类:编译器要不要强制"关注失败情况"

这套设计的初衷是: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。

相关推荐
西峰u15 分钟前
Java多线程初阶完整总结|线程、锁、volatile、等待通知、常见案例
java·开发语言·jvm
风曳丷16 分钟前
10|攻击如何跨越 Context、阶段与时间
后端
tyung17 分钟前
znet 数据编解码:字节流怎么变成消息
后端·网络协议·go
爱喝可乐的中登23 分钟前
SpringBoot 云边协同|智慧地铁 ISCS 改造实战第 10 篇:全网权限与数据隔离重构|线路‑车站‑专业三级 RBAC、边缘数据权限下沉
java·spring boot·重构
落魄实习生36 分钟前
Spring AI Alibaba入门-生态集成
java·人工智能·spring
码视野41 分钟前
基于 Spring Boot + Vue3 的【高校化学实验室安全准入考试与危化品配伍排查系统】设计与实现(含PRD/三端高保真源码/大屏)
前端·人工智能·spring boot·后端·安全·vue3
重生之我是Java开发战士43 分钟前
【Java EE】认识Linux与项目部署
java·linux·java-ee
LXMXHJ1 小时前
springboot项目测试
java·spring boot·后端·测试
程序员雪球1 小时前
本地IDEA打断点debug容器
java·开发语言