异常处理
异常处理是 Java 程序可靠性的基础。它负责把程序运行中的错误从正常业务流程中分离出来,让调用方有机会感知、恢复、记录或终止流程。
新手常把异常当成“报错信息”,高级开发者会把异常看成一种调用契约:方法失败时如何表达失败原因,调用方应该如何处理,日志应该记录到哪里,资源应该如何释放。
# 1. 异常体系
Java 中所有异常和错误都继承自 Throwable。
Throwable
├─ Error
│ ├─ OutOfMemoryError
│ ├─ StackOverflowError
│ └─ ...
│
└─ Exception
├─ IOException
├─ SQLException
├─ ReflectiveOperationException
└─ RuntimeException
├─ NullPointerException
├─ IllegalArgumentException
├─ IllegalStateException
├─ IndexOutOfBoundsException
└─ ...
分类说明:
| 类型 | 编译器是否强制处理 | 典型原因 | 常见处理方式 |
|---|---|---|---|
Error | 否 | JVM 或系统级严重问题 | 通常不捕获,排查系统问题 |
| 受检异常 | 是 | 外部资源失败,如 IO、数据库 | 捕获处理或继续声明抛出 |
| 非受检异常 | 否 | 程序逻辑错误、参数错误、状态错误 | 修正代码或在边界统一处理 |
# 2. Checked Exception 与 Unchecked Exception
受检异常是 Exception 的子类,但不是 RuntimeException 的子类。
public String read(String path) throws IOException {
return Files.readString(Path.of(path));
}
调用方必须处理:
try {
String content = read("a.txt");
} catch (IOException e) {
log.error("读取文件失败", e);
}
非受检异常继承自 RuntimeException:
public void updateAge(int age) {
if (age < 0) {
throw new IllegalArgumentException("年龄不能为负数");
}
}
选择建议:
| 场景 | 建议 |
|---|---|
| 调用方有明确恢复策略 | 可以使用受检异常 |
| 参数非法、状态非法、编程错误 | 使用非受检异常 |
| 业务规则失败 | 常用自定义非受检业务异常 |
| 框架底层异常包装 | 常转换为统一运行时异常 |
很多企业项目倾向于使用运行时业务异常,并在 Web 层统一转换成错误响应。
# 3. 异常传播流程
当方法内部抛出异常时,如果当前方法不处理,异常会沿调用栈向上传播。
public void controller() {
service();
}
public void service() {
repository();
}
public void repository() {
throw new IllegalStateException("数据库状态异常");
}
传播过程:
repository 抛出异常
│ 当前方法未捕获
▼
service 被异常打断
│ 当前方法未捕获
▼
controller 被异常打断
│ 当前方法未捕获
▼
线程默认异常处理器 / 框架异常处理器
异常一旦抛出,当前执行路径会立即中断,后续普通语句不会继续执行。
public void test() {
System.out.println("A");
throw new RuntimeException("error");
// System.out.println("B"); // 不可达
}
# 4. try-catch
基本结构:
try {
int value = 10 / 0;
} catch (ArithmeticException e) {
log.error("计算失败", e);
}
多个 catch:
try {
readFile();
} catch (FileNotFoundException e) {
log.warn("文件不存在", e);
} catch (IOException e) {
log.error("读取失败", e);
}
子类异常必须放在父类异常前面:
try {
readFile();
} catch (IOException e) {
// ...
} catch (FileNotFoundException e) {
// 编译失败,前面已经捕获 IOException
}
多异常捕获:
try {
parse();
} catch (IOException | NumberFormatException e) {
log.error("解析失败", e);
}
catch 的目标不是“让程序不报错”,而是把失败转换为调用方可理解的结果,或者记录足够信息后继续向上抛。
# 5. finally
finally 通常用于释放资源,无论是否发生异常都会执行。
FileInputStream input = null;
try {
input = new FileInputStream("a.txt");
// read
} catch (IOException e) {
log.error("读取失败", e);
} finally {
if (input != null) {
try {
input.close();
} catch (IOException e) {
log.error("关闭失败", e);
}
}
}
执行顺序:
try 正常完成
│
▼
finally
│
▼
继续执行
try 抛出异常
│
▼
匹配 catch
│
▼
finally
│
▼
继续或向上抛出
不要在 finally 中写 return:
public int value() {
try {
return 1;
} finally {
return 2;
}
}
结果是 2,finally 中的 return 会覆盖 try 中的返回值,也可能吞掉异常。
# 6. try-with-resources
Java 7 引入 try-with-resources,用于自动关闭实现了 AutoCloseable 的资源。
try (BufferedReader reader = Files.newBufferedReader(Path.of("a.txt"))) {
String line = reader.readLine();
System.out.println(line);
} catch (IOException e) {
log.error("读取失败", e);
}
等价思路:
打开资源
│
▼
执行 try 块
│
▼
自动 close
│
▼
如有异常,进入 catch
多个资源按声明顺序打开,按相反顺序关闭:
try (
InputStream input = Files.newInputStream(source);
OutputStream output = Files.newOutputStream(target)
) {
input.transferTo(output);
}
关闭顺序:
打开 input
打开 output
使用资源
关闭 output
关闭 input
# 6.1 被抑制异常
如果 try 块抛出异常,关闭资源时也抛出异常,关闭异常会作为 suppressed exception 挂到主异常上。
catch (IOException e) {
for (Throwable suppressed : e.getSuppressed()) {
log.warn("关闭资源时发生异常", suppressed);
}
}
这比传统 finally 更安全,因为传统写法中 close 异常容易覆盖主异常。
# 7. throw 与 throws
throw 用于真正抛出一个异常对象:
throw new IllegalArgumentException("参数错误");
throws 用于方法签名声明可能抛出的异常:
public void read() throws IOException {
// ...
}
区别:
| 关键字 | 位置 | 作用 |
|---|---|---|
throw | 方法体内 | 抛出异常对象 |
throws | 方法声明上 | 声明异常可能向外传播 |
示例:
public void save(User user) {
if (user == null) {
throw new IllegalArgumentException("user 不能为空");
}
}
# 8. 自定义异常
自定义异常可以让错误语义更清晰。
public class BusinessException extends RuntimeException {
private final String code;
public BusinessException(String code, String message) {
super(message);
this.code = code;
}
public String getCode() {
return code;
}
}
使用:
if (stock < quantity) {
throw new BusinessException("STOCK_NOT_ENOUGH", "库存不足");
}
设计建议:
| 建议 | 原因 |
|---|---|
| 异常名表达语义 | 比 RuntimeException 更容易定位问题 |
| 保留 cause | 不丢失底层异常堆栈 |
| 不要过度细分 | 太多异常类型会增加维护成本 |
| 业务异常带错误码 | 便于前后端统一处理 |
包装异常时保留原因:
try {
remoteCall();
} catch (IOException e) {
throw new BusinessException("REMOTE_FAILED", "远程调用失败", e);
}
需要给自定义异常增加对应构造器:
public BusinessException(String code, String message, Throwable cause) {
super(message, cause);
this.code = code;
}
# 9. 日志与异常
异常日志要包含上下文,但不要重复打印。
推荐:
log.error("创建订单失败, userId={}, skuId={}", userId, skuId, e);
不推荐:
log.error(e.getMessage());
原因是只打印 message 会丢失堆栈。
也不推荐每一层都打印再抛出:
catch (Exception e) {
log.error("失败", e);
throw e;
}
如果每层都这样做,同一个异常会被打印多次,干扰排查。
常见分层策略:
Repository 层:捕获底层异常,必要时转换
Service 层:补充业务语义,决定是否回滚
Controller 层:统一异常处理,输出响应
全局异常处理器:记录未处理异常
# 10. 异常与事务
在 Spring 这类框架中,事务回滚通常和异常类型有关。默认情况下,运行时异常和错误会触发回滚,受检异常是否回滚需要额外配置。
常见问题:
@Transactional
public void createOrder() {
try {
saveOrder();
deductStock();
} catch (Exception e) {
log.error("创建订单失败", e);
}
}
这里异常被吞掉,事务框架可能认为方法正常结束,从而提交事务。
更合理的写法:
@Transactional
public void createOrder() {
try {
saveOrder();
deductStock();
} catch (Exception e) {
log.error("创建订单失败", e);
throw e;
}
}
或者抛出业务异常,让统一异常处理器转换响应。
# 11. 异常性能
创建异常对象通常需要填充堆栈信息,这比普通对象创建更昂贵。
new Exception()
│
▼
采集当前调用栈
│
▼
保存 stack trace
因此不要用异常控制正常流程:
try {
int value = Integer.parseInt(text);
} catch (NumberFormatException e) {
// 如果这是高频正常分支,成本会偏高
}
如果输入非法是高频可预期情况,优先在解析前做校验,或者使用返回结果表达失败。
# 12. 常见异常场景
| 异常 | 常见原因 | 处理思路 |
|---|---|---|
NullPointerException | 空引用调用方法或访问字段 | 明确 null 语义,入参校验 |
IllegalArgumentException | 方法参数不合法 | 调用方修正参数 |
IllegalStateException | 当前对象状态不允许操作 | 检查状态流转 |
IndexOutOfBoundsException | 下标越界 | 检查边界 |
ClassCastException | 类型强转错误 | 使用 instanceof 判断 |
ConcurrentModificationException | 遍历集合时结构性修改 | 使用迭代器删除或并发容器 |
IOException | 文件、网络读写失败 | 重试、降级、提示用户 |
# 13. 异常处理原则
# 13.1 能处理才捕获
如果当前层无法恢复,也无法补充有价值的信息,就不要为了“看起来处理了”而捕获。
# 13.2 不要吞异常
不推荐:
try {
doSomething();
} catch (Exception e) {
}
这会让问题消失在日志之外。
# 13.3 不要直接捕获 Throwable
Throwable 包含 Error,随意捕获可能掩盖严重系统问题。
# 13.4 异常信息要可行动
异常 message 应说明具体失败原因:
throw new IllegalArgumentException("userId 不能为空");
比下面更有价值:
throw new IllegalArgumentException("参数错误");
# 13.5 不暴露敏感信息
对外响应不要直接返回完整异常堆栈、SQL、密钥、用户隐私等信息。内部日志可以保留必要上下文,对外响应应转换为安全的错误码和提示。
# Tips 快问快答
Q:Exception 和 Error 有什么区别?
A:Exception 通常表示程序可处理或可预期的异常;Error 通常表示 JVM 或系统级严重问题,一般不捕获。
Q:受检异常和非受检异常怎么区分?
A:继承 RuntimeException 的是非受检异常;其他 Exception 子类通常是受检异常。
Q:什么时候用自定义异常? A:当通用异常无法表达业务语义,或者需要携带错误码、领域信息时使用。
Q:为什么不要捕获后什么都不做? A:异常被吞掉后,程序可能继续在错误状态下运行,排查时也没有日志线索。
Q:finally 一定会执行吗?
A:通常会,但 JVM 直接退出、进程被杀、机器断电等极端情况不会保证执行。
Q:为什么不建议在 finally 里 return?
A:它会覆盖 try 或 catch 的返回值,也可能吞掉原本要抛出的异常。
Q:try-with-resources 关闭资源的顺序是什么? A:多个资源按声明顺序打开,按相反顺序关闭。
Q:异常日志应该在哪一层打印? A:通常在边界层或统一异常处理器打印。中间层如果只是继续抛出,避免重复打印。
Q:Spring 事务里捕获异常会影响回滚吗? A:会。异常被捕获且不再抛出时,事务框架可能认为方法正常结束,从而提交事务。
Q:异常可以用于正常业务分支吗? A:不建议。异常创建和堆栈采集成本较高,也会让正常逻辑难读。