异常体系

“异常到底是个什么”,为什么 Java 要设计两类异常,真实大厂是怎么处理异常的,自己的项目该如何优雅地设计一套异常体系。

第一部分:“异常”到底是什么

异常不是 bug,异常是“程序在运行过程中遇到了预料之外、但又必须被明确处理的情况”。

举个生活例子:

  • 你去 ATM 取钱,余额不足 → 这不是 bug,这是可预见的“异常情况”,银行必须明确告诉你“余额不足”
  • 你去 ATM 取钱,机器突然断电/炸了 → 这才是真正的“灾难性错误”,程序直接崩了也没人怪你

Java 的异常机制就是要把这两种情况在语言层面强行区分开。

第二部分:Java 异常体系顶层设计

PPjwP-118d84b4
  • 所有异常类都继承自 Throwabl
    • Error(严重错误,不建议捕获)
      • 常见:OutOfMemoryError, StackOverflowError, LinkageError
    • Exception(程序异常,可捕获处理)
      • Checked(编译时异常):
        • 如:IOException, SQLException, FileNotFoundException
      • Unchecked(运行时异常)→ RuntimeException 子类:
        • 如:NullPointerException, ArithmeticException, IndexOutOfBoundsException

核心记忆点

  1. Throwable 是所有异常和错误的爸爸
  2. Error + RuntimeException 及其子类 = Unchecked(不检查)
  3. 除了 RuntimeException 的所有 Exception = Checked(必须检查)

第三部分:Exception vs Error

比较点 Exception Error
是否可恢复 通常可以处理(catch 或 throws) 通常无法处理(JVM 层面错误)
是否捕获 建议处理 不建议捕获
常见场景 文件不存在、数据库异常、参数错误等 栈溢出、方法区溢出、链接错误等
示例 IOException, NullPointerException OutOfMemoryError, StackOverflowError

ExceptionError 都是 Throwable 类的子类(在 Java 代码中只有继承了 Throwable 类的实例才可以被 throw 或者被 catch)它们表示在程序运行时发生的异常或错误情况。

总结来看:Exception 表示可以被处理的程序异常,Error 表示系统级的不可恢复错误。

详细说明

1)Exception:是程序中可以处理的异常情况,表示程序逻辑或外部环境中的问题,可以通过代码进行恢复或处理。

常见子类有:IOExceptionSQLExceptionNullPointerExceptionIndexOutOfBoundsException 等。

Exception 又分为 Checked Exception(编译期异常)和 Unchecked Exception(运行时异常)。

  • Checked Exception:在编译时必须显式处理(如使用 try-catch 块或通过 throws 声明抛出)。如 IOException
  • Unchecked Exception:运行时异常,不需要显式捕获。常见的如 NullPointerExceptionIllegalArgumentException 等,继承自 RuntimeException

2)Error:表示严重的错误,通常是 JVM 层次内系统级的、无法预料的错误,程序无法通过代码进行处理或恢复。例如内存耗尽(OutOfMemoryError)、栈溢出(StackOverflowError)。

Error 不应该被程序捕获或处理,因为一般出现这种错误时程序无法继续运行。

第四部分:Checked vs Unchecked

维度 Checked Exception Unchecked Exception (RuntimeException)
编译时检查 必须处理(try-catch 或 throws) 不需要处理
典型场景 外部资源问题(文件、数据库、网络) 程序员自己的 bug(空指针、越界、非法参数)
设计意图 “调用方必须面对并处理” “理论上不应该发生,发生了就是 bug”
大厂态度 尽量少用,能转 Unchecked 就转 主要使用对象,全是 Unchecked 自定义异常

主要有三大区别:分别是发生时机捕获和处理方式设计意图

1)发生时机

  • 编译时异常(Checked Exception)发生在编译阶段,编译器会检查此类异常,程序必须对这些异常进行处理(通过 try-catch 或抛出 throws),否则程序将无法通过编译。
  • 运行时异常(Unchecked Exception)发生在程序运行期间,编译器不会强制要求处理这些异常。程序员可以选择是否处理它们,通常是程序逻辑错误导致的。

2)捕获和处理方式的区别

  • 编译时异常:必须在代码中显式处理,使用 try-catch 或者 throws 关键字声明抛出。
  • 运行时异常:可以不用显式处理,可以选择使用 try-catch 捕获处理,或者让程序终止时由 JVM 抛出。

3)设计意图区别:

  • 编译时异常:通常是由外部因素引发的异常(如文件 I/O 操作、数据库连接失败等),开发者无法完全预知这些问题,因此编译器强制要求进行处理。
  • 运行时异常:一般是由开发者的编程错误或逻辑漏洞引发的,属于程序内部的问题,开发者理论上可以预知,可以在调试阶段发现处理。

常见的编译时异常:

  • SQLException:数据库访问出错。
  • FileNotFoundException:文件未找到。
  • ClassNotFoundException:无法找到指定类。
  • InterruptedException:线程在阻塞状态被打断。

常见的运行时异常:

  • ArithmeticException:数学运算错误,例如除以零。
  • ClassCastException:强制类型转换失败。
  • ArrayIndexOutOfBoundsException:数组索引越界。

大厂真实态度(2024-2025最新):

  • 阿里、腾讯、字节、华为、美团等主流大厂全部放弃 Checked Exception
  • 所有自定义异常都继承 RuntimeException
  • 原因:Checked 异常强制处理,反而写出大量无意义的 try-catch,污染代码

第四部分:大厂异常处理规范(阿里手册+真实项目总结)

场景 正确做法(大厂标配) 绝对禁止(会被开除的写法)
Checked 异常 只能在最底层包装成 Unchecked 抛出 层层 throws 向上传播 / 吞掉不处理
Unchecked 异常 业务代码尽量不 catch,由统一异常处理器处理 大面积 catch RuntimeException 吞掉
catch 顺序 先子类后父类 catch Exception/Throwable 后什么都不干
finally 只用来释放资源 finally 里 return(会吞异常)
日志 必须打完整栈 + 关键上下文参数 catch 了只 e.printStackTrace()
异常包装 只在领域边界(Service→Controller)包装一次 每层都包装(异常栈变几千行)
空指针防护 使用 Optional / Objects.requireNonNull / 校验框架 放任空指针发生再 catch

异常处理时需要注意的六个点

  1. 尽量不要捕获类似Exception这样通用的异常,而应该捕获特定的异常。

软件工程是一门协作的艺术,在日常的开发中我们有义务使自己的代码能更直观、清晰地表达出我们想要表达的信息。

但是如果你什么异常都用了 Exception,那别的开发同事就不能一眼得知这段代码实际想要捕获的异常,并且这样的代码也会捕获到可能你希望它抛出而不希望捕获的异常。

  1. 不要 “吞” 了异常

如果我们捕获了异常,不把异常抛出,或者没有写到日志里,那会出现什么情况?线上除了 bug 莫名其妙的没有任何的信息,你都不知道哪里出错以及出错的原因。

这可能会让一个简单的bug变得难以诊断,而且有些同学比较喜欢用 catch 之后用e.printStackTrace(),在我们产品中通常不推荐用这种方法,一般情况下这样是没有问题的但是这个方法输出的是个标准错误流。

image-253acb0a

比如是在分布式系统中,发生异常但是找不到 stacktrace。

所以最好是输入到日志里,我们产品可以自定义一定的格式,将详细的信息输入到日志系统中,适合清晰高效的排查错误。

  1. 不要延迟处理异常

比如你有个方法,参数是个 name,函数内部调了别的好几个方法,其实你的 name 传的是 null 值,但是你没有在进入这个方法或者这个方法一开始就处理这个情况,而是在你调了别的好几个方法然后爆出这个空指针。

这样的话明明你的出错堆栈信息只需要抛出一点点信息就能定位到这个错误所在的地方,经过了好多方法之后可能就是一坨堆栈信息。

  1. 只在需要try-catch的地方try-catch,try-catch的范围能小则小

只要必要的代码段使用try-catch,不要不分青红皂白try住一坨代码,因为try-catch中的代码会影响JVM对代码的优化,例如重排序。

  1. 不要通过异常来控制程序流程

一些可以用if/else的条件语句来判断例如null值等,就不要用异常,异常肯定是比一些条件语句低效的,有 CPU 分支预测的优化等。

而且每实例化一个 Exception 都会对栈进行快照,相对而言这是一个比较重的操作,如果数量过多开销就不能被忽略了。

  1. 不要在finally代码块中处理返回值或者直接return

在 finally 中 return 或者处理返回值会让发生很诡异的事情,比如覆盖了 try 中的 return ,或者屏蔽的异常。

第五部分:大厂同款自定义异常体系设计

// 1. 统一错误码(最重要!)
@Getter
@AllArgsConstructor
public enum ErrorCode {
    SUCCESS("00000", "成功"),

    // A开头:系统级
    SYSTEM_ERROR("A0001", "系统开小差了"),
    SERVICE_UNAVAILABLE("A0002", "服务暂时不可用"),

    // B开头:业务级
    PARAM_INVALID("B0001", "参数校验失败"),
    USER_NOT_LOGIN("B0201", "用户未登录"),
    BALANCE_NOT_ENOUGH("B0202", "余额不足"),
    ORDER_NOT_FOUND("B0203", "订单不存在"),
    ;

    private final String code;
    private final String message;
}

// 2. 基类异常(所有异常继承它)
@Getter
@RequiredArgsConstructor
public class BaseException extends RuntimeException {
    private final String code;
    private final String message;
    private final Object data; // 可选携带数据

    public BaseException(ErrorCode errorCode) {
        this(errorCode.getCode(), errorCode.getMessage(), null);
    }

    public BaseException(ErrorCode errorCode, Object data) {
        this(errorCode.getCode(), errorCode.getMessage(), data);
    }

    private BaseException(String code, String message, Object data) {
        super(message);
        this.code = code;
        this.message = message;
        this.data = data;
    }
}

// 3. 具体业务异常
public class BusinessException extends BaseException {
    public BusinessException(ErrorCode errorCode) {
        super(errorCode);
    }
    public BusinessException(ErrorCode errorCode, Object data) {
        super(errorCode, data);
    }
}

public class ParamException extends BaseException {
    public ParamException(ErrorCode errorCode) {
        super(errorCode);
    }
}

使用方式:

// 参数校验(推荐用 Validator + 全局处理)
if (StringUtils.isBlank(mobile)) {
    throw new ParamException(ErrorCode.PARAM_INVALID);
}

// 业务异常
if (!user.isLogin()) {
    throw new BusinessException(ErrorCode.USER_NOT_LOGIN);
}

if (account.getBalance().compareTo(amount) < 0) {
    throw new BusinessException(ErrorCode.BALANCE_NOT_ENOUGH, account.getBalance());
}

第六部分:SpringBoot 全局异常处理(大厂标配返回格式)

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public Result<?> handleBusiness(BusinessException e) {
        return Result.fail(e.getCode(), e.getMessage(), e.getData());
    }

    @ExceptionHandler(ParamException.class)
    public Result<?> handleParam(ParamException e) {
        return Result.fail(e.getCode(), e.getMessage());
    }

    // 所有未被捕获的异常
    @ExceptionHandler(Exception.class)
    public Result<?> handleException(Exception e) {
        log.error("系统异常", e);
        return Result.fail(ErrorCode.SYSTEM_ERROR);
    }
}

统一返回格式:

{
    "code": "B0202",
    "message": "余额不足",
    "data": "98.50",
    "success": false,
    "timestamp": "2025-04-05 10:23:11"
}

补充:try-with-resources——资源关闭的标准姿势(JDK 7+)

ℹ️本节定位

讲资源关闭的演进:finally 手动关闭 → try-with-resources 自动关闭。它是 JDK 7 对"异常 + 资源管理"这两个话题的交汇点给出的官方答案,也是面试"finally 一定会执行吗 / 有没有比 finally 更好的方案"的考点。

一、为什么 finally 关资源很痛?

传统写法要在 finally 里逐个判空、逐个 close,而 close 本身还声明受检异常:

BufferedReader br = null;
try {
    br = new BufferedReader(new FileReader("data.txt"));
    return br.readLine();
} catch (IOException e) {
    throw new RuntimeException(e);
} finally {
    if (br != null) {
        try {
            br.close();          // close 也可能抛 IOException
        } catch (IOException e) {
            e.printStackTrace(); // 吞掉关闭异常(坏味道)
        }
    }
}

三层嵌套、判空、再包一层 try——只是为了关闭一个资源。资源一多(流套流)就彻底失控。

二、try-with-resources:把关闭交给语言

只要资源类实现了 AutoCloseable(JDK 7)/ Closeable 接口,就可以放进 try 的括号里:

try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
    return br.readLine();
} catch (IOException e) {
    throw new RuntimeException(e);
}
// 出 try 块时 br.close() 被自动调用——无论正常返回还是抛异常

多个资源用分号隔开,关闭顺序与声明顺序相反(后开的先关,符合"嵌套资源外层依赖内层"的直觉):

try (FileInputStream fis = new FileInputStream("in.txt");
     FileOutputStream fos = new FileOutputStream("out.txt")) {
    fis.transferTo(fos);
}

三、底层原理:编译器生成的合成代码

try-with-resources 是语法糖,编译器把它还原成"finally + 补充异常(suppressed exception)"结构,并且比手写 finally 做得更好:

  • 主异常不会被 close 的异常覆盖:如果 try 体和 close() 都抛异常,try 体的异常作为主异常抛出,close 的异常通过 Throwable.addSuppressed() 挂在旁边,不丢失(手写 finally 里 close 的异常会直接吞掉主异常——这是历史大坑)。
  • 反编译后大致等价于:
Throwable primary = null;
try {
    return br.readLine();
} catch (Throwable t) {
    primary = t;
    throw t;
} finally {
    if (br != null) {
        if (primary != null) {
            try {
                br.close();
            } catch (Throwable suppressed) {
                primary.addSuppressed(suppressed); // 次异常挂载,不吞主异常
            }
        } else {
            br.close();
        }
    }
}

四、规则与易错点

  1. 括号里的对象必须是 final 或事实最终(effectively final)的 AutoCloseable
  2. JDK 9 起可以直接引用在 try 外声明的 final/事实 final 变量:try (br) { ... }
  3. close() 抛出的异常可以用 getSuppressed() 取出,日志排查时别漏看;
  4. 自定义资源类只需实现 AutoCloseable(单方法接口),配合接口默认方法语义非常轻量;
  5. 它只管"退出作用域时关闭",不管懒加载/半打开的网络连接生命周期——连接池类资源仍应交给框架统一管理。

五、勾连

  • 资源泄漏与堆外资源的排查见 01-内存泄漏——GC 只管堆内存,文件句柄/Socket 等"非内存资源"必须显式关闭,try-with-resources 是最低成本的兜底。
  • final 与 effectively final 的完整规则见 01-final关键字

“垃圾代码用 try-catch 吞异常, 普通程序员用异常表达错误, 优秀程序员用异常表达业务意图, 架构师用异常体系划分服务边界和责任。”


⬅️ 注解原理 🏠 00-Java ➡️ 动态代理