自定义异常
本教程共 100 篇 · 第 69 篇 · 更新于 2026-08-05 · 约 20 分钟阅读
69. 自定义异常
本节目标:知道什么时候该自己造异常类、该继承哪个父类,学完能写出构造方法完整、能携带业务信息的自定义异常。
先问一句:真的需要吗
JDK 自带的异常类型已经覆盖了大部分场景。造新类之前,先在这张表里找找有没有现成的。
| 场景 | 用这个 |
|---|---|
| 参数不合法 | IllegalArgumentException |
| 对象状态不对,不能执行该操作 | IllegalStateException |
| 字符串转数字失败 | NumberFormatException |
| 传了不该为 null 的 null | NullPointerException |
| 索引越界 | IndexOutOfBoundsException |
| 方法不支持这个操作 | UnsupportedOperationException |
| IO 出错 | IOException |
能用现成的就用现成的。每多一个自定义异常类,团队就多一份认知成本。
那什么时候该自定义?两个信号。
第一,需要区分处理。 调用方要根据错误类型走不同分支。用户不存在跳注册页,密码错误提示重试——这两种情况用同一个 IllegalArgumentException 就分不开了。
第二,需要携带业务数据。 除了错误消息,还得带上订单号、错误码、剩余重试次数这类字段。
继承 Exception 还是 RuntimeException
这是自定义异常的第一个决策点,直接决定了调用方是否被编译器强制处理。
继承 Exception → 受检异常 → 调用方必须 try...catch 或 throws。
继承 RuntimeException → 非受检异常 → 调用方爱管不管。
// 受检异常:调用方被编译器逼着处理
public class InsufficientBalanceException extends Exception {
public InsufficientBalanceException(String message) {
super(message);
}
}
// 非受检异常:调用方自由决定
public class UserNotFoundException extends RuntimeException {
public UserNotFoundException(String message) {
super(message);
}
}
```bash
怎么选?看这个错误**调用方有没有能力恢复**。
余额不足这种,调用方可以提示用户充值、可以走透支流程,有恢复余地,适合受检异常。
用户 ID 在数据库里查不到,多半是上游传错了或者数据有问题,调用方当场也修不好,适合非受检异常。
> [!NOTE]
> 实际项目里,**继承 `RuntimeException` 是主流做法**。Spring、MyBatis 这些框架的异常几乎全是非受检的。原因是受检异常会污染整条调用链——底层加一个 `throws`,上面十几层方法全得跟着改签名,或者被迫写一堆无意义的 `try...catch`。
## 四个标准构造方法
自定义异常至少要提供构造方法,否则用起来处处别扭。JDK 的异常类都提供四个,照抄就行。
```java
public class BusinessException extends RuntimeException {
// 1. 无参:什么信息都不带
public BusinessException() {
super();
}
// 2. 只带消息:最常用
public BusinessException(String message) {
super(message);
}
// 3. 消息 + 原因:包装底层异常时用
public BusinessException(String message, Throwable cause) {
super(message, cause);
}
// 4. 只带原因:消息自动取 cause 的字符串
public BusinessException(Throwable cause) {
super(cause);
}
}
这四个方法体全是 super(...),一行代码都不用自己写。IDE 里在类名上按快捷键就能一键生成。
第三个构造方法最重要。它保存了原始异常,让调用栈能一路追溯到问题源头。第 70 章会专门讲这条链。
完整用例
把异常类和使用它的代码放在一起看:
// 文件 1:InsufficientBalanceException.java
public class InsufficientBalanceException extends RuntimeException {
public InsufficientBalanceException(String message) {
super(message);
}
public InsufficientBalanceException(String message, Throwable cause) {
super(message, cause);
}
}
```bash
```java
// 文件 2:Account.java
public class Account {
private final String id;
private double balance;
public Account(String id, double balance) {
this.id = id;
this.balance = balance;
}
public void withdraw(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("取款金额必须为正数:" + amount);
}
if (amount > balance) {
throw new InsufficientBalanceException(
"账户 " + id + " 余额不足,当前 " + balance + ",需要 " + amount);
}
balance -= amount;
System.out.println("取款成功,余额剩余 " + balance);
}
public static void main(String[] args) {
Account acc = new Account("A001", 100.0);
try {
acc.withdraw(500.0);
} catch (InsufficientBalanceException e) {
System.out.println("业务失败:" + e.getMessage());
}
}
}
输出:
业务失败:账户 A001 余额不足,当前 100.0,需要 500.0
```bash
编译运行:
```bash
javac InsufficientBalanceException.java Account.java
java Account
给异常加业务字段
只有一句错误消息往往不够。调用方想拿到结构化的数据,比如错误码,好据此决定返回什么 HTTP 状态。
public class ApiException extends RuntimeException {
private final int code; // 业务错误码
private final String detail; // 附加说明
public ApiException(int code, String message) {
this(code, message, null);
}
public ApiException(int code, String message, String detail) {
super(message);
this.code = code;
this.detail = detail;
}
public int getCode() {
return code;
}
public String getDetail() {
return detail;
}
}
```java
用起来就能精准分流:
```java
public class ApiDemo {
public static void main(String[] args) {
try {
callApi(404);
} catch (ApiException e) {
System.out.println("错误码 " + e.getCode() + ":" + e.getMessage());
if (e.getCode() == 404) {
System.out.println("走资源不存在的处理分支");
}
}
}
static void callApi(int status) {
if (status != 200) {
throw new ApiException(status, "接口调用失败", "请检查请求路径");
}
}
}
Tip字段建议加
final,异常对象创建后就不该被改。异常在多线程环境下可能被多处读取,不可变最安全。
搭建项目异常体系
项目一大,异常类会越来越多。散着放,调用方就得写一长串 catch。
常见做法是定义一个根异常,所有业务异常都从它派生。
// 根异常
public class BaseException extends RuntimeException {
public BaseException() {
super();
}
public BaseException(String message) {
super(message);
}
public BaseException(String message, Throwable cause) {
super(message, cause);
}
public BaseException(Throwable cause) {
super(cause);
}
}
```java
往下按业务模块分层:
```java
public class UserNotFoundException extends BaseException {
public UserNotFoundException(String userId) {
super("用户不存在:" + userId);
}
}
public class LoginFailedException extends BaseException {
public LoginFailedException(String message) {
super(message);
}
}
public class OrderExpiredException extends BaseException {
public OrderExpiredException(String orderId) {
super("订单已过期:" + orderId);
}
}
体系长这样:
RuntimeException
└─ BaseException
├─ UserNotFoundException
├─ LoginFailedException
└─ OrderExpiredException
```java
好处立刻显现:**上层可以一把兜住所有业务异常**。
```java
try {
processOrder();
} catch (BaseException e) {
// 所有业务异常统一处理,转成友好提示返回前端
System.out.println("业务错误:" + e.getMessage());
}
也可以在需要时精确捕获某一种:
try {
login(user, pwd);
} catch (UserNotFoundException e) {
System.out.println("跳转注册页");
} catch (LoginFailedException e) {
System.out.println("提示重新输入密码");
}
```bash
## 几个设计要点
**命名以 Exception 结尾。** `UserNotFoundException` 而不是 `UserNotFoundError` 或 `UserNotFound`。这是全 Java 生态的共识。
**层级别超过三层。** `RuntimeException` → `BaseException` → 具体异常,够用了。再深就成了给自己找麻烦。
**别为每个错误消息造一个类。** 「用户名太短」「用户名太长」「用户名含非法字符」不需要三个异常类,一个 `InvalidUsernameException` 带上不同消息就行。
**类的数量控制在个位数到十几个。** 超过这个量级,说明粒度切得太细了。
> [!WARNING]
> 不要继承 `Error` 来自定义异常。`Error` 是 JVM 级严重故障的专属分支,业务代码占用它会让排查问题的人产生严重误判。
## 一个可以直接跑的完整例子
把前面的知识串起来,写一个能编译运行的最小示例:
```java
public class CustomExceptionDemo {
// 根异常
static class BaseException extends RuntimeException {
BaseException(String message) {
super(message);
}
BaseException(String message, Throwable cause) {
super(message, cause);
}
}
// 业务异常
static class ConfigParseException extends BaseException {
private final String key;
ConfigParseException(String key, String message, Throwable cause) {
super(message, cause);
this.key = key;
}
String getKey() {
return key;
}
}
static int parsePort(String value) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
// 包装成业务异常,同时保留原始异常
throw new ConfigParseException("server.port",
"端口配置格式错误:" + value, e);
}
}
public static void main(String[] args) {
try {
parsePort("八千零八十");
} catch (ConfigParseException e) {
System.out.println("出错的配置项:" + e.getKey());
System.out.println("错误信息:" + e.getMessage());
System.out.println("根本原因:" + e.getCause());
}
}
}
输出:
出错的配置项:server.port
错误信息:端口配置格式错误:八千零八十
根本原因:java.lang.NumberFormatException: For input string: "八千零八十"
```bash
原始异常没丢,业务信息也带上了。
## 用工厂方法收敛创建逻辑
异常类多了以后,构造参数容易写得五花八门。给异常类加静态工厂方法,能让调用点整齐很多。
```java
public class ResourceException extends RuntimeException {
private final String resourceType;
private final String resourceId;
private ResourceException(String resourceType, String resourceId, String message) {
super(message);
this.resourceType = resourceType;
this.resourceId = resourceId;
}
// 工厂方法:语义清晰,消息格式统一
public static ResourceException notFound(String type, String id) {
return new ResourceException(type, id, type + " 不存在,id=" + id);
}
public static ResourceException duplicated(String type, String id) {
return new ResourceException(type, id, type + " 已存在,id=" + id);
}
public String getResourceType() {
return resourceType;
}
public String getResourceId() {
return resourceId;
}
public static void main(String[] args) {
try {
throw ResourceException.notFound("用户", "u001");
} catch (ResourceException e) {
System.out.println(e.getMessage());
System.out.println("资源类型:" + e.getResourceType());
}
}
}
输出:
用户 不存在,id=u001
资源类型:用户
```bash
好处是**消息格式集中在一处**。哪天要改文案,改工厂方法就行,不用满项目搜索。
## 关于序列化警告
自定义异常时,IDE 可能会提示「未定义 serialVersionUID」。
原因是 `Throwable` 实现了 `Serializable` 接口,所有异常类都是可序列化的。编译器建议显式声明一个版本号。
```java
public class MyException extends RuntimeException {
private static final long serialVersionUID = 1L;
public MyException(String message) {
super(message);
}
}
加上这一行警告就消失了。
不加会怎样?只在异常对象需要跨 JVM 传输(比如 RMI 远程调用)的场景才有影响。绝大多数项目里异常不出进程,加不加都行。团队有统一规范就跟着走。
Note如果异常类里加了自定义字段,注意这些字段的类型也应该是可序列化的。带上一个不可序列化的对象(比如数据库连接),真要序列化时会抛
NotSerializableException。
小结
JDK 有现成的就别自己造,只在需要区分处理或携带业务数据时才自定义。
主流选择是继承 RuntimeException,避免受检异常污染整条调用链。
四个构造方法照抄 JDK 的模板,其中 (String message, Throwable cause) 最关键,用于保留原始异常。
项目里建一个 BaseException 当根,业务异常从它派生,上层就能一把兜住所有业务错误。