Java 异常处理最佳实践,别再乱用 try-catch
在实际开发中,异常处理往往是被忽视但又极其重要的一环。滥用 try-catch 不仅会让代码变得臃肿难读,还可能掩盖潜在的问题。下面总结一套 Java 异常处理最佳实践,帮助你写出更优雅、更健壮的代码。
1. 理解异常体系,用对类型
Java 的异常分为三类:
- Checked Exception(受检异常):如
IOException、SQLException,必须显式处理(try-catch或throws)。通常表示可恢复的外部错误。 - Unchecked Exception(非受检异常):如
NullPointerException、IllegalArgumentException,是RuntimeException的子类,不强制处理。通常表示程序缺陷,不应捕获后继续执行。 - Error:如
OutOfMemoryError,是 JVM 内部错误,应用层几乎不应该捕获。
✅ 最佳实践:
- 对 Checked Exception 谨慎处理 —— 能恢复就处理,不能恢复就封装后抛出。
- 对 RuntimeException 不要主动捕获,除非你能真正处理(如重试、降级)。
- 永远不要捕获
Error。
// ❌ 错误示例
try {
// 业务代码
} catch (Exception e) { // 吞掉所有异常
// 空处理
}
2. 尽早抛出,晚点捕获(Throw Early, Catch Late)
- Throw Early:参数校验、状态检查应尽早进行,快速失败(fail-fast)。
- Catch Late:只有在有明确处理逻辑(如回滚、重试、转换异常)时才捕获,否则让异常向上抛出。
// ✅ 示例:参数校验提前抛出
public void transfer(Account from, Account to, BigDecimal amount) {
if (from == null || to == null || amount == null) {
throw new IllegalArgumentException("参数不能为空");
}
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("金额必须大于0");
}
// 业务逻辑
}
3. 不要吞掉异常(Never Swallow Exception)
捕获异常后什么都不做,是最危险的行为。
// ❌ 致命错误
try {
// 业务代码
} catch (IOException e) {
// 吞掉异常
}
✅ 正确处理方式:
- 记录日志
- 重新抛出业务异常
- 返回明确的错误码/响应
try {
// 业务代码
} catch (IOException e) {
log.error("文件读取失败,文件路径: {}", path, e);
throw new BusinessException("文件处理失败", e);
}
4. 使用 try-with-resources 管理资源
对于实现了 AutoCloseable 的资源(如流、连接),优先使用 try-with-resources,它会自动关闭资源,避免资源泄漏。
// ✅ 推荐
try (FileInputStream fis = new FileInputStream("file.txt");
BufferedReader br = new BufferedReader(new InputStreamReader(fis))) {
// 读取文件
} catch (IOException e) {
log.error("读取文件失败", e);
}
避免手动在 finally 中关闭,容易遗漏或出错。
5. 异常链与上下文信息
捕获异常后重新抛出时,不要丢失原始异常,使用 initCause 或构造函数传递 cause。
// ❌ 丢失原始异常
try {
// 数据库操作
} catch (SQLException e) {
throw new BusinessException("数据库错误");
}
// ✅ 保留原始异常
try {
// 数据库操作
} catch (SQLException e) {
throw new BusinessException("数据库错误", e);
}
同时,异常信息中应包含足够的上下文(如 ID、参数值),方便定位问题。
throw new BusinessException(String.format("用户[%s]转账失败,金额: %s", userId, amount), e);
6. 区分业务异常与系统异常
- 业务异常:如余额不足、用户不存在,应定义为受检异常或自定义
RuntimeException,由业务层处理并返回友好提示。 - 系统异常:如数据库连接失败、第三方接口超时,应在底层封装后抛出,由统一异常处理机制捕获并返回统一错误响应。
✅ 推荐:使用统一异常处理器(如 Spring 的 @RestControllerAdvice)统一处理异常,避免到处 try-catch。
7. 不要用异常控制业务流程
异常处理的性能开销较大,且会破坏代码可读性。
// ❌ 用异常控制流程
try {
userService.findUser(id);
} catch (UserNotFoundException e) {
// 新建用户
}
// ✅ 改用条件判断
Optional<User> userOpt = userService.findUser(id);
if (userOpt.isEmpty()) {
// 新建用户
}
8. 合理定义自定义异常
- 自定义异常应继承合适的父类(通常为
RuntimeException,除非强制调用方处理)。 - 不要为了“省事”定义一个
BaseException然后到处使用,应按场景细分。
// ✅ 示例
public class BusinessException extends RuntimeException {
private final String errorCode;
public BusinessException(String errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public BusinessException(String errorCode, String message, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
}
}
9. 日志记录的原则
- 在异常被捕获并处理的地方记录日志,避免重复记录。
- 使用
log.error时带上异常对象,否则只会记录 message,丢失堆栈。
// ❌ 丢失堆栈
log.error("处理失败:" + e.getMessage());
// ✅ 打印完整堆栈
log.error("处理失败", e);
10. 单元测试覆盖异常场景
确保代码中的异常分支被测试覆盖,尤其是自定义异常和资源关闭逻辑。
@Test
void testTransfer_InsufficientBalance() {
assertThrows(BusinessException.class, () -> {
accountService.transfer(from, to, amount);
});
}
总结:异常处理黄金法则
| 原则 | 说明 |
|---|---|
| 明确异常类型 | Checked / Unchecked 各司其职 |
| 尽早抛出 | 参数校验前置 |
| 晚点捕获 | 只在有能力处理的地方捕获 |
| 不吞异常 | 要么记录,要么抛出 |
| 资源自动关闭 | 使用 try-with-resources |
| 保留原始异常 | 传递 cause |
| 丰富上下文 | 异常信息包含关键参数 |
| 统一处理 | 全局异常处理器 |
| 不用于流程控制 | 性能差,可读性差 |
| 记录日志要完整 | 带上堆栈信息 |
记住:异常是代码的“故障说明书”,而不是“兜底毯子”。 优雅的异常处理,能让你的系统在出错时依然可控、可追溯、可恢复。
