checked vs unchecked:异常处理的两种哲学
在 Java 的异常体系中,有一个让很多开发者困惑的设计:为什么要区分受检异常(checked exception)和运行时异常(unchecked exception)?
面试中经常被问到:"什么时候用受检异常,什么时候用运行时异常?"很多候选人的回答是:"受检异常需要处理,运行时异常不需要。"但如果追问:"为什么 JDK 要这么设计?现代 Java 开发中应该怎么选?"很多人就答不上来了。
今天我们就来把这个设计决策讲透。
一、真实面试场景
候选人小赵在面试某大厂时,被问到这个问题:
"你们项目里为什么要用受检异常?而不是全部用运行时异常?"
小赵想了想说:"受检异常编译器会强制处理,所以比较安全吧..."
面试官追问:"那为什么 Spring 框架大量使用运行时异常?它不安全吗?"
小赵沉默了。
面试官继续问:"那你认为受检异常有什么缺点?"
小赵说:"呃...代码写起来比较麻烦?"
面试官点点头:"那你能说说受检异常和运行时异常各适合什么场景吗?"
小赵支支吾吾答不上来。
【面试官心理】
我想知道的是:候选人有没有理解受检异常的设计初衷、能不能说出受检异常的缺点(过度声明、异常屏蔽)、有没有实际的工程经验(Spring、JDBC、Hibernate 的异常设计对比)。只知道"受检异常需要处理"是远远不够的。
二、受检异常(Checked Exception)
2.1 什么是受检异常?
受检异常是编译器强制要求处理的异常。方法如果可能抛出受检异常,必须:
- 使用
throws 声明异常
- 或者使用
try-catch 捕获处理
// 声明可能抛出受检异常
public void readFile(String path) throws IOException {
// ...
}
// 调用方必须处理
public void process() {
try {
readFile("test.txt");
} catch (IOException e) {
// 必须处理,否则编译失败
}
}
如果调用方不处理,编译器会报错:
error: unreported exception IOException; must be caught or declared to be thrown
2.2 常见的受检异常
2.3 受检异常的设计哲学
受检异常的设计初衷是:强制调用方意识到可能发生的异常,并做出处理决策。
// JDK 的经典用法
public class FileInputStream {
public FileInputStream(String name) throws FileNotFoundException {
// 文件可能不存在,调用方必须处理
}
public int read() throws IOException {
// 读取可能失败,调用方必须处理
}
public void close() throws IOException {
// 关闭可能失败,调用方必须处理
}
}
设计者认为:文件操作是可恢复的错误(文件不存在可以创建、网络断了可以重试),所以应该强制调用方处理。
三、运行时异常(Unchecked Exception)
3.1 什么是运行时异常?
运行时异常(RuntimeException)是编译器不强制要求处理的异常。它的出现通常表示程序有 bug:
public class UserService {
public User getUserById(Long id) {
// id 为 null 会抛 NullPointerException,但编译器不强制处理
return userRepository.findById(id);
}
}
调用方可以不处理,异常会往上传播直到被捕获或导致程序崩溃。
3.2 常见的运行时异常
3.3 运行时异常的设计哲学
运行时异常的设计哲学是:这类异常不应该被捕获,因为它们表示程序有 bug,正确的做法是修复代码。
public int divide(int a, int b) {
if (b == 0) {
// 不应该用受检异常,而是用运行时异常
// 因为调用方传递 b=0 本身就是调用方的 bug
throw new ArithmeticException("除数不能为零");
}
return a / b;
}
四、受检异常的缺点
4.1 异常声明污染
受检异常会导致方法签名充满 throws 声明:
public void saveOrder(Order order)
throws IOException, SQLException, BusinessException {
// 方法签名变得很丑
}
public void submitOrder(Order order)
throws IOException, SQLException, BusinessException {
// 每个方法都要声明同样的异常
}
public void processBatch(List<Order> orders)
throws IOException, SQLException, BusinessException {
// 异常声明层层传播
}
这就是著名的"异常声明污染"问题。调用栈深的时候,每层方法都要声明异常,代码变得非常难看。
4.2 异常屏蔽
当 try-catch-finally 中 finally 也可能抛异常时:
try {
conn.execute(sql); // 抛出 SQLException
} finally {
conn.close(); // 抛出 IOException
}
// 实际抛出的是 IOException,SQLException 被屏蔽了
JDK 7 引入了 try-with-resources 来解决这个问题,但老代码仍然存在这个问题。
4.3 调用方被迫处理
public String readConfig(String path) throws IOException {
// ...
}
// 调用方被迫处理
public void init() {
try {
String config = readConfig("config.properties");
} catch (IOException e) {
// 必须处理,但有时候真的不知道怎么处理
log.error("读取配置失败", e);
}
}
有时候调用方真的不知道该如何处理这个异常(文件不存在怎么办?网络断了怎么办?),但编译器强制要求处理。
4.4 【直观类比】受检异常的过度设计
想象你去餐厅吃饭,菜单上写着:
"您选择的菜品可能:
- 暂时售罄(CheckedException)
- 厨房着火(Error)
- 厨师心情不好(CheckedException)
- 食材不新鲜(UncheckedException)"
受检异常就像要求你提前为每一种可能的情况做好准备。运行时异常就像告诉你:"如果食物不新鲜,那是厨房的问题,你直接投诉就行,不用提前准备。"
五、现代 Java 中的异常选择
5.1 Spring 框架的选择:运行时异常
看看 Spring 的异常设计:
// Spring 的异常体系
public class NestedRuntimeException extends RuntimeException {
// 所有 Spring 业务异常都继承自 RuntimeException
}
// 典型的 Spring Data 异常
public class DataAccessException extends RuntimeException {}
// 具体异常
public class DataIntegrityViolationException extends DataAccessException {}
public class DataRetrievalFailureException extends DataAccessException {}
Spring 的选择是:业务异常全部用运行时异常,调用方可以选择处理或不处理。
5.2 JDBC 的选择:受检异常
JDBC 选择用受检异常:
public interface Connection extends AutoCloseable {
Statement createStatement() throws SQLException;
void commit() throws SQLException;
void rollback() throws SQLException;
void close() throws SQLException;
}
JDBC 的理由是:数据库操作是可恢复的(连接断开可以重连),应该让调用方意识到并处理。
5.3 对比与选择
5.4 实际工程建议
现代 Java 开发的最佳实践:
// 1. 业务异常用运行时异常
public class OrderException extends RuntimeException {
private String orderId;
private String errorCode;
public OrderException(String orderId, String errorCode, String message) {
super(message);
this.orderId = orderId;
this.errorCode = errorCode;
}
}
// 2. 使用异常链保留根本原因
public void submitOrder(Order order) {
try {
orderDao.save(order);
} catch (DataAccessException e) {
throw new OrderException(order.getId(), "SAVE_FAILED", "保存订单失败", e);
}
}
// 3. 使用统一异常处理(Spring MVC)
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(OrderException.class)
public Response handleOrderException(OrderException e) {
return Response.error(e.getErrorCode(), e.getMessage());
}
@ExceptionHandler(Exception.class)
public Response handleException(Exception e) {
log.error("未知错误", e);
return Response.error("SYSTEM_ERROR", "系统错误");
}
}
六、throws 和 throws 的正确使用
6.1 方法签名中的 throws
// 声明可能抛出的受检异常
public void readFile(String path) throws IOException {
// ...
}
6.2 重写方法中的异常规则
class Parent {
public void doSomething() throws IOException {}
}
class Child extends Parent {
@Override
// 允许不声明异常
public void doSomething() {}
// 允许声明更具体的异常
public void doSomething() throws FileNotFoundException {}
// 错误!不能声明更宽泛的异常
// public void doSomething() throws Exception {} // 编译错误
}
6.3 ❌ 错误示范:捕获并重新抛出不同的异常
try {
fileService.readFile(path);
} catch (IOException e) {
// 错误:丢失了原始异常信息
throw new RuntimeException("读取文件失败");
}
正确做法:
try {
fileService.readFile(path);
} catch (IOException e) {
// 正确:保留原始异常作为 cause
throw new OrderException("读取文件失败", e);
}
七、面试追问链
第一层:基础概念
面试官问:"受检异常和运行时异常有什么区别?"
标准回答:受检异常(Checked Exception)是编译器强制要求处理的异常,方法必须声明或捕获;运行时异常(RuntimeException)编译器不强制要求处理,通常表示程序 bug。
第二层:设计哲学
面试官追问:"为什么要区分这两种异常?"
需要说出受检异常的设计初衷(强制调用方处理可恢复错误)、运行时异常的设计初衷(表示 bug,不应该被捕获),以及受检异常的缺点(声明污染、异常屏蔽)。
第三层:工程实践
面试官追问:"你们项目里怎么选择用哪种异常?"
结合实际场景:业务校验失败用运行时异常、外部依赖(数据库、文件)可以用受检异常或包装后的运行时异常、Spring 框架推荐使用运行时异常。
第四层:对比框架
面试官追问:"Spring 为什么用运行时异常?和 JDBC 的受检异常有什么不同?"
Spring 认为:业务异常应该让调用方选择处理或不处理(运行时异常更灵活);JDBC 认为:数据库错误必须被处理(受检异常更强制)。
【面试官心理】
我问他这个问题,想知道的是:候选人有没有理解"受检 vs 运行时"背后的设计哲学,而不是只会背结论。能说出 Spring 的异常设计、JDBC 的异常设计、以及实际工程中的权衡,才说明他是真的理解。
【学习小结】
- 受检异常:强制调用方处理,适合可恢复的错误(IO、数据库)
- 运行时异常:不强制处理,适合表示 bug 或业务规则违反
- 现代 Java 开发倾向于使用运行时异常(Spring 风格)
- 异常链:用 cause 保留原始异常
- 统一异常处理:用 @ExceptionHandler 集中处理