引言
异常处理是 Java 语言的核心机制之一,它提供了一种结构化的方式来处理程序运行过程中可能出现的错误或异常情况。良好的异常处理能够提升程序的健壮性、可维护性和可调试性。JDK 21 在异常处理方面保持了经典的 try-catch-finally 模型,并结合新特性(如虚拟线程、record)提供了更现代的使用方式。本文将系统地介绍 Java 异常处理机制的方方面面,包括异常层次结构、受检与非受检异常、try-with-resources、自定义异常、异常链以及最佳实践,并通过实战案例帮助你编写出更可靠的代码。
一、异常概述
异常(Exception)是指程序在执行过程中发生的非正常事件,它会中断正常的指令流。异常可能由多种原因引起,例如:
- 用户输入错误
- 文件不存在或无法读取
- 网络连接失败
- 数组越界访问
- 空指针引用
- 数据库连接失败
Java 通过异常处理机制将错误处理代码与正常业务逻辑分离,使代码更加清晰和可维护。
二、异常层次结构
Java 中所有异常和错误的根类是 java.lang.Throwable,它有两个直接子类:Error 和 Exception。
Throwable
├── Error (系统级错误,通常无法处理)
│ ├── OutOfMemoryError
│ ├── StackOverflowError
│ └── ...
└── Exception (应用级异常,可处理)
├── RuntimeException (非受检异常)
│ ├── NullPointerException
│ ├── ArrayIndexOutOfBoundsException
│ ├── ArithmeticException
│ └── ...
└── 其他受检异常
├── IOException
├── SQLException
├── ClassNotFoundException
└── ...
1. Error
Error 表示严重的系统级错误,通常是 JVM 或底层资源的问题,应用程序不应该试图捕获或处理。例如 OutOfMemoryError、StackOverflowError。
2. Exception
Exception 表示应用程序可以处理的异常情况,分为两类:
- 受检异常(Checked Exception) :继承自
Exception但不继承RuntimeException的异常。编译器强制要求处理(捕获或声明抛出)。例如IOException、SQLException。 - 非受检异常(Unchecked Exception) :继承自
RuntimeException的异常。编译器不强制要求处理,通常表示编程错误。例如NullPointerException、IllegalArgumentException。
三、异常处理基础
1. try-catch 块
try 块包含可能抛出异常的代码,catch 块处理特定类型的异常。
java
try {
int result = 10 / 0; // 可能抛出 ArithmeticException
} catch (ArithmeticException e) {
System.out.println("除数不能为零: " + e.getMessage());
}
多个 catch 块可以处理不同类型的异常,注意子类异常应放在父类异常之前:
java
try {
// 可能抛出多种异常的代码
} catch (FileNotFoundException e) {
// 处理文件未找到
} catch (IOException e) {
// 处理其他 IO 异常
} catch (Exception e) {
// 处理其他所有异常
}
JDK 7 引入了多异常捕获(multi-catch),可以在一个 catch 块中捕获多个类型:
java
try {
// ...
} catch (IOException | SQLException e) {
// 处理 IOException 或 SQLException
e.printStackTrace();
}
注意 :多异常捕获的变量隐式为
final,且异常类型之间不能有继承关系。
2. finally 块
finally 块中的代码无论是否发生异常都会执行,通常用于释放资源(如关闭文件、数据库连接等)。
java
FileInputStream input = null;
try {
input = new FileInputStream("file.txt");
// 处理文件
} catch (IOException e) {
e.printStackTrace();
} finally {
if (input != null) {
try {
input.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
3. throw 与 throws
throw用于手动抛出异常对象。throws用于在方法签名中声明该方法可能抛出的受检异常。
java
public void validateAge(int age) throws IllegalArgumentException {
if (age < 0) {
throw new IllegalArgumentException("年龄不能为负数");
}
}
调用声明了受检异常的方法时,必须捕获或继续声明抛出:
java
public void process() {
try {
validateAge(-1);
} catch (IllegalArgumentException e) {
System.out.println("参数无效: " + e.getMessage());
}
}
四、try-with-resources(自动资源管理)
JDK 7 引入了 try-with-resources 语法,用于自动关闭实现了 AutoCloseable 接口的资源。它简化了资源管理,避免了忘记关闭资源导致的内存泄漏。
java
try (FileInputStream input = new FileInputStream("file.txt");
FileOutputStream output = new FileOutputStream("out.txt")) {
// 使用 input 和 output
} catch (IOException e) {
e.printStackTrace();
}
在 try-with-resources 语句结束时,资源会按照声明的相反顺序自动关闭(先关闭 output,再关闭 input)。关闭时抛出的异常会被抑制(suppressed),可以通过 Throwable.getSuppressed() 获取。
JDK 9 的改进
JDK 9 允许在 try-with-resources 中使用 effectively final 的变量:
java
FileInputStream input = new FileInputStream("file.txt");
// input 是 effectively final
try (input) {
// 使用 input
} catch (IOException e) {
e.printStackTrace();
}
这样代码更加简洁,资源变量可以在 try 之前创建。
五、自定义异常
当内置异常不足以描述特定业务错误时,可以创建自定义异常类。
1. 自定义受检异常
java
public class InsufficientFundsException extends Exception {
public InsufficientFundsException(String message) {
super(message);
}
}
2. 自定义非受检异常
java
public class InvalidInputException extends RuntimeException {
public InvalidInputException(String message) {
super(message);
}
public InvalidInputException(String message, Throwable cause) {
super(message, cause);
}
}
3. 使用 record 封装异常信息(JDK 16+)
重要澄清 :Java 16 的
record不能继承除java.lang.Record之外的任何类,因此 record 不能直接继承 Exception。但我们可以利用 record 的紧凑性来封装异常详情,然后由异常类持有该 record。
java
public record ErrorDetails(String code, String message) {}
public class BusinessException extends RuntimeException {
private final ErrorDetails details;
public BusinessException(ErrorDetails details) {
super(details.message());
this.details = details;
}
public ErrorDetails getDetails() {
return details;
}
}
六、异常链
异常链是指将一个异常包装在另一个异常中,保留原始异常信息,便于追踪问题根源。
java
try {
// 可能抛出 IOException
} catch (IOException e) {
throw new ServiceException("服务调用失败", e); // 将 IOException 作为 cause
}
在自定义异常中,构造函数应提供接受 Throwable cause 的版本:
java
public class ServiceException extends Exception {
public ServiceException(String message, Throwable cause) {
super(message, cause);
}
}
通过 getCause() 获取原始异常:
java
catch (ServiceException e) {
Throwable rootCause = e.getCause();
if (rootCause != null) {
rootCause.printStackTrace();
}
}
七、异常处理最佳实践
1. 不要吞掉异常
java
// 错误做法:捕获后什么都不做
try {
// ...
} catch (Exception e) {
// 忽略异常
}
吞掉异常会隐藏问题,导致后续代码在错误状态下继续执行。至少要记录日志:
java
catch (Exception e) {
logger.error("处理失败", e);
}
2. 不要捕获 Throwable 或 Error
Throwable 包括 Error,而 Error 通常是 JVM 的严重错误(如 OOM),不应该被应用程序捕获。捕获 Throwable 会掩盖系统级问题。
3. 优先使用具体异常
捕获具体异常类型可以更精确地处理不同错误情况,避免过度捕获。
java
// 避免
catch (Exception e) { ... }
// 推荐
catch (FileNotFoundException e) { ... }
catch (IOException e) { ... }
4. 使用 try-with-resources 管理资源
任何实现了 AutoCloseable 的资源(如流、连接)都应使用 try-with-resources,避免手动关闭的繁琐和遗漏。
5. 在 finally 中不要抛出异常
如果 finally 块中抛出异常,会覆盖 try 块中的原始异常,导致原始异常丢失。应确保 finally 中的操作不会抛出异常,或捕获并处理。
6. 记录完整的异常堆栈
使用日志框架记录异常时,应包含异常对象(如 logger.error("...", e)),以便输出完整堆栈。
7. 受检异常 vs 非受检异常的选择
- 对于可恢复的错误,且调用方能够采取有意义的措施,使用受检异常(如文件不存在,让用户重新选择)。
- 对于编程错误或不可恢复的错误,使用非受检异常(如参数校验失败)。
- 现代 Java 开发倾向于使用非受检异常,以避免强制调用方处理,但关键业务流程中受检异常仍有价值。
8. 避免过度使用异常控制流程
异常应当用于异常情况,而不是正常的流程控制。频繁抛出和捕获异常会影响性能。
9. 为自定义异常提供足够的上下文
异常消息应清晰描述问题,并包含必要的上下文信息(如订单 ID、文件名等)。
八、实战案例:银行账户转账系统
下面通过一个完整的实战案例,综合运用本文介绍的异常处理知识,实现一个银行账户转账系统。
1. 定义业务异常
java
// 账户余额不足异常(受检异常)
public class InsufficientFundsException extends Exception {
private final double currentBalance;
private final double requiredAmount;
public InsufficientFundsException(String message, double currentBalance, double requiredAmount) {
super(message);
this.currentBalance = currentBalance;
this.requiredAmount = requiredAmount;
}
public double getCurrentBalance() {
return currentBalance;
}
public double getRequiredAmount() {
return requiredAmount;
}
}
// 账户不存在异常(非受检异常)
public class AccountNotFoundException extends RuntimeException {
private final String accountId;
public AccountNotFoundException(String accountId) {
super("账户不存在: " + accountId);
this.accountId = accountId;
}
public String getAccountId() {
return accountId;
}
}
2. 账户实体与转账服务
java
public record Account(String id, String owner, double balance) {
// 扣款
public Account debit(double amount) throws InsufficientFundsException {
if (amount > balance) {
throw new InsufficientFundsException(
"余额不足,当前余额: " + balance + ",需要: " + amount,
balance, amount
);
}
return new Account(id, owner, balance - amount);
}
// 入账
public Account credit(double amount) {
return new Account(id, owner, balance + amount);
}
}
public class TransferService {
private final Map<String, Account> accounts = new ConcurrentHashMap<>();
public void addAccount(Account account) {
accounts.put(account.id(), account);
}
public void transfer(String fromId, String toId, double amount) {
// 参数校验(非受检异常)
if (amount <= 0) {
throw new IllegalArgumentException("转账金额必须大于 0");
}
Account from = accounts.get(fromId);
if (from == null) {
throw new AccountNotFoundException(fromId);
}
Account to = accounts.get(toId);
if (to == null) {
throw new AccountNotFoundException(toId);
}
try {
// 扣款
Account newFrom = from.debit(amount);
// 入账
Account newTo = to.credit(amount);
// 更新账户
accounts.put(fromId, newFrom);
accounts.put(toId, newTo);
System.out.println("转账成功: " + fromId + " -> " + toId + ",金额: " + amount);
} catch (InsufficientFundsException e) {
// 记录日志并重新抛出,保留异常链
System.err.println("转账失败: " + e.getMessage());
throw new TransferException("转账失败", e);
}
}
}
// 转账异常(包装异常,保留原始异常链)
public class TransferException extends RuntimeException {
public TransferException(String message, Throwable cause) {
super(message, cause);
}
}
3. 客户端调用与测试
java
public class TransferDemo {
public static void main(String[] args) {
TransferService service = new TransferService();
service.addAccount(new Account("A001", "张三", 1000.0));
service.addAccount(new Account("A002", "李四", 500.0));
// 正常转账
try {
service.transfer("A001", "A002", 200.0);
} catch (TransferException e) {
System.err.println("转账异常: " + e.getMessage());
System.err.println("根因: " + e.getCause().getMessage());
}
// 余额不足
try {
service.transfer("A001", "A002", 2000.0);
} catch (TransferException e) {
System.err.println("转账异常: " + e.getMessage());
System.err.println("根因: " + e.getCause().getMessage());
}
// 账户不存在
try {
service.transfer("A999", "A002", 100.0);
} catch (AccountNotFoundException e) {
System.err.println("账户异常: " + e.getMessage());
}
// 参数校验
try {
service.transfer("A001", "A002", -50.0);
} catch (IllegalArgumentException e) {
System.err.println("参数异常: " + e.getMessage());
}
}
}
运行结果:
转账成功: A001 -> A002,金额: 200.0
转账失败: 余额不足,当前余额: 800.0,需要: 2000.0
转账异常: 转账失败
根因: 余额不足,当前余额: 800.0,需要: 2000.0
账户异常: 账户不存在: A999
参数异常: 转账金额必须大于 0
4. 案例要点总结
| 场景 | 异常类型 | 处理方式 |
|---|---|---|
| 余额不足 | 受检异常 InsufficientFundsException |
捕获后包装为 TransferException 抛出,保留异常链 |
| 账户不存在 | 非受检异常 AccountNotFoundException |
直接抛出,由调用方决定是否处理 |
| 参数非法 | 非受检异常 IllegalArgumentException |
直接抛出,属于编程错误 |
| 转账失败 | 非受检异常 TransferException |
包装底层异常,提供统一出口 |
九、JDK 21 中的异常处理相关特性
1. 虚拟线程与异常处理
JDK 21 正式引入了虚拟线程(Virtual Threads),它们与普通线程一样遵循相同的异常传播机制。当虚拟线程中抛出未捕获异常时,会传递给线程的 UncaughtExceptionHandler。由于虚拟线程非常轻量,大量使用时应注意异常处理的效率。
java
Thread.ofVirtual().start(() -> {
try {
// 可能抛出异常
} catch (Exception e) {
// 处理
}
});
2. StackWalker API(JDK 9+)
StackWalker 提供了更高效、更灵活的堆栈遍历能力,可以用于高级异常分析和日志记录。
java
StackWalker walker = StackWalker.getInstance();
walker.forEach(frame -> System.out.println(frame.getClassName() + ":" + frame.getLineNumber()));
在 JDK 21 中,StackWalker 仍然是最佳实践,尤其适合需要过滤堆栈帧的场景。
3. 结构化并发(预览特性)中的异常处理
结构化并发(Structured Concurrency)在 JDK 21 中仍为预览特性,它提供了一种更结构化的方式来管理并发任务。当子任务抛出异常时,父任务会收集所有异常并统一处理,避免了异常丢失。
java
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> future1 = scope.fork(() -> fetchData1());
Future<String> future2 = scope.fork(() -> fetchData2());
scope.join();
scope.throwIfFailed(); // 如果任一子任务失败,抛出异常
// 处理结果
} catch (ExecutionException | InterruptedException e) {
// 处理
}
这展示了异常处理与并发新特性的结合。
十、总结
Java 异常处理机制提供了一套完善的结构化错误处理方案。理解异常层次结构、熟练掌握 try-catch-finally 和 try-with-resources、合理设计自定义异常,并遵循最佳实践,能够显著提升代码的健壮性和可维护性。在 JDK 21 中,异常处理与虚拟线程、结构化并发等现代特性协同工作,为构建高并发、高可靠的应用提供了坚实基础。希望本文能够帮助你更好地运用异常处理,写出更优质的 Java 代码。