Java 异常处理最佳实践,别再乱用 try-catch

0 阅读1分钟

在实际开发中,异常处理往往是被忽视但又极其重要的一环。滥用 try-catch 不仅会让代码变得臃肿难读,还可能掩盖潜在的问题。下面总结一套 Java 异常处理最佳实践,帮助你写出更优雅、更健壮的代码。

1. 理解异常体系,用对类型

Java 的异常分为三类:

  • Checked Exception(受检异常):如 IOExceptionSQLException,必须显式处理(try-catchthrows)。通常表示可恢复的外部错误
  • Unchecked Exception(非受检异常):如 NullPointerExceptionIllegalArgumentException,是 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

丰富上下文

异常信息包含关键参数

统一处理

全局异常处理器

不用于流程控制

性能差,可读性差

记录日志要完整

带上堆栈信息

记住:异常是代码的“故障说明书”,而不是“兜底毯子”。 优雅的异常处理,能让你的系统在出错时依然可控、可追溯、可恢复。