checked vs unchecked:异常处理的两种哲学

在 Java 的异常体系中,有一个让很多开发者困惑的设计:为什么要区分受检异常(checked exception)和运行时异常(unchecked exception)?

面试中经常被问到:"什么时候用受检异常,什么时候用运行时异常?"很多候选人的回答是:"受检异常需要处理,运行时异常不需要。"但如果追问:"为什么 JDK 要这么设计?现代 Java 开发中应该怎么选?"很多人就答不上来了。

今天我们就来把这个设计决策讲透。

一、真实面试场景

候选人小赵在面试某大厂时,被问到这个问题:

"你们项目里为什么要用受检异常?而不是全部用运行时异常?"

小赵想了想说:"受检异常编译器会强制处理,所以比较安全吧..."

面试官追问:"那为什么 Spring 框架大量使用运行时异常?它不安全吗?"

小赵沉默了。

面试官继续问:"那你认为受检异常有什么缺点?"

小赵说:"呃...代码写起来比较麻烦?"

面试官点点头:"那你能说说受检异常和运行时异常各适合什么场景吗?"

小赵支支吾吾答不上来。

【面试官心理】 我想知道的是:候选人有没有理解受检异常的设计初衷、能不能说出受检异常的缺点(过度声明、异常屏蔽)、有没有实际的工程经验(Spring、JDBC、Hibernate 的异常设计对比)。只知道"受检异常需要处理"是远远不够的。

二、受检异常(Checked Exception)

2.1 什么是受检异常?

受检异常是编译器强制要求处理的异常。方法如果可能抛出受检异常,必须:

  1. 使用 throws 声明异常
  2. 或者使用 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 常见的受检异常

异常说明
IOException输入输出异常,如文件读写、网络通信
SQLException数据库操作异常
ClassNotFoundException类找不到,通常在反射时出现
NoSuchMethodException方法找不到
InvocationTargetException反射调用目标异常

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 常见的运行时异常

异常说明
NullPointerException空指针访问
ArrayIndexOutOfBoundsException数组越界
ClassCastException类型转换错误
IllegalArgumentException非法参数
ArithmeticException算术错误,如除以零
ConcurrentModificationException并发修改集合
UnsupportedOperationException不支持的操作

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 对比与选择

场景推荐异常类型理由
业务校验失败运行时异常调用方参数错误,bug
文件/网络操作受检异常可恢复,应该处理
数据库操作受检异常(传统)或运行时异常(Spring)可恢复
业务逻辑错误运行时异常(业务异常)业务规则违反
未知错误运行时异常未知错误不应该被捕获

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 集中处理