Skip to content

异常体系与错误处理

1. 这是什么

Java 异常体系用于描述程序执行过程中的异常情况。
它不仅是一种错误通知机制,也是系统可维护性的重要组成部分。

一句话理解:

  • 异常不是“程序崩了才会有”的东西
  • 它更像一套“错误分类、传递、定位、恢复”的机制

2. 为什么重要

异常处理做得好,问题能快速定位,系统能平稳退化。
异常处理做不好,就会出现这些连锁问题:

  • 异常被吞掉,线上只剩“操作失败”
  • 原始错误上下文丢失,排查困难
  • 资源没释放,连接、文件句柄泄漏
  • 业务异常和系统异常混在一起,边界不清

换句话说,异常处理能力直接决定代码的可维护性和系统的可观测性。

3. 先建立直觉:异常到底是在解决什么问题

程序执行中总会遇到不正常情况,例如:

  • 文件不存在
  • 参数非法
  • 网络超时
  • 库存不足
  • 数据格式错误

这些问题不适合都用 if-else 平铺处理,因为:

  • 错误可能发生在很深的调用层级
  • 调用方未必知道底层细节
  • 你通常还希望把错误原因一路往上传递

异常机制的价值就在这里:

  • 让错误可以跨方法层级传播
  • 让调用方明确区分“正常路径”和“异常路径”
  • 让程序既能终止错误流程,也能保留完整原因链

4. 核心内容

4.1 Throwable 体系怎么理解

Java 异常体系顶层是 Throwable,下面主要分两大类:

text
Throwable
 ├─ Error
 └─ Exception
    ├─ RuntimeException
    └─ 其他受检异常

可以这样理解:

  • Error:更偏向 JVM 或运行环境级严重问题,业务代码通常不负责处理
  • Exception:程序运行过程中常见的异常情况
  • RuntimeException:运行时异常,通常表示程序逻辑问题或非法状态

4.2 受检异常和非受检异常

受检异常

受检异常通常指:

  • 继承自 Exception
  • 但不继承自 RuntimeException

特点:

  • 编译器强制你处理
  • 要么 catch
  • 要么在方法签名里 throws

适合:

  • 调用方确实有恢复机会
  • 这是一个可预期的外部失败,例如文件、网络、配置、I/O

非受检异常

非受检异常通常指:

  • RuntimeException 及其子类

特点:

  • 编译器不强制捕获
  • 更适合表示编程错误、非法参数、非法状态、业务规则不满足

常见例子:

  • NullPointerException
  • IllegalArgumentException
  • IllegalStateException

工程里最常见的经验是:

  • 外部依赖失败,更偏向受检异常或明确结果返回
  • 编程错误和业务规则违反,更偏向运行时异常

4.3 try-catch-finally 解决了什么

  • try:放可能出错的代码
  • catch:捕获并处理异常
  • finally:无论是否出错,通常都会执行,适合收尾逻辑

最重要的理解不是语法,而是职责:

  • try 负责主流程
  • catch 负责异常分支
  • finally 负责清理

4.4 为什么优先使用 try-with-resources

对于文件、连接、流、Socket 这类资源,推荐优先使用:

java
try (BufferedReader reader = ...) {
    // use reader
}

原因是:

  • 资源会自动关闭
  • 比手写 finally 更简洁
  • 异常链和关闭逻辑处理更规范

只要资源实现了 AutoCloseable,就适合这么用。

4.5 什么时候该抛异常,什么时候该返回结果对象

这也是设计时非常重要的判断。

更适合抛异常的场景:

  • 出现了真正异常流程
  • 当前方法无法继续完成职责
  • 调用方必须知道这次失败

更适合返回结果对象的场景:

  • 失败是业务上的常规分支
  • 调用方预期会频繁处理这种结果
  • 你想避免把普通分支全部异常化

例如:

  • “用户不存在”在有些系统里可能是普通查询结果,适合返回空或结果对象
  • “数据库连接失败”通常是真异常,适合抛出异常

4.6 自定义业务异常怎么设计

业务异常通常不是简单抛一个 RuntimeException("失败了") 就够了。
更好的做法是明确结构,例如包含:

  • 错误码
  • 面向日志的技术信息
  • 面向前端或调用方的提示信息
  • 原始 cause

示意:

java
public class BizException extends RuntimeException {
    private final String code;

    public BizException(String code, String message) {
        super(message);
        this.code = code;
    }

    public String getCode() {
        return code;
    }
}

4.7 异常包装时为什么一定要保留 cause

很多代码会在底层异常外再包一层更高层语义,这是合理的。
例如:

  • IOException 包装成“初始化配置失败”
  • 把底层 SQL 异常包装成“订单保存失败”

但一定不要这样写:

java
throw new RuntimeException("save failed");

更应该这样写:

java
throw new RuntimeException("save failed", e);

因为如果不保留 cause

  • 原始堆栈就断了
  • 后续排查只能看到表层信息

4.8 异常处理的分层思路

一个更健康的分层思路通常是:

  • 底层:尽量提供准确异常信息
  • 业务层:按业务语义做包装或转换
  • 接口层:统一转成稳定的错误响应

这意味着:

  • 不是每层都要 catch
  • 也不是每层都该原样往外抛
  • 真正重要的是在合适层次做合适转换

5. 学习重点

这一章真正要掌握的,不是会写 try-catch,而是下面几个判断:

  • 什么属于真正异常,什么属于业务普通分支
  • 什么时候该捕获,什么时候应该继续向上抛
  • 包装异常时如何保留完整上下文
  • 如何让业务异常结构化、可追踪、可统一处理
  • 如何避免资源泄漏

6. 常见问题

6.1 直接捕获 Exception 却不处理

例如:

java
try {
    doWork();
} catch (Exception e) {
}

这等于把问题藏起来。
程序看起来“没报错”,其实只是把错误吃掉了。

6.2 吞掉异常后继续执行

如果异常已经意味着当前流程不可靠,再假装没事继续跑,往往会把问题放大。

6.3 只打印异常消息,不打印堆栈

e.getMessage() 远远不够。
定位问题通常需要完整堆栈和 cause 链。

6.4 业务异常命名混乱,没有边界

如果项目里到处都是:

  • CustomException
  • BaseException
  • ServiceException

但没有明确语义层级,后续维护会很痛苦。

6.5 包装异常时丢失原始 cause

这是最可惜的一类错误。
你明明知道上层需要更高层语义,但因为没把 cause 传进去,最后还是让排查难度暴涨。

7. 动手验证

这一节可以直接复制运行,边看边验证。

7.1 准备一个可运行示例

新建文件 ExceptionHandlingDemo.java,内容如下:

java
import java.io.IOException;

public class ExceptionHandlingDemo {
    static class ConfigLoadException extends Exception {
        ConfigLoadException(String message) {
            super(message);
        }
    }

    static class BizException extends RuntimeException {
        private final String code;

        BizException(String code, String message) {
            super(message);
            this.code = code;
        }

        String getCode() {
            return code;
        }
    }

    static class DemoResource implements AutoCloseable {
        DemoResource() {
            System.out.println("resourceOpened");
        }

        void read() throws IOException {
            throw new IOException("disk read failed");
        }

        @Override
        public void close() {
            System.out.println("resourceClosed");
        }
    }

    static String loadConfig(String env) throws ConfigLoadException {
        if (!"prod".equals(env)) {
            throw new ConfigLoadException("config for " + env + " not found");
        }
        return "config-loaded";
    }

    static void createOrder(int amount) {
        if (amount <= 0) {
            throw new BizException("ORDER_AMOUNT_ILLEGAL", "amount must be positive");
        }
    }

    static String bootApplication(String env) {
        try {
            return loadConfig(env);
        } catch (ConfigLoadException e) {
            throw new IllegalStateException("application boot failed", e);
        }
    }

    public static void main(String[] args) {
        try {
            loadConfig("test");
        } catch (ConfigLoadException e) {
            System.out.println("checkedException=" + e.getMessage());
        }

        try {
            createOrder(0);
        } catch (BizException e) {
            System.out.println("runtimeExceptionCode=" + e.getCode());
            System.out.println("runtimeExceptionMessage=" + e.getMessage());
        }

        try (DemoResource resource = new DemoResource()) {
            resource.read();
        } catch (IOException e) {
            System.out.println("tryWithResourcesCaught=" + e.getMessage());
        }

        boolean finallyExecuted = false;
        try {
            throw new IllegalArgumentException("bad request");
        } catch (IllegalArgumentException e) {
            System.out.println("caughtIllegalArgument=" + e.getMessage());
        } finally {
            finallyExecuted = true;
            System.out.println("finallyExecuted=" + finallyExecuted);
        }

        try {
            bootApplication("dev");
        } catch (IllegalStateException e) {
            System.out.println("wrappedMessage=" + e.getMessage());
            System.out.println("wrappedCause=" + e.getCause().getClass().getSimpleName());
        }
    }
}

7.2 编译并运行

bash
javac ExceptionHandlingDemo.java
java ExceptionHandlingDemo

如果你在 PowerShell 中操作,也直接执行同样两条命令即可。

7.3 你应该观察到什么

输出不一定逐字完全一致,但应包含这些关键信息:

text
checkedException=config for test not found
runtimeExceptionCode=ORDER_AMOUNT_ILLEGAL
runtimeExceptionMessage=amount must be positive
resourceOpened
resourceClosed
tryWithResourcesCaught=disk read failed
caughtIllegalArgument=bad request
finallyExecuted=true
wrappedMessage=application boot failed
wrappedCause=ConfigLoadException

7.4 每一行在验证什么

  • checkedException=...:说明受检异常要求调用方显式处理
  • runtimeExceptionCode=ORDER_AMOUNT_ILLEGAL:说明业务异常可以结构化携带错误码
  • resourceOpenedresourceClosed:说明 try-with-resources 能自动关闭资源
  • tryWithResourcesCaught=...:说明资源使用失败后仍能正常捕获异常
  • finallyExecuted=true:说明 finally 通常会执行收尾逻辑
  • wrappedCause=ConfigLoadException:说明异常包装后仍保留了原始原因链

7.5 再做两个延伸验证

你可以继续做下面两个实验:

  1. throw new IllegalStateException("application boot failed", e); 改成不带 e
  2. try (DemoResource resource = new DemoResource()) 改成手写 try-catch-finally

你可以观察:

  • 不保留 cause 后,异常链会断
  • 手写资源关闭逻辑更容易出错,也更繁琐

8. 练习建议

下面这些练习做完,这一章会更扎实:

  • 设计一套简单的业务异常结构,至少包含错误码和消息
  • 写一个服务层包装底层异常的例子,并保留原始 cause
  • 写一个统一异常处理示例,把不同异常转成不同响应
  • 对一个“吞异常”的坏例子进行重构
  • 对比“返回结果对象”和“抛业务异常”两种设计在不同场景下的取舍

9. 自测问题

  • 受检异常和非受检异常该如何取舍?
  • 为什么不能随意吞异常?
  • 为什么包装异常时要保留原始 cause?
  • 什么时候更适合返回结果对象,而不是直接抛异常?
  • 自定义业务异常时应该包含哪些关键信息?

10. 自测核对要点

如果你的回答能覆盖下面这些点,说明这一章基本掌握到位了:

  • Throwable 体系里 ErrorExceptionRuntimeException 的层级不同
  • 受检异常强调显式处理,运行时异常更偏向编程错误和业务非法状态
  • try-with-resources 是资源释放的首选方式
  • 异常包装是合理的,但必须保留 cause
  • 吞异常、泛捕获、只打 message 都会严重降低可维护性
  • 业务异常应该结构化设计,而不是只抛一个笼统字符串