异常体系:Error、Exception、RuntimeException 一文搞懂

写 Java 代码的时候,异常处理无处不在。try-catch-throws,运行时异常,受检异常...这些概念你一定用过,但真的理解它们的区别吗?

我见过太多候选人在面试中说"Exception 是运行时异常"。还有人问"Error 和 Exception 有什么区别",答不上来。更有人不知道什么时候该 try-catch,什么时候该往外抛。

今天我们就来把这个异常体系彻底讲透。

一、真实面试场景

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

"你们项目里是怎么处理异常的?能举一个例子吗?"

小李说:"我们用了 try-catch 捕获异常,然后用 log.error 输出日志。"

面试官继续追问:"那你们什么时候捕获异常,什么时候往外抛?"

小李说:"遇到异常就 try-catch 捕获,然后打印日志。"

面试官皱了皱眉:"如果遇到空指针异常呢?你们也 catch 住吗?"

小李愣了一下:"额...对,我们统一 catch 了所有异常。"

面试官摇了摇头,继续问:"那你知道 Error 和 Exception 的区别吗?RuntimeException 和普通 Exception 有什么区别?"

小张开始支支吾吾。

【面试官心理】 我想知道他有没有真正理解异常体系。很多团队的做法是"catch 所有异常然后打印日志",这种做法其实暴露了对异常体系的理解不足。RuntimeException 和普通 Exception 的处理策略完全不同,catch 所有异常会导致业务错误被吞掉,程序可能在错误状态下继续运行。

二、异常体系全景图

Java 的异常体系是一个树形结构:

Throwable
├── Error(错误)
│   ├── VirtualMachineError
│   │   ├── OutOfMemoryError
│   │   └── StackOverflowError
│   ├── ThreadDeath
│   └── LinkageError

└── Exception(异常)
    ├── IOException(受检异常)
    │   ├── FileNotFoundException
    │   └── SocketException
    ├── SQLException(受检异常)
    └── RuntimeException(运行时异常)
        ├── NullPointerException
        ├── ArrayIndexOutOfBoundsException
        ├── ClassCastException
        ├── IllegalArgumentException
        └── ArithmeticException

2.1 Throwable:所有异常的老祖宗

Throwable 是 Java 中所有错误和异常的父类。它有两个直接子类:

  • Error:表示 JVM 无法处理的严重问题
  • Exception:表示程序可以处理的异常情况
public class Throwable {
    private String detailMessage;
    private StackTraceElement[] stackTrace;
    private Throwable cause;  // 原因链
    
    public Throwable() {}
    public Throwable(String message) {}
    public Throwable(String message, Throwable cause) {}
    
    public String getMessage() {}
    public void printStackTrace() {}
}

三、Error:JVM 的"不可抗力"

3.1 Error 的本质

Error 代表的是 JVM 层面的错误,是程序无法处理的。比如:

  • OutOfMemoryError:内存耗尽
  • StackOverflowError:栈溢出
  • VirtualMachineError:JVM 崩溃

这些错误的发生不是因为你的代码有 bug,而是因为运行环境出了问题。

3.2 ❌ 错误示范:catch Error

try {
    // 可能会触发 StackOverflowError 的代码
} catch (Error e) {
    // 不要这样做!
    System.out.println("捕获了 Error");
}

捕获 Error 是不合理的行为,因为:

  1. 你无法恢复:内存耗尽了,你能做什么?重启 JVM 吗?
  2. 程序状态不可信:JVM 已经出问题了,程序可能处于未定义状态
  3. 掩盖真正问题:捕获 Error 会让问题更难排查

3.3 正确做法:不要捕获 Error

对于 Error,正确的处理方式是让它传播出去,或者配置 JVM 参数

// 方案1:让 Error 传播出去
public void readFile() throws IOException {
    // 如果发生 OutOfMemoryError,让它自然传播
    // 不要 catch
}

// 方案2:配置 JVM 参数应对
// -Xmx512m 设置最大堆内存
// -Xss1m 设置栈大小
⚠️

捕获 Error 是一种反模式。如果你的代码 catch 了 Error,那说明你的异常处理策略有问题。Error 是给 JVM 和 JVM 工具看的,不是给业务代码看的。

四、Exception:程序可以处理的异常

4.1 Exception 的分类

Exception 分为两类:

  1. 受检异常(Checked Exception):编译器要求必须处理的异常
  2. 运行时异常(Unchecked Exception / RuntimeException):编译器不要求强制处理的异常
Exception
├── 受检异常(Checked)—— 必须 try-catch 或 throws
│   ├── IOException
│   ├── SQLException
│   └── ClassNotFoundException

└── RuntimeException(Unchecked)—— 可选处理
    ├── NullPointerException
    ├── ArrayIndexOutOfBoundsException
    ├── ClassCastException
    └── IllegalArgumentException

4.2 受检异常(Checked Exception)

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

  • 使用 try-catch 捕获处理
  • 或者使用 throws 声明抛出
public class FileService {
    
    // 这个方法可能抛出受检异常,必须声明或捕获
    public String readFile(String path) throws IOException {
        BufferedReader reader = new BufferedReader(new FileReader(path));
        return reader.readLine();
    }
}

调用方必须处理:

public void process() {
    try {
        String content = fileService.readFile("config.txt");
    } catch (IOException e) {
        // 必须处理:要么 catch,要么继续 throws
        log.error("读取文件失败", e);
    }
}

4.3 运行时异常(RuntimeException)

运行时异常是编译器不强制要求处理的异常。它的出现通常表示程序有 bug:

public class UserService {
    
    public User getUserById(Long id) {
        // 如果 id 为 null,这里会抛 NullPointerException
        // 但编译器不强制要求处理
        return userDao.findById(id);  // id 可能为空
    }
}

调用方可以不处理:

public void process() {
    // 可以不 catch,因为 RuntimeException 编译器不强制要求
    User user = userService.getUserById(userId);
    // 但如果不处理,异常会往上传播,直到被捕获或导致程序崩溃
}

4.4 【直观类比】受检异常 vs 运行时异常

想象你去餐厅吃饭:

  • 受检异常就像"需要预约才能进入"。如果你没有预约,餐厅不让你进。在编译阶段就检查你是否处理了。

  • 运行时异常就像"菜里有虫子"。这是服务员的失误(程序 bug),你可以在发现时处理,也可以不管(让它影响整个用餐体验)。

五、常见异常详解

5.1 运行时异常(RuntimeException)

NullPointerException:空指针异常

String str = null;
str.length();  // NullPointerException

ArrayIndexOutOfBoundsException:数组越界

int[] arr = {1, 2, 3};
int x = arr[5];  // ArrayIndexOutOfBoundsException

ClassCastException:类型转换错误

Object obj = "hello";
Integer num = (Integer) obj;  // ClassCastException

IllegalArgumentException:非法参数

public void setAge(int age) {
    if (age < 0) {
        throw new IllegalArgumentException("年龄不能为负数");
    }
}

5.2 受检异常(Checked Exception)

IOException:输入输出异常

try {
    FileInputStream fis = new FileInputStream("test.txt");
} catch (FileNotFoundException e) {  // IOException 的子类
    e.printStackTrace();
}

SQLException:数据库异常

try {
    Connection conn = DriverManager.getConnection(url, user, pwd);
} catch (SQLException e) {
    log.error("数据库连接失败", e);
}

ClassNotFoundException:类找不到

try {
    Class.forName("com.mysql.jdbc.Driver");
} catch (ClassNotFoundException e) {
    log.error("驱动类未找到", e);
}

六、生产场景与异常处理

6.1 ❌ 错误示范:捕获所有异常

try {
    processOrder(orderId);
} catch (Exception e) {
    log.error("处理订单失败", e);  // 捕获了所有异常,包括 RuntimeException
}

问题:这种写法会吞掉 NullPointerException、IllegalArgumentException 等运行时异常,导致程序在错误状态下继续运行。

6.2 ✅ 正确做法:区分异常类型

public void processOrder(String orderId) {
    // 业务校验异常,直接抛运行时异常
    if (orderId == null || orderId.isEmpty()) {
        throw new IllegalArgumentException("订单号不能为空");
    }
    
    try {
        orderService.submit(orderId);
    } catch (IOException e) {
        // 受检异常,必须处理:记录日志,可以重试或回滚
        log.error("提交订单失败", e);
        throw new OrderSubmitException("订单提交失败", e);
    }
}

6.3 异常链:保留根本原因

try {
    orderDao.save(order);
} catch (SQLException e) {
    // 保留原始异常作为 cause
    throw new ServiceException("保存订单失败", e);
}

这样堆栈信息会完整保留:

ServiceException: 保存订单失败
    at OrderService.save(OrderService.java:25)
    ...
Caused by: java.sql.SQLException: 数据插违反约束
    at OrderDao.save(OrderDao.java:18)
    ...

6.4 异常抛出策略

场景处理方式
业务校验失败IllegalArgumentException 等运行时异常
外部依赖失败(数据库、Redis)受检异常,可以用业务异常包装
未知错误捕获并转换为业务异常,记录日志
幂等性失败捕获后重试,或记录后跳过
事务失败抛异常触发回滚

七、面试追问链

第一层:基础概念

面试官问:"Error 和 Exception 的区别是什么?"

标准回答:Error 表示 JVM 无法处理的严重问题(如 OutOfMemoryError),程序不应该捕获 Error;Exception 表示程序可以处理的异常情况,分为受检异常(编译器要求处理)和运行时异常(不强制要求处理)。

第二层:追问受检/运行时异常

面试官追问:"受检异常和运行时异常有什么区别?"

标准回答:受检异常是编译器强制要求处理的异常(如 IOException、SQLException),如果不处理或不声明,编译不通过;运行时异常(RuntimeException)编译器不强制要求处理,通常表示程序 bug(如 NullPointerException),可以处理也可以不处理。

第三层:实际应用

面试官追问:"你们项目里怎么处理异常?什么时候捕获,什么时候抛出?"

需要结合实际场景:业务校验失败抛运行时异常,外部依赖失败捕获并转换为业务异常,重复操作需要幂等处理等等。

第四层:异常设计

面试官追问:"如果要设计一个统一的异常处理,应该怎么做?"

可以回答:定义业务异常基类,封装错误码和错误信息,全局异常处理器统一处理,记录日志,返回统一格式的错误响应。

【面试官心理】 我问他异常处理,其实是在考察他对异常体系的理解深度。知道 Error/Exception/RuntimeException 区别只是基础,能说出"业务异常应该怎么设计"、"异常链怎么保留"才是加分项。

【学习小结】

  • Error:JVM 错误,程序不应捕获,让其传播
  • Exception:程序可以处理的异常
    • 受检异常(Checked):编译器强制处理
    • 运行时异常(RuntimeException):不强制处理,通常是 bug
  • 不要 catch 所有异常,会吞掉运行时异常
  • 保留异常链(cause)以便排查问题
  • 业务异常用运行时异常更灵活