深蓝黑背景封面。左侧原创继承树示意图:顶部 Throwable 节点向下分出两支,左支紫色 Error(系统级错误,例子 OutOfMemoryError、StackOverflowError,标注「扛不住,别 catch」),右支青色 Exception;Exception 再向下分为 Checked(受检异常,如 IOException,编译期强制处理)与 Unchecked(RuntimeException 子类,如空指针,视为程序 bug)。底部一行口诀。右侧大标题「Error 和 Exception 到底差在哪」,副标题「Checked 异常为什么备受争议」;左上标注「面试经典 · Java 后端」,右上圆角胶囊标注「Day 03 · 11-10」 图 1:本文封面,继承树的颜色区分「能不能救」。

先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。「Error 和 Exception 的区别」是 Java 面试的入场题,很多人背得出「Error 是系统错误、Exception 是程序异常」这一句,却说不清这条分界线为什么是 Throwable 之下的第一层,以及 Checked 异常这套「编译器强制」机制为什么几十年来吵个不停。

先把树画对:Throwable 下的两个世界。

Java 语言规范(JLS)第 11 章把整棵继承树钉得很死:Throwable 是 Object 的直接子类,Throwable 之下只有两个直接子类——Error 和 Exception。

Error 是「普通程序通常不指望恢复」的那一类:OutOfMemoryError、StackOverflowError、NoClassDefFoundError。它们大多由虚拟机自身抛出,代表运行环境已经出了大问题。Exception 则是「普通程序可能希望从中恢复」的那一类,它下面又分两支:RuntimeException 及其子类(空指针、数组越界、非法参数),以及其余所有异常。

沿着这棵树,规范给出两个集合定义:unchecked 异常 = RuntimeException 家族 + Error 家族;checked 异常 = 剩下的全部。也就是说,Checked 与 Unchecked 的分界不是「严重程度」,而是「编译器管不管你」。

Checked 的强制力:编译期的一道闸。

JLS 第 11.2 节规定:一个方法可能抛出的 checked 异常,要么在方法体内 catch 掉,要么写进 throws 子句,否则编译不过。throws 子句因此成了方法契约的一部分——看到 readConfig(String path) throws IOException,调用方在编译期就被告知「这个调用可能失败,你得想好怎么办」。

为什么 Error 和 RuntimeException 被豁免?规范自己也给了理由。Error 可能在程序的任何地方发生,要求声明它们只会让代码堆满无意义的 throws;RuntimeException 则往往是代码缺陷——规范举的例子是:一个从构造上就保证无环的数据结构,程序员确信不会空指针,但编译器没有能力证明,强制声明纯属打扰。换句话说,unchecked 的设计语义是「这是 bug,去修代码」,checked 的设计语义是「这是环境故障,去做预案」。

争议在哪:理想契约与现实妥协。

Checked 异常挨骂,根源在于强制力沿着调用链向上传染。底层的 IOException 一路穿透十几层中间方法,每一层都要么加 throws(契约被污染,改一次底层全员跟着改)、要么 catch 住再包装成自己的异常(样板代码爆炸),最糟的是图省事写个空 catch 把它吞掉——恰恰违背了设计初衷。再加上函数式接口、回调场景里 checked 异常无处安放,许多现代框架在设计上全面转向 unchecked,JVM 生态后来出现的语言也大多不再提供受检异常。争议的共识落点是:checked 的理念没错,错在被用在了太多「其实恢复不了」的场景。

用代码把三类对待方式跑出来。

下面这段程序演示按类型分流的重试策略,以及 catch (Throwable) 这个经典陷阱。用本机 JDK 21 编译运行(javac ExceptionDemo.java && java ExceptionDemo):

public class ExceptionDemo {
    static String readConfig(String path) throws java.io.IOException { // Checked,必须声明
        if (path == null) throw new IllegalArgumentException("path 不能为 null"); // Unchecked,不用声明
        throw new java.io.IOException("读取失败:连接超时");
    }

    static void loadWithPolicy(String path) {
        try {
            readConfig(path);
        } catch (IllegalArgumentException e) {
            System.out.println("[参数非法] 直接失败,不重试:" + e.getMessage());
        } catch (java.io.IOException e) {
            System.out.println("[IO 异常] 可重试后告警:" + e.getMessage());
        }
    }

    public static void main(String[] args) {
        loadWithPolicy(null);
        loadWithPolicy("cfg.yaml");
        try {
            throw new OutOfMemoryError("模拟 OOM");
        } catch (Throwable t) { // 陷阱:把 Error 一起吞掉
            System.out.println("catch(Throwable) 吞掉了:" + t.getClass().getSimpleName());
        }
        System.out.println("程序带病继续运行(危险)");
        try {
            int[] a = new int[3];
            System.out.println(a[5]);
        } catch (Exception e) { // Unchecked 也能 catch,只是编译器不强制
            System.out.println("Unchecked 也能 catch:" + e.getClass().getSimpleName());
        }
    }
}

实测输出:

[参数非法] 直接失败,不重试:path 不能为 null
[IO 异常] 可重试后告警:读取失败:连接超时
catch(Throwable) 吞掉了:OutOfMemoryError
程序带病继续运行(危险)
Unchecked 也能 catch:ArrayIndexOutOfBoundsException

输出里藏着三个考点。第一,IllegalArgumentException 不需要 throws 声明也能抛,编译器对 unchecked 完全放行。第二,catch (Throwable) 连 OutOfMemoryError 都拦下了,程序在内存已经耗尽的状态下继续跑——这就是真实系统里 OOM 之后服务「假活着」的常见成因,兜底应该用 catch (Exception) 而不是 Throwable。第三,unchecked 不是「不能 catch」,而是「不强制 catch」,边界分清后,catch 的语义就单纯了。

深蓝黑背景概念图。左侧完整继承树:根节点 Throwable 向下分出 Error 与 Exception 两支;Error 紫色标注,例子为 OutOfMemoryError、StackOverflowError、NoClassDefFoundError,语义是系统级错误、程序基本没救、不该捕获;Error 下方另有一张陷阱卡,说明 catch (Throwable) 会把 Error 一起吞掉,导致 OOM 之后系统带病运行。Exception 青色标注,向下再分两支:Checked 受检异常(IOException、SQLException,编译器强制 catch 或 throws),Unchecked 非受检异常(RuntimeException 的子类 NullPointerException、IllegalArgumentException、IndexOutOfBoundsException,视为代码缺陷、不强制声明)。右侧上方对比卡列出 Checked 与 Unchecked 在强制处理、设计意图、典型代表与争议点四行的差异;右侧下方工程应用卡给出重试决策:网络超时类 IOException 可重试、参数非法直接失败、Error 不捕获上抛。底部总结条:该治的治,是 bug 的修,扛不住的上抛 图 2:继承树全貌与「catch (Throwable) 吞掉 Error」陷阱的位置。

这套分类在工程里值多少钱。

调用外部服务(包括大模型 API)的重试策略,本质就是一张异常分类表:连接超时、限流这类 IOException 式的瞬时故障值得指数退避重试;参数非法、鉴权失败这类 unchecked 信号说明请求本身有问题,重试一万次也是同样的错;而 Error 意味着进程自身已经不稳,正确动作是记录现场后熔断上抛,让上层重启。分错类的代价是双向的:把 bug 当故障重试会打爆下游,把瞬时故障当 bug 直接放弃会放大抖动。

回到面试现场,建议的答题结构:先画出 Throwable → Error / Exception → RuntimeException 的继承树并给出 checked/unchecked 的规范定义,再讲编译期强制机制与两类豁免的设计理由,最后主动谈 Checked 争议的根源(契约传染、吞异常反模式)与 catch (Throwable) 陷阱。树画对是及格线,讲清「为什么是编译器而不是运行时在管」是加分项。你在项目里见过最离谱的吞异常写法是什么?欢迎贴出来一起吐槽。