图 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 的语义就单纯了。
图 2:继承树全貌与「catch (Throwable) 吞掉 Error」陷阱的位置。
这套分类在工程里值多少钱。
调用外部服务(包括大模型 API)的重试策略,本质就是一张异常分类表:连接超时、限流这类 IOException 式的瞬时故障值得指数退避重试;参数非法、鉴权失败这类 unchecked 信号说明请求本身有问题,重试一万次也是同样的错;而 Error 意味着进程自身已经不稳,正确动作是记录现场后熔断上抛,让上层重启。分错类的代价是双向的:把 bug 当故障重试会打爆下游,把瞬时故障当 bug 直接放弃会放大抖动。
回到面试现场,建议的答题结构:先画出 Throwable → Error / Exception → RuntimeException 的继承树并给出 checked/unchecked 的规范定义,再讲编译期强制机制与两类豁免的设计理由,最后主动谈 Checked 争议的根源(契约传染、吞异常反模式)与 catch (Throwable) 陷阱。树画对是及格线,讲清「为什么是编译器而不是运行时在管」是加分项。你在项目里见过最离谱的吞异常写法是什么?欢迎贴出来一起吐槽。