Java中的Exception:checked、unchecked与设计方法
1. 总体介绍
Exception是Java用于表明某件事未按预期发生的机制。许多项目对exception的使用不一致。有的地方到处是throws Exception声明。有的地方则在空的catch块中吞掉错误。
在本文中,你将了解checked异常和unchecked异常的区别。你还会知道何时应该使用哪种类型,以及如何为应用程序设计自己的异常体系。文章最后介绍如何使用JUnit 5和AssertJ来测试异常行为。
2. Throwable的继承体系
所有通过throw抛出的东西都继承自java.lang.Throwable。简化的继承树如下:
Throwable
├── Error (unchecked) – JVM的严重错误
│ ├── OutOfMemoryError
│ └── StackOverflowError
└── Exception (checked)
├── IOException
├── SQLException
└── RuntimeException (unchecked)
├── IllegalArgumentException
├── IllegalStateException
└── NullPointerException
分类规则非常简单:
- Checked异常是
Exception及其子类,除了RuntimeException分支。编译器强制要求你处理这种类型。 - Unchecked异常是
RuntimeException、Error及其子类。编译器不强制要求你处理。
你几乎不应该捕获Error。当JVM内存不足时,应用程序很少能自动恢复。
3. Checked异常
Checked异常是method“契约”的一部分。当method声明throws时,调用者必须选择以下两种方式之一。第一种方式是用try/catch捕获错误。第二种方式是继续向上声明throws。
考虑一个支付网关,它可能拒绝交易。这种情况完全可能发生,调用者应该决定接下来如何处理:
public class PaymentDeclinedException extends Exception {
private final String reason;
public PaymentDeclinedException(String reason) {
super("Payment declined: " + reason);
this.reason = reason;
}
public String getReason() {
return reason;
}
}
public class PaymentGateway {
public void charge(String cardNumber, long amount) throws PaymentDeclinedException {
if (amount > 10_000_000) {
throw new PaymentDeclinedException("超过限额");
}
System.out.println("Charged " + amount + " from card " + cardNumber);
}
}
PaymentDeclinedException继承Exception,所以它是checked异常。如果你调用charge(...)但不处理错误,编译器会报错:
error: unreported exception PaymentDeclinedException; must be caught or declared to be thrown
调用者必须考虑失败情况:
public class CheckoutService {
private final PaymentGateway gateway = new PaymentGateway();
public String checkout(String card, long amount) {
try {
gateway.charge(card, amount);
return "SUCCESS";
} catch (PaymentDeclinedException e) {
System.out.println("Reason: " + e.getReason());
return "DECLINED";
}
}
public static void main(String[] args) {
var service = new CheckoutService();
System.out.println(service.checkout("4111-xxxx", 50_000));
System.out.println(service.checkout("4111-xxxx", 20_000_000));
}
}
输出:
Charged 50000 from card 4111-xxxx
SUCCESS
Reason: 超过限额
DECLINED
这里错误被转换成了一个业务状态(DECLINED)。这是checked异常最擅长处理的情况。
4. Unchecked异常
Unchecked异常通常表示编程错误或违反前置条件。例如传递null、传递负数,或在对象处于错误状态时调用method。
public class Order {
private final long id;
private int quantity;
private boolean shipped;
public Order(long id, int quantity) {
if (quantity <= 0) {
throw new IllegalArgumentException("quantity must be > 0, got: " + quantity);
}
this.id = id;
this.quantity = quantity;
}
public void changeQuantity(int newQuantity) {
if (shipped) {
throw new IllegalStateException("Order " + id + " has shipped, cannot change quantity");
}
this.quantity = newQuantity;
}
public void ship() {
this.shipped = true;
}
}
构造函数在输入无效时抛出IllegalArgumentException。方法changeQuantity在订单已发货时抛出IllegalStateException。没有人应该在每个调用处“捕获并处理”这些错误。正确的做法是修复调用代码。
在创建新异常类之前,你应该重用JDK中的现有异常:
| 情况 | 应该使用的Exception |
|---|---|
| 参数无效 | IllegalArgumentException |
| 对象处于错误状态 | IllegalStateException |
必需参数为null |
NullPointerException(通过Objects.requireNonNull) |
| 操作不支持 | UnsupportedOperationException |
| 索引超出范围 | IndexOutOfBoundsException |
5. 选择checked还是unchecked?
有一条务实的原则:问问调用者能否对这个错误做什么有用的事情。
- 调用者能够合理地恢复,例如重试、选择替代方案或通知用户。此时checked异常是可接受的选择。
- 错误由bug或基础设施故障引起,调用者无能为力。此时应使用unchecked异常。
在实践中,Spring和Hibernate等现代框架几乎只使用unchecked异常。例如,Spring将SQLException包装为DataAccessException。原因是checked异常有几个缺点:
- 它们逐层传播:repository层的
throws导致service和controller层也要声明throws。 - 它们与lambda和Stream API不兼容(见第8节)。
- 它们暴露了实现细节。接口
OrderRepository不应该声明throws SQLException。
注意: 对于Spring Boot应用程序,默认应对业务错误使用unchecked异常。然后使用
@RestControllerAdvice集中处理。只有当你真正想强制调用者立即处理时,才使用checked异常。
6. 为应用程序设计自定义异常
6.1. 创建基类
你应该为每个domain创建一个基异常类。这样可以在需要时用一个catch块捕获该domain的所有错误:
public class OrderException extends RuntimeException {
public OrderException(String message) {
super(message);
}
public OrderException(String message, Throwable cause) {
super(message, cause);
}
}
该类必须始终有一个接收cause的构造函数。这样,原始错误在你包装它时不会丢失。
6.2. 携带上下文的子类
一个好的异常应该能回答“发生了什么事,涉及什么数据?“的问题。不要仅仅写"Not found":
public class OrderNotFoundException extends OrderException {
private final long orderId;
public OrderNotFoundException(long orderId) {
super("Not found order id=" + orderId);
this.orderId = orderId;
}
public long getOrderId() {
return orderId;
}
}
字段orderId被保存下来供上层使用。例如,controller advice可以读取它来为客户端返回JSON错误,而不必解析消息字符串。
6.3. 包装下层错误(异常转换)
当service层调用基础设施时,你应该将技术错误转换为domain错误,并保留cause:
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class OrderExportService {
public void export(long orderId, Path target) {
try {
Files.writeString(target, "order=" + orderId);
} catch (IOException e) {
throw new OrderException("Export order " + orderId + " to " + target + " failed", e);
}
}
}
第二个参数e是cause。当打印堆栈跟踪时,你会看到两个层级:
OrderException: Export order 42 to /readonly/order.txt failed
at OrderExportService.export(OrderExportService.java:12)
Caused by: java.nio.file.AccessDeniedException: /readonly/order.txt
...
如果你写throw new OrderException(e.getMessage())而不传递e,Caused by部分会消失。那样的话调试会变得非常困难。
7. 常见的反模式
吞掉异常。 这是最危险的错误,因为应用程序会继续运行,好像什么都没发生:
try {
gateway.charge(card, amount);
} catch (PaymentDeclinedException e) {
// TODO
}
如果你真的想忽略一个错误,你应该在注释中明确说明理由。至少也应该记录该错误。
既记录又重新抛出。 写log.error(...)然后throw e;会导致同一个错误在日志中出现多次。你应该只选择一个:在原地处理(并记录),或将其抛给上层处理。
捕获Exception或Throwable范围太广。 这样做,你会无意中也捕获了NullPointerException和其他bug。应该捕获你能处理的具体类型。唯一的合理例外是在应用程序的最外层,例如全局处理程序。
使用异常来控制流程。 创建异常很昂贵,因为需要收集堆栈跟踪。这样的代码也很难读:
// Don't do this
try {
return Integer.parseInt(input);
} catch (NumberFormatException e) {
return 0;
}
对于你预期会经常出错的数据,应该提前检查。你也可以返回Optional。异常应该保留给异常情况。
声明throws Exception。 这样做会完全消除关于可能发生何种错误的信息。它还强制所有调用者必须捕获一个非常宽泛的类型。
8. lambda中的Checked异常
接口Function不声明throws,所以下面的代码无法编译:
List<String> contents = paths.stream()
.map(Files::readString) // error: unreported exception IOException
.toList();
常见的处理方式是将错误包装为UncheckedIOException。JDK中已经为此目的提供了这个类:
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
public class OrderFileReader {
public List<String> readAll(List<Path> paths) {
return paths.stream()
.map(OrderFileReader::readQuietly)
.toList();
}
private static String readQuietly(Path path) {
try {
return Files.readString(path);
} catch (IOException e) {
throw new UncheckedIOException("Cannot read " + path, e);
}
}
}
方法readQuietly将try/catch块隔离在pipeline之外,使stream保持简洁。readAll的调用者可以捕获UncheckedIOException,并通过getCause()获取原始的IOException。
9. try-with-resources和suppressed异常
使用需要关闭的资源时,应该使用try-with-resources。有一个容易被忽视的点:如果try块和close()都抛出错误,close()的错误不会覆盖主错误。它被作为suppressed异常附加到主错误中:
public class SuppressedDemo {
static class ReportConnection implements AutoCloseable {
void query() {
throw new IllegalStateException("Query failed");
}
@Override
public void close() {
throw new IllegalStateException("Close failed");
}
}
public static void main(String[] args) {
try (var connection = new ReportConnection()) {
connection.query();
} catch (IllegalStateException e) {
System.out.println("Main: " + e.getMessage());
for (Throwable s : e.getSuppressed()) {
System.out.println("Suppressed: " + s.getMessage());
}
}
}
}
输出:
Main: Query failed
Suppressed: Close failed
如果你自己写try/finally并在finally中调用close(),“Close失败”的错误会覆盖“Query失败”。这样你就会丧失真正的原因。
10. 测试异常抛出行为
如果使用Spring Boot,spring-boot-starter-test已经包含了JUnit 5和AssertJ。对于纯Java项目,添加以下依赖:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
你应该检查异常的类型、消息、上下文数据和cause:
import org.junit.jupiter.api.Test;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.*;
class OrderExceptionTest {
@Test
void whenQuantityIsZero_thenThrowIllegalArgument() {
assertThatThrownBy(() -> new Order(1L, 0))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("quantity must be > 0");
}
@Test
void whenOrderShipped_thenCannotChangeQuantity() {
var order = new Order(7L, 2);
order.ship();
assertThatIllegalStateException()
.isThrownBy(() -> order.changeQuantity(5))
.withMessage("Order 7 has shipped, cannot change quantity");
}
@Test
void notFoundException_carriesOrderId() {
assertThatThrownBy(() -> { throw new OrderNotFoundException(42L); })
.isInstanceOf(OrderException.class)
.hasFieldOrPropertyWithValue("orderId", 42L);
}
@Test
void whenPaymentOverLimit_thenThrowCheckedException() {
var gateway = new PaymentGateway();
assertThatExceptionOfType(PaymentDeclinedException.class)
.isThrownBy(() -> gateway.charge("4111-xxxx", 20_000_000))
.satisfies(e -> assertThat(e.getReason()).isEqualTo("超过限额"));
}
@Test
void whenExportFails_thenCauseIsPreserved() {
var service = new OrderExportService();
Path invalid = Path.of("/directory-does-not-exist/order.txt");
assertThatThrownBy(() -> service.export(42L, invalid))
.isInstanceOf(OrderException.class)
.hasMessageContaining("Export order 42")
.hasCauseInstanceOf(java.io.IOException.class);
}
}
需要注意的几点:
assertThatThrownBy接收一个可以抛出任何Throwable的lambda,包括checked异常。因此你不需要在test中使用try/catch。hasFieldOrPropertyWithValue读取gettergetOrderId()。这样你可以确保上下文被正确传递。hasCauseInstanceOf断言在包装错误时没有丢失cause。如果缺少这个test,遗忘cause很容易被漏过。在test中,父目录不存在,所以Files.writeString抛出NoSuchFileException,它是IOException的子类。
11. 总结
在本文中,你看到checked异常用于调用者可以恢复的情况。Unchecked异常用于编程错误、条件违反或基础设施故障,调用者无法做任何事情。对于现代应用程序,特别是Spring Boot,unchecked异常是合理的默认选择。
设计自己的异常时,应遵循以下原则:
- 为每个domain创建一个基类。
- 将上下文数据放入子类。
- 在各层之间转换错误时始终保留
cause。
最后,你应该避免诸如吞掉异常、既记录又重新抛出,或捕获过于宽泛的Exception等反模式。同时,编写test来锁定代码的异常行为。