异常体系
本教程共 100 篇 · 第 65 篇 · 更新于 2026-08-05 · 约 6 分钟阅读
65. 异常体系
本节目标:搞懂 Java 为什么用异常表示错误,认清 Throwable 家族的继承树,学完能一眼判断某个异常是必须捕获还是可以放过。
程序总会出错
写程序时你会发现,代码本身没问题,运行起来照样能崩。
用户输入年龄,你期待一个数字,他敲了 abc。程序要读某个文件,文件被人删了。网络突然断开,磁盘突然写满。
这些情况不是「写错代码」,而是运行环境不配合。健壮的程序必须能应对它们,而不是一崩了之。
public class ErrorDemo {
public static void main(String[] args) {
String s = "abc";
int n = Integer.parseInt(s); // 这里会炸:NumberFormatException
System.out.println(n);
}
}
```bash
编译能过,运行直接终止。控制台会打印一串红字,程序后面的代码一行都执行不到。
## 错误码方案为什么被淘汰
早期 C 语言的做法是**返回错误码**:函数返回 `0` 表示成功,返回其他数字代表不同的失败原因。
```java
// 这是「反面教材」,演示错误码风格的写法
int code = processFile("test.txt");
if (code == 0) {
// 成功
} else if (code == 1) {
// 文件不存在
} else if (code == 2) {
// 没有读权限
} else {
// 未知错误
}
这套方案有三个硬伤。
第一,错误码是光秃秃的数字,2 到底代表什么,全靠文档和记忆。
第二,正常返回值和错误码挤在一个返回位置上。如果方法本来就要返回 int,那 -1 到底是计算结果还是错误标记?说不清。
第三,也是最要命的——调用方可以直接忽略。你不检查返回值,编译器一句话都不会说,错误就这么被吞掉了。
Java 的答案:把错误做成对象
Java 换了个思路:错误也是对象,出错时不返回数字,而是「抛出」一个异常对象。
异常对象是一个类的实例,类名本身就说明了错误类型,对象里还能装错误消息和调用栈。
抛出的异常会沿着方法调用链往上传,直到被某一层 try...catch 接住。这样一来,出错的地方和处理错误的地方就解耦了。
try {
String s = processFile("test.txt");
System.out.println(s);
} catch (FileNotFoundException e) {
System.out.println("文件不存在");
} catch (SecurityException e) {
System.out.println("没有权限");
} catch (IOException e) {
System.out.println("其他 IO 错误");
}
```bash
对比错误码写法,这段代码的意图一目了然:哪种错走哪个分支,全靠类型说话。
## Throwable 继承树
Java 的异常全部是类,它们有一棵共同的继承树,根是 `Throwable`。
```text
┌───────────┐
│ Object │
└───────────┘
▲
┌───────────┐
│ Throwable │
└───────────┘
▲
┌─────────┴─────────┐
┌───────────┐ ┌───────────┐
│ Error │ │ Exception │
└───────────┘ └───────────┘
▲ ▲
┌───────┘ ┌────┴──────────┐
┌─────────────────┐ ┌──────────────────┐ ┌───────────┐
│OutOfMemoryError │… │ RuntimeException │ │IOException│…
└─────────────────┘ └──────────────────┘ └───────────┘
▲
┌───────────┴─────────────┐
┌─────────────────────┐ ┌─────────────────────────┐
│NullPointerException │ │IllegalArgumentException │…
└─────────────────────┘ └─────────────────────────┘
记住三个层次就够用了。
Throwable 是所有可抛出对象的祖先,它直接继承 Object。只有 Throwable 及其子类的对象才能被 throw 抛出、被 catch 捕获。
Throwable 底下分成两大阵营:Error 和 Exception。这两个分支的性质完全不同。
Error:别管,管不了
Error 代表 JVM 层面的严重故障,属于「程序自己修不好」的那一类。
OutOfMemoryError:内存耗尽StackOverflowError:栈溢出,通常是无限递归导致的NoClassDefFoundError:运行时找不到某个类文件
碰上 Error,正确姿势是让程序挂掉,然后去查是内存配置不够,还是代码写出了死循环。在业务代码里捕获 Error 几乎没有意义,捕获了也无力回天。
Warning别写
catch (Throwable t)这种「一网打尽」的代码。它会连OutOfMemoryError一起吞掉,让真正致命的问题被掩盖在日志里。
Exception:这才是你要处理的
Exception 代表程序运行中可预期、可处理的错误,这是我们平时打交道最多的分支。
Exception 内部又分两类,这个划分直接决定了编译器怎么对待你的代码:
RuntimeException及其子类- 其余所有
Exception子类,比如IOException、SQLException
受检异常:编译器盯着你
第二类(非 RuntimeException)叫受检异常(Checked Exception)。
「受检」的意思是——编译器会检查。只要一个方法可能抛出受检异常,调用它的代码就必须做出选择:要么当场 try...catch 捕获,要么在自己的方法签名上写 throws 继续往外扔。
两样都不做?编译不通过。
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class CheckedDemo {
public static void main(String[] args) {
// Files.readString 声明了 throws IOException,必须处理
try {
String content = Files.readString(Path.of("demo.txt"));
System.out.println(content);
} catch (IOException e) {
System.out.println("读文件失败:" + e.getMessage());
}
}
}
```bash
如果把 `try...catch` 去掉,编译器会直接报错:`unreported exception IOException; must be caught or declared to be thrown`。
这套强制机制的用意很实在:**文件可能不存在、网络可能断,这些是客观事实,你不能假装看不见**。
常见的受检异常有 `IOException`、`FileNotFoundException`、`SQLException`、`ClassNotFoundException`、`InterruptedException`。
## 非受检异常:编译器不管
`RuntimeException` 及其子类,还有整个 `Error` 分支,都属于**非受检异常**(Unchecked Exception)。
编译器完全不管它们。你既不用捕获,也不用声明,代码照样能编译通过。
```java
public class UncheckedDemo {
public static void main(String[] args) {
int[] arr = new int[3];
// 编译没有任何提示,运行时才炸
System.out.println(arr[5]); // ArrayIndexOutOfBoundsException
}
}
为什么放它们一马?因为这类异常基本都是代码逻辑写错了。
数组越界,说明你的下标计算有 bug。空指针,说明你没判断对象是否为 null。参数非法,说明调用方传错了值。
这些问题的正解是改代码,不是加 try...catch 遮丑。编译器要是逼你在每次数组访问、每次方法调用外面都套一层 try,代码就没法看了。
常见的非受检异常:
| 异常类 | 触发场景 |
|---|---|
NullPointerException | 对 null 调用方法或访问字段 |
ArrayIndexOutOfBoundsException | 数组下标越界 |
IndexOutOfBoundsException | 集合/字符串索引越界 |
ClassCastException | 强制类型转换失败 |
IllegalArgumentException | 方法收到不合法的参数 |
NumberFormatException | 字符串转数字失败(IllegalArgumentException 的子类) |
ArithmeticException | 整数除以零 |
Note「编译器不强制捕获」不等于「不该捕获」。比如解析用户输入时,
NumberFormatException就该捕获并给出友好提示——用户输错字符不是你的 bug。是否捕获,看具体场景。
一张判定表
新手最容易混的就是这几个概念的关系。用一张表理清楚:
| 类型 | 编译器强制处理 | 典型代表 | 该怎么办 |
|---|---|---|---|
Error | 否 | OutOfMemoryError | 让它崩,去查环境或配置 |
RuntimeException | 否 | NullPointerException | 改代码修 bug |
其他 Exception | 是 | IOException | 捕获处理或声明抛出 |
判定口诀:看是不是 RuntimeException 的后代。是,编译器放行;不是(且属于 Exception 分支),编译器强制你表态。
Throwable 的常用方法
不管哪种异常,都继承了 Throwable 的这几个方法,用得非常频繁:
public class ThrowableMethodDemo {
public static void main(String[] args) {
try {
Integer.parseInt("hello");
} catch (NumberFormatException e) {
// 错误描述文本
System.out.println(e.getMessage());
// 类名 + 消息
System.out.println(e.toString());
// 打印完整调用栈到标准错误流
e.printStackTrace();
}
}
}
```java
运行输出大致是这样:
```text
For input string: "hello"
java.lang.NumberFormatException: For input string: "hello"
java.lang.NumberFormatException: For input string: "hello"
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
at java.base/java.lang.Integer.parseInt(Integer.java:665)
at java.base/java.lang.Integer.parseInt(Integer.java:781)
at ThrowableMethodDemo.main(ThrowableMethodDemo.java:4)
printStackTrace() 打印的调用栈从下往上读,最下面是 main 方法,最上面是真正抛异常的那一行。定位 bug 时,先看最上面属于你自己代码的那一行。
Tip调用栈里
java.base/开头的都是 JDK 内部的类。跳过它们,直接找你自己的类名,那才是问题源头。
小结
Java 用异常对象代替错误码,让错误自带类型信息,还能跨越多层调用向上传播。
Throwable 是根,往下分 Error(严重故障,别管)和 Exception(可处理错误,重点关注)。
Exception 里,RuntimeException 分支属于非受检异常,编译器不管,出现了就去改代码;其余分支属于受检异常,编译器强制你捕获或声明。
判断一个异常归哪类,只需要问一句:它是 RuntimeException 的子类吗。