在实际开发中,异常处理往往是被忽视但又极其重要的一环。滥用 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
丰富上下文
异常信息包含关键参数
统一处理
全局异常处理器
不用于流程控制
性能差,可读性差
记录日志要完整
带上堆栈信息
记住:异常是代码的“故障说明书”,而不是“兜底毯子”。 优雅的异常处理,能让你的系统在出错时依然可控、可追溯、可恢复。