引言

异常处理看似简单——try-catch-finally 谁都会写。但在实际项目中,”怎么处理异常”远比”怎么捕获异常”更难回答。你是否见过这样的代码:空 catch 块、catch(Exception e) 一把梭、return null 代替异常传播、日志打印后又重新抛出?本文将梳理 Java 异常处理体系,剖析常见的反模式,并结合 Spring 框架和异步场景给出生产级最佳实践。

Java 异常体系

Java 中所有异常事件都继承自 java.lang.Throwable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Throwable
├── Error(错误)
│ ├── OutOfMemoryError
│ ├── StackOverflowError
│ └── NoClassDefFoundError
└── Exception(异常)
├── RuntimeException(运行时异常,非受检)
│ ├── NullPointerException
│ ├── IllegalArgumentException
│ └── IndexOutOfBoundsException
└── Checked Exception(受检异常)
├── IOException
├── SQLException
└── ClassNotFoundException
  • Error:表示 JVM 层面的严重问题,应用程序不应也通常无法处理。例如 OutOfMemoryErrorStackOverflowError
  • Checked Exception:编译器强制要求处理(try-catchthrows 声明)。设计意图是迫使开发者预见并处理可恢复的错误情况。
  • RuntimeException:非受检异常,编译器不强制处理。通常表示编程错误(逻辑 bug),应当在开发阶段修复而非运行时捕获。

Checked vs Unchecked:永恒的争议

受检异常的初衷是好的——让 API 的异常语义显式化。但在实践中它带来了明显的问题:

  1. 传播成本高:调用链上的每个方法都必须声明 throws,导致接口签名膨胀
  2. Lambda 不友好:函数式接口的抽象方法没有声明 throws,受检异常在 Stream 中难以处理
  3. 滥用导致”吞异常”:开发者为了快速消除编译错误,直接写空 catch 块

目前业界的主流观点倾向于:业务异常使用非受检异常,基础设施异常在适当层级转换处理。Spring 框架的设计选择也印证了这一趋势——DataAccessException 及其子类均为非受检异常。

Try-With-Resources

Java 7 引入的 try-with-resources 是异常处理领域最实用的语法改进。它确保实现了 AutoCloseable 接口的资源在代码块执行完毕后被自动关闭:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 传统方式:繁琐且容易遗漏关闭
BufferedReader br = null;
try {
br = new BufferedReader(new FileReader("file.txt"));
return br.readLine();
} finally {
if (br != null) br.close(); // 还有自己的 try-catch
}

// try-with-resources:简洁且安全
try (BufferedReader br = new BufferedReader(new FileReader("file.txt"))) {
return br.readLine();
}
// br 自动关闭,即使发生异常

// 多资源声明
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql);
ResultSet rs = stmt.executeQuery()) {
// 三个资源按打开顺序的逆序自动关闭
}

关键细节:close() 抛出的异常会被抑制(suppressed)——如果 try 块和 close() 都抛出了异常,try 块中的异常被抛出,close() 的异常被附加为 suppressed exception,可通过 getSuppressed() 获取。

异常链与异常转译

异常链(Exception Chaining)允许在抛出新异常时保留原始异常信息:

1
2
3
4
5
try {
// 数据库操作
} catch (SQLException e) {
throw new DataAccessException("Failed to query user", e); // e 作为 cause
}

异常转译(Exception Translation)是在层次边界处将底层异常转换为有业务含义的高层异常。例如:

  • 数据访问层捕获 SQLException,转换为 DataAccessException
  • 服务层捕获 DataAccessException,转换为 UserNotFoundException
  • 控制器层捕获 UserNotFoundException,转换为 HTTP 404 响应

层次化异常设计是分层架构的重要组成部分:低层不应向高层暴露实现细节,高层不应关心低层使用什么数据库或 RPC 框架。

最佳实践

1. 永远不要吞掉异常

1
2
3
4
5
6
7
8
9
10
11
12
13
// 差:异常被吞了
try {
doSomething();
} catch (Exception e) {
// 什么都没做,出问题根本不知道
}

// 最差:日志后继续吞
try {
doSomething();
} catch (Exception e) {
e.printStackTrace(); // 输出到 stderr,生产环境可能丢失
}

如果确实不需要处理异常,至少要记录日志并说明理由:

1
2
3
4
5
try {
cache.put(key, value);
} catch (Exception e) {
log.warn("Failed to update cache for key={}, continuing", key, e);
}

2. 尽早失败(Fail-Fast)

错误发生的时间点越接近根源,排查成本越低。在方法入口处验证参数并立即抛出异常:

1
2
3
4
5
6
7
public void process(Order order) {
Objects.requireNonNull(order, "Order must not be null");
if (order.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Order amount must be positive");
}
// 正常逻辑
}

3. 抛具体的异常

1
2
3
4
5
6
7
8
9
10
// 差
throw new Exception("Something went wrong");

// 好
throw new OrderNotFoundException("Order #" + orderId + " not found");

// 更好:携带上下文信息
throw new InsufficientBalanceException(
String.format("Account %s balance %.2f is insufficient for amount %.2f",
accountId, balance, amount));

4. 异常消息要有价值

好的异常消息应该回答三个问题:发生了什么、涉及什么数据、当前状态是什么

1
2
3
4
5
6
// 差
throw new IllegalArgumentException("Invalid value");

// 好
throw new IllegalArgumentException(
"Product quantity must be positive, but was: " + quantity);

5. 不要用异常控制流程

异常机制设计用于异常情况,用它控制正常业务流程是严重的性能反模式:

1
2
3
4
5
6
7
8
9
10
11
// 差:用异常控制数组边界判断
try {
for (int i = 0; ; i++) {
arr[i] = process(arr[i]); // 靠 ArrayIndexOutOfBoundsException 终止
}
} catch (ArrayIndexOutOfBoundsException ignored) {}

// 好:正常的边界检查
for (int i = 0; i < arr.length; i++) {
arr[i] = process(arr[i]);
}

常见反模式

反模式 表现 危害
万能捕获 catch(Exception e) 在最外层 掩盖真正错误,调试困难
错误时返回 null catch { return null; } 调用方不知道出错了,NPE 在远端爆发
日志后重抛 catch { log.error(...); throw e; } 同一异常被多次记录,日志污染
空 catch catch(Exception e) {} 完全隐蔽问题,生产环境难以排查
过度 try-catch 每个语句都包裹 代码可读性崩溃
在 finally 中写 return finally { return ...; } 吞掉 try 块中的异常和原始返回值

Spring 框架中的异常处理

@ControllerAdvice 全局异常处理

@ControllerAdvice 配合 @ExceptionHandler 提供了集中的异常映射机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleNotFound(ResourceNotFoundException ex) {
return new ErrorResponse("NOT_FOUND", ex.getMessage());
}

@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleValidation(MethodArgumentNotValidException ex) {
List<String> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage())
.toList();
return new ErrorResponse("VALIDATION_FAILED", String.join("; ", errors));
}

@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleAll(Exception ex) {
log.error("Unexpected error", ex);
return new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred");
}
}

异步代码中的异常处理

@Async 方法中的异常不会自动传播到调用者,需要配合 AsyncUncaughtExceptionHandler

1
2
3
4
5
6
7
8
9
@Configuration
public class AsyncConfig implements AsyncConfigurer {
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) ->
log.error("Async method {} failed with params {}",
method.getName(), params, ex);
}
}

对于 CompletableFuture,使用 exceptionally()handle() 进行处理:

1
2
3
4
5
CompletableFuture.supplyAsync(() -> fetchData())
.exceptionally(ex -> {
log.error("Async fetch failed", ex);
return fallbackData();
});

自定义异常设计指南

一个设计良好的自定义异常应当:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class OrderProcessingException extends RuntimeException {

private final String orderId;
private final ErrorCode errorCode;

public OrderProcessingException(ErrorCode errorCode, String orderId, String message) {
super(message);
this.errorCode = errorCode;
this.orderId = orderId;
}

public enum ErrorCode {
ORDER_NOT_FOUND,
INSUFFICIENT_INVENTORY,
PAYMENT_FAILED
}
}

关键原则:

  1. 从业务语义出发,而非技术细节(如 InsufficientInventoryException 而非 DatabaseException
  2. 携带足够的上下文信息,便于排查(订单号、用户 ID、时间戳)
  3. 可以包含错误码枚举,方便监控和国际化
  4. 继承 RuntimeException(在现代实践中通常优先于 checked exception)

结语

异常处理是代码质量的试金石。一段异常处理设计得当的代码,可以让运维团队在凌晨三点快速定位问题根因;而一个被吞掉的异常,可能让排查成本从十分钟膨胀到几天。始终记住:异常不是为了”不出错”,而是为了”出错时能快速知道发生了什么”。敬畏异常,就是敬畏生产环境的稳定和可观测性。