自定义异常与异常处理策略 | JavaSE
自定义异常与异常处理策略
一、学习目标
完成本章后,你应该能够:
- 能够解释为什么业务系统需要自定义异常。
- 能够创建继承
Exception的自定义受检异常。 - 能够创建继承
RuntimeException的自定义运行时异常。 - 能够为自定义异常设计无参、message、cause 等构造器。
- 能够使用
throw new XxxException(...)主动抛出业务异常。 - 能够根据业务场景选择 Checked Exception 或 RuntimeException。
- 能够理解异常传播、异常恢复、异常转换三种基本处理思想。
- 能够设计“底层发现问题 → 上层集中处理”的异常架构。
- 能够识别吞异常、重复记录、异常类型过宽、丢失 cause 等常见异常设计问题。
二、核心知识
2.1 为什么需要自定义异常
Java 已经提供大量异常:
NullPointerException
IllegalArgumentException
IOException
SQLException
ParseException
...
但是 Java 不可能提前知道世界上所有业务规则。
例如一个学生系统规定:
年龄必须在 0~150
Java 只知道:
int age = -100;
是一个完全合法的 int。
它不知道:
-100 岁
违反了你的业务规则。
再例如:
用户名已经存在
余额不足
订单已经关闭
验证码已经过期
库存不足
账号被冻结
这些都不是 Java 语言自身能够定义的问题。
因此企业项目需要:
自定义异常(Custom Exception)
用异常类型表达:
当前系统自己的业务失败语义。
2.2 为什么不用 Exception 统一表示所有业务错误
当然可以写:
throw new Exception("年龄非法");
然后:
throw new Exception("库存不足");
再:
throw new Exception("账号冻结");
但是调用者最后看到的类型全部是:
Exception
只能依赖 message 猜:
到底发生了什么?
如果定义:
AgeIllegalException
StockNotEnoughException
AccountFrozenException
调用者直接从:
异常类型
就能理解问题类别。
因此:
异常类型本身也是程序语义的一部分。
2.3 自定义受检异常
原课程给出的第一类方式是:
继承 Exception
例如:
public class AgeIllegalException
extends Exception {
public AgeIllegalException() {
}
public AgeIllegalException(
String message
) {
super(message);
}
}
继承关系:
Throwable
└── Exception
└── AgeIllegalException
由于它没有继承:
RuntimeException
因此属于:
Checked Exception
也就是受检异常。
2.4 抛出自定义受检异常
例如:
public static void checkAge(int age)
throws AgeIllegalException {
if (age < 0 || age > 150) {
throw new AgeIllegalException(
"年龄必须在 0~150 之间"
);
}
}
这里同时出现:
throw
+
throws
它们职责不同:
throw new AgeIllegalException(...)
表示:
真的创建并抛出异常对象
而:
throws AgeIllegalException
表示:
声明该 Checked Exception
可能传播给调用者
2.5 调用受检业务异常方法
调用:
checkAge(age);
因为:
AgeIllegalException
属于 Checked Exception,
调用者需要:
捕获
或者:
继续声明传播
例如:
try {
checkAge(-10);
} catch (AgeIllegalException e) {
System.out.println(e.getMessage());
}
2.6 自定义运行时异常
另一种方式是继承:
RuntimeException
例如:
public class AgeIllegalRuntimeException
extends RuntimeException {
public AgeIllegalRuntimeException() {
}
public AgeIllegalRuntimeException(
String message
) {
super(message);
}
}
继承关系:
Throwable
└── Exception
└── RuntimeException
└── AgeIllegalRuntimeException
因此属于:
Unchecked Exception
调用者不会被编译器强制要求:
try-catch
或者:
throws
2.7 使用自定义运行时异常
public static void checkAge(int age) {
if (age < 0 || age > 150) {
throw new AgeIllegalRuntimeException(
"年龄必须在 0~150 之间"
);
}
}
调用:
checkAge(-10);
即使没有:
try-catch
也可以通过编译。
异常真正发生时才进入异常传播流程。
2.8 两种自定义异常的核心区别
| 对比项 | 自定义受检异常 | 自定义运行时异常 |
| --------------------------- | -------------------------- | ---------------------------- |
| 继承 | Exception | RuntimeException |
| 类型 | Checked | Unchecked |
| 编译器是否强制检查 | 是 | 否 |
| 调用者是否必须显式处理/声明 | 是 | 否 |
| 适合场景 | 希望调用者明确面对失败契约 | 常见业务校验、非法状态等 |
| 示例 | AgeIllegalException | AgeIllegalRuntimeException |
不要简单理解成:
Checked = 高级
Runtime = 低级
它们表达的是不同 API 契约设计。
2.9 为什么异常类通常提供 message 构造器
例如:
public AgeIllegalException(
String message
) {
super(message);
}
调用:
throw new AgeIllegalException(
"年龄不能小于 0,当前值:" + age
);
message 能保存:
具体失败信息
之后调用:
e.getMessage()
即可取得。
因此异常通常至少需要考虑:
异常是什么类型
+
发生问题的详细信息是什么
2.10 为什么调用 super(message)
父类:
Throwable
已经提供异常消息管理能力。
我们不需要重新自己写:
private String message;
而是:
super(message);
将详细信息交给父类异常体系管理。
于是可以直接使用:
getMessage()
2.11 cause:异常的真正原因
实际项目经常出现:
底层异常
↓
转换为高层异常
例如:
IOException
↓
ConfigurationLoadException
高层代码希望对外表达:
配置加载失败
但是又不能丢掉真正原因:
底层文件读取失败
因此 Throwable 支持:
cause
也就是异常原因。
自定义异常可以进一步提供:
public class ConfigException
extends RuntimeException {
public ConfigException(String message) {
super(message);
}
public ConfigException(
String message,
Throwable cause
) {
super(message, cause);
}
}
然后:
try {
loadFile();
} catch (IOException e) {
throw new ConfigException(
"配置文件加载失败",
e
);
}
形成:
ConfigException
↓ caused by
IOException
这样既保留:
业务层语义
也保留:
底层根因
三、使用方法
3.1 完整定义一个业务运行时异常
例如注册系统:
public class UsernameIllegalException
extends RuntimeException {
public UsernameIllegalException() {
}
public UsernameIllegalException(
String message
) {
super(message);
}
public UsernameIllegalException(
String message,
Throwable cause
) {
super(message, cause);
}
}
3.2 在业务校验中使用
public class UserService {
public static void register(
String username
) {
if (username == null ||
username.isBlank()) {
throw new UsernameIllegalException(
"用户名不能为空"
);
}
if (username.length() < 4 ||
username.length() > 20) {
throw new UsernameIllegalException(
"用户名长度必须为 4~20"
);
}
System.out.println(
"注册成功:" + username
);
}
}
调用:
UserService.register("abc");
业务层主动产生:
UsernameIllegalException
而不是一个模糊的:
Exception
3.3 最外层集中处理
public class App {
public static void main(String[] args) {
try {
UserService.register("abc");
} catch (
UsernameIllegalException e
) {
System.out.println(
"注册失败:" +
e.getMessage()
);
}
}
}
形成:
业务层
发现规则不满足
↓
抛业务异常
↓
异常向上传播
↓
边界层
统一处理
↓
输出用户可以理解的信息
3.4 异常传播
所谓异常传播:
底层发生异常
↓
当前层不处理
↓
异常继续向调用者传递
例如:
Repository
↓
Service
↓
Controller
只要中间层没有合适处理器,
异常就继续向上。
3.5 异常恢复
异常恢复(Exception Recovery)表示:
发生问题以后,程序确实拥有办法恢复到可继续工作的状态。
例如用户输入:
abc
程序需要整数。
可以:
捕获 NumberFormatException
↓
提示重新输入
↓
再次读取
这是真正的恢复。
再比如:
主服务器连接失败
↓
切换备用服务器
也属于恢复策略。
3.6 异常转换
异常转换(Exception Translation)表示:
底层异常
↓
转换成符合当前抽象层语义的异常
例如:
IOException
↓
UserDataLoadException
或者后续 Java Web 项目中:
数据库访问异常
↓
业务层异常
关键不是简单换一个类名,
而是:
让上层看到符合它当前抽象层的失败语义。
3.7 转换异常时保留 cause
不推荐:
catch (IOException e) {
throw new ConfigException(
"读取配置失败"
);
}
这样可能丢失:
真正底层原因
更合理:
catch (IOException e) {
throw new ConfigException(
"读取配置失败",
e
);
}
这样异常链中仍然能够看到:
ConfigException
Caused by: IOException
对 Debug 非常重要。
四、原理与进阶
4.1 自定义异常本质上仍然只是一个类
例如:
public class BusinessException
extends RuntimeException {
}
它仍然遵循之前学过的:
类
继承
构造器
super
多态
所以异常模块并不是一套完全独立的新语言系统。
它是在:
Java 面向对象体系
之上增加:
throw
传播
捕获
机制。
4.2 为什么业务系统常倾向运行时业务异常
在很多现代分层业务系统中,大量业务异常会设计为:
RuntimeException
例如:
BusinessException
├── UserNotFoundException
├── OrderClosedException
└── InsufficientBalanceException
好处是:
中间业务层不需要机械地
throws / catch 大量异常
异常可以自然传播到:
统一异常处理边界
但是这只是常见工程设计之一。
不能因此得出:
自定义 Checked Exception 没有任何价值。
如果 API 希望强制调用者明确面对某个可恢复失败条件,受检异常仍然有其表达价值。
4.3 异常应该表达“异常情况”,不是代替所有 if
错误思路:
try {
if (...) {
throw ...
}
} catch (...) {
// 所有正常分支都靠异常控制
}
例如:
用户输入菜单选项 1 / 2 / 3
这是正常控制流,
适合:
if
switch
而:
订单已经关闭,却继续执行付款
可能属于异常业务状态。
因此:
正常分支
→ if / switch / return 等正常控制流
异常失败
→ Exception
不要把异常机制变成普通流程控制工具。
4.4 异常粒度应该合适
过粗:
BusinessException
什么问题都扔一个类型,
可能缺乏语义。
过细:
UsernameLengthIsThreeException
UsernameLengthIsTwoException
UsernameContainsSpaceAtIndex2Exception
...
又会造成类型爆炸。
合理粒度应该围绕:
调用者是否需要区分处理
设计。
这是异常建模的核心判断之一。
五、实践应用
5.1 注册业务
假设规则:
用户名不能为空
用户名长度 4~20
年龄 0~150
可以设计:
UserValidationException
统一表达:
用户注册参数不符合业务要求
如果未来不同校验需要不同处理,
再拆分为:
UsernameIllegalException
AgeIllegalException
而不是一开始无限制造异常类。
5.2 订单系统
可能出现:
OrderNotFoundException
OrderClosedException
StockNotEnoughException
InsufficientBalanceException
这些异常比:
throw new Exception("error");
拥有明显更强的业务语义。
5.3 分层系统异常处理
可以建立:
Repository
↓
负责发现基础设施失败
Service
↓
必要时转换为业务语义
Controller / UI
↓
统一捕获
记录日志
转换响应
提示用户
注意:
“最外层统一处理”不是让所有层都什么也不做。
中间层如果拥有:
补充业务语义
异常转换
恢复能力
仍然应该承担对应职责。
5.4 与未来 Spring 全局异常处理的关系
以后进入 Spring Boot 时经常会看到:
Service
↓
throw BusinessException
↓
Controller
↓
全局异常处理器
↓
统一 JSON 响应
所以当前 JavaSE 异常体系:
不是学完就结束
而是 Java Web:
统一异常处理
业务异常体系
接口错误响应
日志追踪
的基础。
六、常见问题
6.1 为什么 Java 已经有 IllegalArgumentException,还要自定义异常?
如果:
IllegalArgumentException
已经足够准确,
完全可以直接使用。
只有当系统需要表达:
自己的业务语义
或者调用者需要区分:
不同失败类别
时,自定义异常才更有价值。
6.2 自定义异常必须继承 Exception 吗?
不是。
常见选择:
extends Exception
得到受检异常。
或者:
extends RuntimeException
得到运行时异常。
6.3 为什么自定义异常通常有无参和 message 构造器?
为了支持:
只表达异常类型
以及:
异常类型 + 详细错误信息
两种创建方式。
如果需要异常转换,通常还值得提供:
cause
message + cause
构造器。
6.4 自定义运行时异常是不是完全不用处理?
不是。
“不要求编译期强制捕获”
不代表:
程序设计上不需要处理
最终通常仍然需要在系统边界:
记录
转换
响应
6.5 catch 后重新 throw 是不是错误?
不一定。
如果当前层只是:
补充上下文
转换异常类型
重新抛出完全可能是合理设计。
关键是不要:
毫无意义地捕获
再原样抛出
造成重复代码。
6.6 为什么异常转换要保留 cause?
因为:
高层异常
告诉你:
业务上发生了什么
而:
cause
告诉你:
底层为什么发生
两者共同构成完整诊断信息。
6.7 是不是越多自定义异常越专业?
不是。
异常类数量应该服务于:
业务语义
调用者处理能力
系统维护性
不是为了“看起来架构复杂”。
6.8 什么叫吞异常?
例如:
try {
doSomething();
} catch (Exception e) {
}
问题发生以后:
不记录
不恢复
不传播
不响应
导致系统失去失败信息。
这通常是严重的异常处理反模式。
6.9 是否应该每一层都记录一次异常日志?
通常不应该机械重复记录。
如果:
Repository 记录一次
Service 又记录一次
Controller 再记录一次
同一个异常可能生成大量重复日志。
通常应明确:
在哪一层负责最终记录
而中间层重点承担:
补充语义
转换
传播
七、练习与验收
7.1 知识问答
- 为什么业务系统需要自定义异常?
- 自定义受检异常通常继承哪个类?
- 自定义运行时异常通常继承哪个类?
- 两类自定义异常的编译期约束有什么区别?
- 为什么异常类通常需要 message 构造器?
super(message)有什么作用?- 什么是异常传播?
- 什么是异常恢复?
- 什么是异常转换?
- 什么是异常链?
- cause 有什么作用?
- 为什么异常转换时不应该轻易丢弃底层异常?
- 为什么正常业务分支不应该全部依赖异常实现?
- 为什么自定义异常不是越多越好?
- 什么叫吞异常?
- 什么情况下当前层应该捕获异常?
- 什么情况下当前层应该继续传播异常?
- 为什么现代分层业务系统中常见 RuntimeException 型业务异常?
7.2 代码阅读
阅读:
public class AgeIllegalException
extends Exception {
public AgeIllegalException() {
}
public AgeIllegalException(
String message
) {
super(message);
}
}
回答:
- 该异常属于 Checked 还是 Unchecked?
- 为什么?
super(message)把信息交给谁?- 调用者使用该异常时会受到什么编译期约束?
阅读:
public static void checkAge(int age) {
if (age < 0 || age > 150) {
throw new AgeIllegalRuntimeException(
"年龄非法:" + age
);
}
}
回答:
- 为什么方法声明上不一定需要
throws? throw创建的是什么对象?- 异常发生后当前方法怎样结束?
- 调用者是否可以主动捕获它?
7.3 手写代码
任务一:AgeIllegalException
定义:
AgeIllegalException
要求:
- 继承
Exception。 - 无参构造器。
- message 构造器。
- 年龄非法时主动抛出。
- 调用者正确处理编译器要求。
任务二:AgeIllegalRuntimeException
重新实现:
AgeIllegalRuntimeException
要求:
- 继承
RuntimeException。 - 比较调用代码与上一题有什么不同。
- 说明为什么编译器要求发生变化。
任务三:注册异常
设计:
register(String username)
规则:
用户名不能为空
长度必须为 4~20
设计一个合理的自定义异常并使用。
任务四:异常转换
设计:
LowLevelException
↓
ServiceException
Service 捕获底层异常以后:
- 转换为高层异常。
- 保存原异常 cause。
- 在最外层查看完整异常链。
7.4 Debug
下面代码存在多个异常设计问题:
public static void register(
String username
) throws Exception {
try {
if (username == null) {
throw new Exception("error");
}
} catch (Exception e) {
}
}
要求找出:
- 异常类型是否过于宽泛?
- message 是否缺乏业务语义?
- catch 是否吞掉了异常?
throws Exception是否还有实际意义?- 应该如何重构?
继续分析:
try {
repository.load();
} catch (IOException e) {
throw new UserLoadException(
"用户加载失败"
);
}
回答:
- 高层业务语义是否得到了增强?
- 哪项重要调试信息可能丢失?
- 应该如何修改异常构造器和抛出代码?
7.5 综合训练
设计一个用户注册异常体系。
业务规则:
用户名不能为空
用户名长度 4~20
年龄必须 0~150
账号不能重复
结构:
App
↓
UserService
↓
UserValidator
要求:
- 自主决定定义一个统一异常还是多个异常。
- 说明你的异常粒度设计理由。
- 自主选择继承
Exception或RuntimeException。 - Validator 负责发现非法状态并抛异常。
- Service 不吞异常。
- App 统一处理并输出适合用户阅读的信息。
- 画出正常执行流。
- 画出异常传播流。
- 说明哪些异常适合恢复,哪些只适合终止当前业务。
- 不允许使用空
catch。
7.6 本章验收
- [ ] 能闭卷定义一个继承 Exception 的自定义异常。
- [ ] 能闭卷定义一个继承 RuntimeException 的自定义异常。
- [ ] 能解释两者编译期约束差异。
- [ ] 能正确编写 message 构造器。
- [ ] 能理解并使用 cause。
- [ ] 能独立使用
throw new 自定义异常(...)。 - [ ] 能解释异常传播、恢复、转换。
- [ ] 能设计三层系统异常传播方案。
- [ ] 能发现吞异常和异常类型过宽问题。
- [ ] 能解释为什么业务异常不能简单全部写成
Exception。 - [ ] 能为一个小型业务设计合理的异常粒度。