异常体系与错误处理
1. 这是什么
Java 异常体系用于描述程序执行过程中的异常情况。
它不仅是一种错误通知机制,也是系统可维护性的重要组成部分。
一句话理解:
- 异常不是“程序崩了才会有”的东西
- 它更像一套“错误分类、传递、定位、恢复”的机制
2. 为什么重要
异常处理做得好,问题能快速定位,系统能平稳退化。
异常处理做不好,就会出现这些连锁问题:
- 异常被吞掉,线上只剩“操作失败”
- 原始错误上下文丢失,排查困难
- 资源没释放,连接、文件句柄泄漏
- 业务异常和系统异常混在一起,边界不清
换句话说,异常处理能力直接决定代码的可维护性和系统的可观测性。
3. 先建立直觉:异常到底是在解决什么问题
程序执行中总会遇到不正常情况,例如:
- 文件不存在
- 参数非法
- 网络超时
- 库存不足
- 数据格式错误
这些问题不适合都用 if-else 平铺处理,因为:
- 错误可能发生在很深的调用层级
- 调用方未必知道底层细节
- 你通常还希望把错误原因一路往上传递
异常机制的价值就在这里:
- 让错误可以跨方法层级传播
- 让调用方明确区分“正常路径”和“异常路径”
- 让程序既能终止错误流程,也能保留完整原因链
4. 核心内容
4.1 Throwable 体系怎么理解
Java 异常体系顶层是 Throwable,下面主要分两大类:
Throwable
├─ Error
└─ Exception
├─ RuntimeException
└─ 其他受检异常可以这样理解:
Error:更偏向 JVM 或运行环境级严重问题,业务代码通常不负责处理Exception:程序运行过程中常见的异常情况RuntimeException:运行时异常,通常表示程序逻辑问题或非法状态
4.2 受检异常和非受检异常
受检异常
受检异常通常指:
- 继承自
Exception - 但不继承自
RuntimeException
特点:
- 编译器强制你处理
- 要么
catch - 要么在方法签名里
throws
适合:
- 调用方确实有恢复机会
- 这是一个可预期的外部失败,例如文件、网络、配置、I/O
非受检异常
非受检异常通常指:
RuntimeException及其子类
特点:
- 编译器不强制捕获
- 更适合表示编程错误、非法参数、非法状态、业务规则不满足
常见例子:
NullPointerExceptionIllegalArgumentExceptionIllegalStateException
工程里最常见的经验是:
- 外部依赖失败,更偏向受检异常或明确结果返回
- 编程错误和业务规则违反,更偏向运行时异常
4.3 try-catch-finally 解决了什么
try:放可能出错的代码catch:捕获并处理异常finally:无论是否出错,通常都会执行,适合收尾逻辑
最重要的理解不是语法,而是职责:
try负责主流程catch负责异常分支finally负责清理
4.4 为什么优先使用 try-with-resources
对于文件、连接、流、Socket 这类资源,推荐优先使用:
try (BufferedReader reader = ...) {
// use reader
}原因是:
- 资源会自动关闭
- 比手写
finally更简洁 - 异常链和关闭逻辑处理更规范
只要资源实现了 AutoCloseable,就适合这么用。
4.5 什么时候该抛异常,什么时候该返回结果对象
这也是设计时非常重要的判断。
更适合抛异常的场景:
- 出现了真正异常流程
- 当前方法无法继续完成职责
- 调用方必须知道这次失败
更适合返回结果对象的场景:
- 失败是业务上的常规分支
- 调用方预期会频繁处理这种结果
- 你想避免把普通分支全部异常化
例如:
- “用户不存在”在有些系统里可能是普通查询结果,适合返回空或结果对象
- “数据库连接失败”通常是真异常,适合抛出异常
4.6 自定义业务异常怎么设计
业务异常通常不是简单抛一个 RuntimeException("失败了") 就够了。
更好的做法是明确结构,例如包含:
- 错误码
- 面向日志的技术信息
- 面向前端或调用方的提示信息
- 原始 cause
示意:
public class BizException extends RuntimeException {
private final String code;
public BizException(String code, String message) {
super(message);
this.code = code;
}
public String getCode() {
return code;
}
}4.7 异常包装时为什么一定要保留 cause
很多代码会在底层异常外再包一层更高层语义,这是合理的。
例如:
- 把
IOException包装成“初始化配置失败” - 把底层 SQL 异常包装成“订单保存失败”
但一定不要这样写:
throw new RuntimeException("save failed");更应该这样写:
throw new RuntimeException("save failed", e);因为如果不保留 cause:
- 原始堆栈就断了
- 后续排查只能看到表层信息
4.8 异常处理的分层思路
一个更健康的分层思路通常是:
- 底层:尽量提供准确异常信息
- 业务层:按业务语义做包装或转换
- 接口层:统一转成稳定的错误响应
这意味着:
- 不是每层都要
catch - 也不是每层都该原样往外抛
- 真正重要的是在合适层次做合适转换
5. 学习重点
这一章真正要掌握的,不是会写 try-catch,而是下面几个判断:
- 什么属于真正异常,什么属于业务普通分支
- 什么时候该捕获,什么时候应该继续向上抛
- 包装异常时如何保留完整上下文
- 如何让业务异常结构化、可追踪、可统一处理
- 如何避免资源泄漏
6. 常见问题
6.1 直接捕获 Exception 却不处理
例如:
try {
doWork();
} catch (Exception e) {
}这等于把问题藏起来。
程序看起来“没报错”,其实只是把错误吃掉了。
6.2 吞掉异常后继续执行
如果异常已经意味着当前流程不可靠,再假装没事继续跑,往往会把问题放大。
6.3 只打印异常消息,不打印堆栈
e.getMessage() 远远不够。
定位问题通常需要完整堆栈和 cause 链。
6.4 业务异常命名混乱,没有边界
如果项目里到处都是:
CustomExceptionBaseExceptionServiceException
但没有明确语义层级,后续维护会很痛苦。
6.5 包装异常时丢失原始 cause
这是最可惜的一类错误。
你明明知道上层需要更高层语义,但因为没把 cause 传进去,最后还是让排查难度暴涨。
7. 动手验证
这一节可以直接复制运行,边看边验证。
7.1 准备一个可运行示例
新建文件 ExceptionHandlingDemo.java,内容如下:
import java.io.IOException;
public class ExceptionHandlingDemo {
static class ConfigLoadException extends Exception {
ConfigLoadException(String message) {
super(message);
}
}
static class BizException extends RuntimeException {
private final String code;
BizException(String code, String message) {
super(message);
this.code = code;
}
String getCode() {
return code;
}
}
static class DemoResource implements AutoCloseable {
DemoResource() {
System.out.println("resourceOpened");
}
void read() throws IOException {
throw new IOException("disk read failed");
}
@Override
public void close() {
System.out.println("resourceClosed");
}
}
static String loadConfig(String env) throws ConfigLoadException {
if (!"prod".equals(env)) {
throw new ConfigLoadException("config for " + env + " not found");
}
return "config-loaded";
}
static void createOrder(int amount) {
if (amount <= 0) {
throw new BizException("ORDER_AMOUNT_ILLEGAL", "amount must be positive");
}
}
static String bootApplication(String env) {
try {
return loadConfig(env);
} catch (ConfigLoadException e) {
throw new IllegalStateException("application boot failed", e);
}
}
public static void main(String[] args) {
try {
loadConfig("test");
} catch (ConfigLoadException e) {
System.out.println("checkedException=" + e.getMessage());
}
try {
createOrder(0);
} catch (BizException e) {
System.out.println("runtimeExceptionCode=" + e.getCode());
System.out.println("runtimeExceptionMessage=" + e.getMessage());
}
try (DemoResource resource = new DemoResource()) {
resource.read();
} catch (IOException e) {
System.out.println("tryWithResourcesCaught=" + e.getMessage());
}
boolean finallyExecuted = false;
try {
throw new IllegalArgumentException("bad request");
} catch (IllegalArgumentException e) {
System.out.println("caughtIllegalArgument=" + e.getMessage());
} finally {
finallyExecuted = true;
System.out.println("finallyExecuted=" + finallyExecuted);
}
try {
bootApplication("dev");
} catch (IllegalStateException e) {
System.out.println("wrappedMessage=" + e.getMessage());
System.out.println("wrappedCause=" + e.getCause().getClass().getSimpleName());
}
}
}7.2 编译并运行
javac ExceptionHandlingDemo.java
java ExceptionHandlingDemo如果你在 PowerShell 中操作,也直接执行同样两条命令即可。
7.3 你应该观察到什么
输出不一定逐字完全一致,但应包含这些关键信息:
checkedException=config for test not found
runtimeExceptionCode=ORDER_AMOUNT_ILLEGAL
runtimeExceptionMessage=amount must be positive
resourceOpened
resourceClosed
tryWithResourcesCaught=disk read failed
caughtIllegalArgument=bad request
finallyExecuted=true
wrappedMessage=application boot failed
wrappedCause=ConfigLoadException7.4 每一行在验证什么
checkedException=...:说明受检异常要求调用方显式处理runtimeExceptionCode=ORDER_AMOUNT_ILLEGAL:说明业务异常可以结构化携带错误码resourceOpened、resourceClosed:说明try-with-resources能自动关闭资源tryWithResourcesCaught=...:说明资源使用失败后仍能正常捕获异常finallyExecuted=true:说明finally通常会执行收尾逻辑wrappedCause=ConfigLoadException:说明异常包装后仍保留了原始原因链
7.5 再做两个延伸验证
你可以继续做下面两个实验:
- 把
throw new IllegalStateException("application boot failed", e);改成不带e - 把
try (DemoResource resource = new DemoResource())改成手写try-catch-finally
你可以观察:
- 不保留
cause后,异常链会断 - 手写资源关闭逻辑更容易出错,也更繁琐
8. 练习建议
下面这些练习做完,这一章会更扎实:
- 设计一套简单的业务异常结构,至少包含错误码和消息
- 写一个服务层包装底层异常的例子,并保留原始 cause
- 写一个统一异常处理示例,把不同异常转成不同响应
- 对一个“吞异常”的坏例子进行重构
- 对比“返回结果对象”和“抛业务异常”两种设计在不同场景下的取舍
9. 自测问题
- 受检异常和非受检异常该如何取舍?
- 为什么不能随意吞异常?
- 为什么包装异常时要保留原始 cause?
- 什么时候更适合返回结果对象,而不是直接抛异常?
- 自定义业务异常时应该包含哪些关键信息?
10. 自测核对要点
如果你的回答能覆盖下面这些点,说明这一章基本掌握到位了:
Throwable体系里Error、Exception、RuntimeException的层级不同- 受检异常强调显式处理,运行时异常更偏向编程错误和业务非法状态
try-with-resources是资源释放的首选方式- 异常包装是合理的,但必须保留 cause
- 吞异常、泛捕获、只打 message 都会严重降低可维护性
- 业务异常应该结构化设计,而不是只抛一个笼统字符串