自定义异常与异常处理策略 | 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 知识问答

  1. 为什么业务系统需要自定义异常?
  2. 自定义受检异常通常继承哪个类?
  3. 自定义运行时异常通常继承哪个类?
  4. 两类自定义异常的编译期约束有什么区别?
  5. 为什么异常类通常需要 message 构造器?
  6. super(message) 有什么作用?
  7. 什么是异常传播?
  8. 什么是异常恢复?
  9. 什么是异常转换?
  10. 什么是异常链?
  11. cause 有什么作用?
  12. 为什么异常转换时不应该轻易丢弃底层异常?
  13. 为什么正常业务分支不应该全部依赖异常实现?
  14. 为什么自定义异常不是越多越好?
  15. 什么叫吞异常?
  16. 什么情况下当前层应该捕获异常?
  17. 什么情况下当前层应该继续传播异常?
  18. 为什么现代分层业务系统中常见 RuntimeException 型业务异常?

7.2 代码阅读

阅读:

public class AgeIllegalException
        extends Exception {

    public AgeIllegalException() {
    }

    public AgeIllegalException(
            String message
    ) {
        super(message);
    }
}

回答:

  1. 该异常属于 Checked 还是 Unchecked?
  2. 为什么?
  3. super(message) 把信息交给谁?
  4. 调用者使用该异常时会受到什么编译期约束?

阅读:

public static void checkAge(int age) {
    if (age < 0 || age > 150) {
        throw new AgeIllegalRuntimeException(
                "年龄非法:" + age
        );
    }
}

回答:

  1. 为什么方法声明上不一定需要 throws
  2. throw 创建的是什么对象?
  3. 异常发生后当前方法怎样结束?
  4. 调用者是否可以主动捕获它?

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) {
    }
}

要求找出:

  1. 异常类型是否过于宽泛?
  2. message 是否缺乏业务语义?
  3. catch 是否吞掉了异常?
  4. throws Exception 是否还有实际意义?
  5. 应该如何重构?

继续分析:

try {
    repository.load();
} catch (IOException e) {
    throw new UserLoadException(
            "用户加载失败"
    );
}

回答:

  1. 高层业务语义是否得到了增强?
  2. 哪项重要调试信息可能丢失?
  3. 应该如何修改异常构造器和抛出代码?

7.5 综合训练

设计一个用户注册异常体系。

业务规则:

用户名不能为空
用户名长度 4~20
年龄必须 0~150
账号不能重复

结构:

App
 ↓
UserService
 ↓
UserValidator

要求:

  1. 自主决定定义一个统一异常还是多个异常。
  2. 说明你的异常粒度设计理由。
  3. 自主选择继承 ExceptionRuntimeException
  4. Validator 负责发现非法状态并抛异常。
  5. Service 不吞异常。
  6. App 统一处理并输出适合用户阅读的信息。
  7. 画出正常执行流。
  8. 画出异常传播流。
  9. 说明哪些异常适合恢复,哪些只适合终止当前业务。
  10. 不允许使用空 catch

7.6 本章验收

  • [ ] 能闭卷定义一个继承 Exception 的自定义异常。
  • [ ] 能闭卷定义一个继承 RuntimeException 的自定义异常。
  • [ ] 能解释两者编译期约束差异。
  • [ ] 能正确编写 message 构造器。
  • [ ] 能理解并使用 cause。
  • [ ] 能独立使用 throw new 自定义异常(...)
  • [ ] 能解释异常传播、恢复、转换。
  • [ ] 能设计三层系统异常传播方案。
  • [ ] 能发现吞异常和异常类型过宽问题。
  • [ ] 能解释为什么业务异常不能简单全部写成 Exception
  • [ ] 能为一个小型业务设计合理的异常粒度。