Java 把异常分成两类:编译器强制你处理的受检异常,和不强制的非受检异常。学会 try、catch 和 finally,学会恰当地抛出和包装异常,再看 try-with-resources 如何按相反顺序关闭资源。
异常是 Java 方法表达“我做不完这件事”的方式。有些异常编译器要求你处理,有些不要求,这种划分决定了 Java 代码怎样应对失败。故事的另一半是清理:把打开的东西关掉,即使中途出了错也一样。
本文讲异常的层次结构、受检异常和非受检异常、try、catch 和 finally、抛出和包装异常、自定义异常,以及 try-with-resources。最后是三个要避免的习惯。下面每个程序都在 Java 25 上跑过,输出直接从运行结果粘贴而来。想自己运行,就把代码存成 Main.java,再执行 java Main.java。
异常的层次结构
Java 里的每个异常都是一个对象,它的类继承自 Throwable。沿着三个常见异常的父类一路往上走,就能看到整棵家族树:
void main() {
var types = List.of(
IOException.class,
IllegalArgumentException.class,
StackOverflowError.class);
for (var type : types) {
var chain = new ArrayList<String>();
for (Class<?> c = type; c != null; c = c.getSuperclass()) {
chain.add(c.getSimpleName());
}
IO.println(String.join(" -> ", chain));
}
}
输出:
IOException -> Exception -> Throwable -> Object
IllegalArgumentException -> RuntimeException -> Exception -> Throwable -> Object
StackOverflowError -> VirtualMachineError -> Error -> Throwable -> Object
Throwable 下面有两个分支:
Error表示 JVM 自己出了问题:内存耗尽、栈溢出、类文件损坏。你的代码通常修不好这些,所以不去捕获它们。Exception表示出了错,但程序也许能处理。
Exception 还会再分一次。RuntimeException 及其子类是非受检的。其余所有 Exception 都是受检的。Error 也是非受检的。两者的区别完全在于编译器要求你做什么。
受检异常:要么捕获,要么声明
受检异常必须被捕获,或者在方法的 throws 子句里声明,两样都不做的代码,编译器拒绝通过。new URI(text) 可能抛出受检的 URISyntaxException,所以下面的代码无法构建:
URI parse(String text) {
return new URI(text);
}
void main() {
IO.println(parse("https://example.com/docs"));
}
构建失败,报错:
Main.java:2: error: unreported exception URISyntaxException; must be caught or declared to be thrown
return new URI(text);
^
报错信息点出了两条出路。你可以捕获异常,在出错的地方处理它;也可以加上 throws URISyntaxException,把问题交给调用者:
String hostOf(String text) {
try {
return new URI(text).getHost();
} catch (URISyntaxException e) {
IO.println("bad address: " + e.getMessage());
return "unknown";
}
}
URI parse(String text) throws URISyntaxException {
return new URI(text);
}
void main() throws URISyntaxException {
IO.println(hostOf("https://example.com/docs"));
IO.println(hostOf("https://example.com/my docs"));
IO.println(parse("https://example.org/api").getPath());
}
输出:
example.com
bad address: Illegal character in path at index 22: https://example.com/my docs
unknown
/api
hostOf 捕获异常并返回一个备用值,所以它的调用者永远看不到这个异常。parse 声明了异常,所以每个调用者都得重新做同样的选择。main 也声明了它。如果异常逃出了 main,程序会带着栈跟踪停下,本系列第一篇就演示过。
非受检异常两样都不需要。Integer.parseInt 抛出的 NumberFormatException 是一个 RuntimeException,你调用它时完全可以不写 try,也不写 throws。
用十岁孩子能懂的话说
受检异常就像包裹上贴的“易碎”标签。每个经手这个包裹的人都得注意到这张标签。你要么自己小心地拆开包裹,要么把它交给下一个人,标签还留在上面。你不能把标签撕掉,假装它不存在。
非受检异常是没贴标签的包裹。它照样可能摔坏,但一路上没有人被迫去操心。
准确的说法
编译器会跟踪每条语句可能抛出哪些受检异常。它从被调用的方法和构造器的 throws 子句里读出这些信息,也从你自己写的 throw 语句里读。每个受检异常都必须被外层针对该类型或其父类型的 catch 处理,或者列在你正在写的方法的 throws 子句里。这项检查在编译时进行,运行中的程序里完全没有它:JVM 对受检异常和非受检异常一视同仁。
这个比喻的局限:真正的标签跟着包裹走,谁都能看到。Java 的检查只在 javac 编译 Java 源码时发生。运行时没有人去找标签,所以用其他 JVM 语言编译出来的代码,或者用技巧隐藏了类型的代码,可以让受检异常穿过一个从未声明它的方法。
该用哪一种
通常的规则是:编程错误用非受检异常,调用者有理由恢复的情况用受检异常。在不允许的地方出现 null、索引越过末尾、数量为负,这些都是调用方代码的 bug。强迫每个调用者都去捕获,只会增加噪音,所以它们是 RuntimeException。文件不存在、网络断开、用户输入的文本不是合法地址,这些是调用者能处理的,比如再问一次或者用默认值。Java 把它们设计成了受检异常。
这条规则至今仍有争议。受检异常让失败出现在方法签名里,编译器也不会让你漏掉一个。但它们会蔓延到程序的每一层,诱使人写空的 catch 块只为让报错消失,而且和 lambda 表达式配合得不好。forEach(s -> IO.println(new URI(s))) 会报同样的 unreported exception 错误,因为 forEach 接收的接口,其方法没有声明任何受检异常。Kotlin、Scala 和 C# 干脆不要受检异常,很多现代 Java 库也只抛非受检异常。JDK 本身两种都在用,所以两种你都得用得顺手。
try、catch 和 finally
try 块运行可能抛出异常的代码,每个 catch 写明它处理的类型。一个 catch 可以列出多个类型,用 | 分隔,这叫多重捕获(multi-catch):
int port(String text) {
try {
int n = Integer.parseInt(text);
return List.of(80, 443, 8080).get(n);
} catch (NumberFormatException | IndexOutOfBoundsException e) {
IO.println("fallback, because " + e.getClass().getSimpleName());
return 80;
}
}
void main() {
IO.println(port("1"));
IO.println(port("one"));
IO.println(port("7"));
}
输出:
443
fallback, because NumberFormatException
80
fallback, because ArrayIndexOutOfBoundsException
80
看最后几行。我们向只有三个元素的 List.of 要第 7 个元素,预期得到 IndexOutOfBoundsException。列表实际抛出的是它的子类 ArrayIndexOutOfBoundsException,因为这个列表底层是数组。catch 照样生效,因为 catch 处理写明的类型及其所有子类。这正是应该捕获文档里写的类型、而不去猜具体类的理由。
多重捕获里的类型之间不能有继承关系。NumberFormatException | IllegalArgumentException 会报 Alternatives in a multi-catch statement cannot be related by subclassing,因为父类已经涵盖了子类。
宽的 catch 放在窄的前面,编译不通过
catch 块从上到下依次尝试,第一个类型匹配的胜出。所以父类型放在它自己的子类上面,下面那个 catch 就永远到达不了:
void main() {
try {
IO.println(Integer.parseInt("forty"));
} catch (RuntimeException e) {
IO.println("something went wrong");
} catch (NumberFormatException e) {
IO.println("not a number");
}
}
构建失败,报错:
Main.java:6: error: exception NumberFormatException has already been caught
} catch (NumberFormatException e) {
^
先放窄的类型,再放宽的。编译器也检查另一个方向:捕获 try 块根本不可能抛出的受检异常,比如给一句普通的 IO.println 包上 IOException 的 catch,会报 exception IOException is never thrown in body of corresponding try statement。
finally 总会执行
try 结束时,finally 块就会执行,不管它是正常结束、抛出了异常,还是返回了:
String check(String text) {
try {
IO.println("try: parsing " + text);
Integer.parseInt(text);
return "number";
} catch (NumberFormatException e) {
IO.println("catch: " + e.getMessage());
return "not a number";
} finally {
IO.println("finally: done with " + text);
}
}
void main() {
IO.println("result: " + check("42"));
IO.println("result: " + check("forty"));
}
输出:
try: parsing 42
finally: done with 42
result: number
try: parsing forty
catch: For input string: "forty"
finally: done with forty
result: not a number
return "number" 先执行,定下了返回值,但方法要等 finally 输出它那一行之后才离开。然后 main 才拿到结果。走 catch 的那条路也是一样。
finally 里的 return 会吞掉异常
会返回的 finally 块会取代 try 正在做的事,包括一个正要抛出去的异常。javac 会对此发出警告,但只在你用 -Xlint 要求警告时才会。本系列用 -Xlint:all -Werror 编译每个示例,这会把警告变成构建失败:
int risky() {
try {
throw new IllegalStateException("the order was lost");
} finally {
return -1;
}
}
void main() {
IO.println("risky() returned " + risky());
IO.println("no exception reached main");
}
构建失败,报错:
Main.java:6: warning: [finally] finally clause cannot complete normally
error: warnings found and -Werror specified
用普通的 javac Main.java,或者用 java Main.java,完全没有警告,程序照常运行。为了展示它的行为,这个版本用 @SuppressWarnings("finally") 关掉了警告:
@SuppressWarnings("finally")
int risky() {
try {
throw new IllegalStateException("the order was lost");
} finally {
return -1;
}
}
void main() {
IO.println("risky() returned " + risky());
IO.println("no exception reached main");
}
输出:
risky() returned -1
no exception reached main
IllegalStateException 消失了。没有栈跟踪,没有消息,调用者拿到 -1,好像一切正常。finally 只用来做清理,永远不要在里面 return、break 或 throw。
抛出异常
throw 语句接受任何 Throwable 对象,而你能放进去的最有用的东西,是一条告诉读者哪里出错的消息。对于错误的参数,JDK 给了你两个工具:IllegalArgumentException,以及针对 null 的 Objects.requireNonNull:
record Transfer(String from, String to, long cents) {
Transfer {
Objects.requireNonNull(from, "from account is required");
Objects.requireNonNull(to, "to account is required");
if (cents <= 0) {
throw new IllegalArgumentException(
"cents must be positive, got " + cents + " for " + from + " -> " + to);
}
}
}
void main() {
IO.println(new Transfer("ana", "ben", 500));
try {
new Transfer("ana", null, 500);
} catch (NullPointerException e) {
IO.println(e.getMessage());
}
new Transfer("ana", "ben", -500);
}
输出后停止:
Transfer[from=ana, to=ben, cents=500]
to account is required
Exception in thread "main" java.lang.IllegalArgumentException: cents must be positive, got -500 for ana -> ben
参数不是 null 时,requireNonNull 返回这个参数;是 null 时,它抛出带着你的消息的 NullPointerException。不写消息的话,getMessage() 返回 null,对谁都没有帮助。
IllegalArgumentException 的消息说明了规则是什么、哪个值违反了它、它属于哪笔转账。拿它和一个光秃秃的 "invalid" 比一比。这行字出现在日志里时,前一种写法会告诉你该去哪里查。讲 record 的那一篇介绍了像这样的紧凑构造器。
包装异常要保留原因
捕获一个底层异常、再抛出一个更有意义的异常时,把原来的异常作为原因(cause)传进去。每个标准异常都有一个接受 (String message, Throwable cause) 的构造器:
int readPort(String text) {
try {
return Integer.parseInt(text);
} catch (NumberFormatException e) {
throw new IllegalStateException("config: port must be a number", e);
}
}
int readPortLogged(String text) {
try {
return readPort(text);
} catch (IllegalStateException e) {
IO.println("log: " + e.getMessage());
throw e;
}
}
void main() {
try {
readPortLogged("eighty");
} catch (IllegalStateException e) {
IO.println("error: " + e.getMessage());
IO.println("caused by: " + e.getCause());
}
}
输出:
log: config: port must be a number
error: config: port must be a number
caused by: java.lang.NumberFormatException: For input string: "eighty"
这里发生了两件事。readPort 做的是包装:新异常从配置的角度解释问题,而 getCause() 仍然保存着原来的 NumberFormatException。readPortLogged 做的是重新抛出:它先记日志,再用 throw e; 抛出同一个对象,所以调用者看到的正是 readPort 抛出的那个异常。
如果包装后的异常没有被捕获,栈跟踪会把两个都打印出来。在 IllegalStateException 的调用帧之后,会有一行以 Caused by: java.lang.NumberFormatException: For input string: "eighty" 开头,接着是那个异常自己的调用帧,以及一行类似 ... 1 more 的内容,代表两者共有的帧。读很长的栈跟踪时,从最后一个 Caused by: 往上读。最底下那个通常就是问题的源头。
把构造器里的 e 去掉,后半部分就整个消失了。丢掉原因,是让调试时间变长的最常见方式之一。
自定义异常
自定义异常就是一个类:继承 Exception 得到受检异常,继承 RuntimeException 得到非受检异常。下面是最小的版本:
class InsufficientFundsException extends Exception {
InsufficientFundsException(String message) {
super(message);
}
}
void main() {
IO.println(new InsufficientFundsException("short by 30"));
}
构建失败,报错:
Main.java:1: warning: [serial] serializable class Main.InsufficientFundsException has no definition of serialVersionUID
error: warnings found and -Werror specified
Throwable 实现了 Serializable,所以每个异常都是可序列化的,而 -Xlint:all 要求每个可序列化的类为它的序列化形式声明一个版本号。普通的 javac 不会吭声,不过加上这个字段也只要一行。再留意警告里的名字:Main.InsufficientFundsException。在紧凑源文件里,你声明的每个类都位于一个隐藏的 Main 类内部。
好的自定义异常会把处理者需要的事实放在字段里,而不只是写在消息文本中:
class InsufficientFundsException extends Exception {
private static final long serialVersionUID = 1L;
final String account;
final long shortByCents;
InsufficientFundsException(String account, long shortByCents) {
super("account " + account + " is short by " + shortByCents + " cents");
this.account = account;
this.shortByCents = shortByCents;
}
}
class Account {
final String id;
long balanceCents;
Account(String id, long balanceCents) {
this.id = id;
this.balanceCents = balanceCents;
}
void withdraw(long cents) throws InsufficientFundsException {
if (cents > balanceCents) {
throw new InsufficientFundsException(id, cents - balanceCents);
}
balanceCents -= cents;
}
}
void main() {
var account = new Account("ACC-7", 1_000);
try {
account.withdraw(400);
account.withdraw(900);
} catch (InsufficientFundsException e) {
IO.println(e.getMessage());
IO.println("offer a top-up of " + e.shortByCents + " cents to " + e.account);
}
IO.println("balance: " + account.balanceCents);
IO.println(new InsufficientFundsException("X", 1));
}
输出:
account ACC-7 is short by 300 cents
offer a top-up of 300 cents to ACC-7
balance: 600
Main$InsufficientFundsException: account X is short by 1 cents
处理者直接读取 e.shortByCents,不必再从字符串里把数字挑出来。第二次取款在 balanceCents -= cents 执行之前就抛出了异常,所以余额保持在 600。
最后一行又一次暴露了那个隐藏的类。异常的 toString 使用二进制类名 Main$InsufficientFundsException,未捕获的异常也会在栈跟踪里打印这个名字。在由真实文件组成的项目里,它就是普通的类名。
钱不够是调用者能处理的情况,所以这个异常是受检的。如果是你自己代码内部的规则被破坏,就改为继承 RuntimeException,调用者也不需要 throws。无论哪种,只要这个异常将来可能包装别的异常,就加一个接受 Throwable cause 的构造器。
try-with-resources 替你关闭资源
try-with-resources 语句在 try 后面的括号里声明资源,并在代码块结束时逐个关闭它们,不管代码块是怎么结束的。资源是任何实现了 AutoCloseable 的对象,这个接口只有一个方法 close()。文件、套接字和数据库连接都实现了它。为了让输出可预测,本文用一个在打开和关闭时输出信息的小类:
class Res implements AutoCloseable {
final String name;
Res(String name) {
this.name = name;
IO.println("open " + name);
}
@Override
public void close() {
IO.println("close " + name);
}
}
void main() {
try (var a = new Res("A"); var b = new Res("B")) {
IO.println("using " + a.name + " and " + b.name);
}
IO.println("after the try");
}
输出:
open A
open B
using A and B
close B
close A
after the try
资源按声明顺序打开,按相反顺序关闭。当一个资源依赖另一个时,这一点很重要。带缓冲的写入器包装着文件,所以写入器必须先刷新并关闭,底下的文件才能关闭。
用 -Xlint:all 编写这个示例时,碰到了两个小意外:
- 从不使用的资源会收到警告。 如果 try 块是空的,javac 会报
auto-closeable resource a is never referenced in body of corresponding try statement。如果你确实只需要打开和关闭,就把它命名为_,写成try (var _ = new Res("A")),警告就消失了。 close()应该只声明它真正会抛出的异常。AutoCloseable.close()声明的是throws Exception。Res.close()保留这个子句时,javac 警告它could throw InterruptedException。解决办法是像上面那样什么都不声明,或者声明一个具体的类型,比如IOException。
try 块抛出异常,资源照样关闭
即使 try 块抛出异常,资源也会被关闭,而且是在同一条语句的任何 catch 执行之前关闭:
class Res implements AutoCloseable {
final String name;
Res(String name) {
this.name = name;
IO.println("open " + name);
}
@Override
public void close() {
IO.println("close " + name);
}
}
void main() {
try (var a = new Res("A"); var b = new Res("B")) {
IO.println("using " + a.name + " and " + b.name);
throw new IllegalStateException("disk full");
} catch (IllegalStateException e) {
IO.println("caught: " + e.getMessage());
}
}
输出:
open A
open B
using A and B
close B
close A
caught: disk full
两个资源都在 caught: disk full 输出之前关闭了。变量 a 和 b 在 catch 里甚至不在作用域内,这是有意为之:catch 执行时,资源早已关闭。
看着资源关闭
一步一步看,顺序更清楚。下面的动画跟着上面的程序走,右侧逐行显示它的输出:
try-with-resources 先打开 A,再打开 B。try 块抛出异常。在异常离开 try 块之前,B 关闭,然后 A 关闭,之后 catch 块才收到这个异常。
如果动画没有播放,下面用文字写出这些步骤:
try语句开始,此时还没有打开任何资源。new Res("A")运行,输出open A,成为资源a。new Res("B")运行,输出open B,成为资源b。- try 块输出
using A and B,然后抛出IllegalStateException。 - 异常离开
try之前,Java 关闭资源,从最后打开的那个开始。b.close()输出close B。 a.close()输出close A。- 两个都关闭后,异常到达
catch (IllegalStateException e),输出caught: disk full。
close() 也抛出异常时:被抑制的异常
如果 try 块抛出异常,某个 close() 也抛出异常,那么 try 块的异常胜出,关闭时的失败作为被抑制的(suppressed)异常附加在它上面。你可以用 getSuppressed() 读取它们:
class Res implements AutoCloseable {
final String name;
Res(String name) {
this.name = name;
IO.println("open " + name);
}
@Override
public void close() {
IO.println("close " + name);
throw new IllegalStateException("close failed: " + name);
}
}
void main() {
try (var a = new Res("A"); var b = new Res("B")) {
IO.println("using " + a.name + " and " + b.name);
throw new RuntimeException("body failed");
} catch (RuntimeException e) {
IO.println("caught: " + e.getMessage());
for (Throwable s : e.getSuppressed()) {
IO.println("suppressed: " + s.getMessage());
}
}
}
输出:
open A
open B
using A and B
close B
close A
caught: body failed
suppressed: close failed: B
suppressed: close failed: A
B 的 close() 抛出了异常,Java 仍然调用了 A 的 close()。什么都没有丢。try 块的异常通常才是真正的问题,所以你捕获的是它,关闭时的失败按发生顺序附在上面。未捕获的异常会在栈跟踪里的 Suppressed: 下面打印它们。
拿它和旧写法比一比:在 finally 里调用 close()。那种写法下,close() 抛出的异常会取代 try 块的异常,原来的问题就消失了,和 finally 里写 return 的情况一样。
关闭一个已有的变量
从 Java 9 开始,括号里可以写一个已有的变量,而不必声明新变量,只要它是 final 或实际上的 final(effectively final),也就是从未被重新赋值:
class Res implements AutoCloseable {
final String name;
Res(String name) {
this.name = name;
IO.println("open " + name);
}
@Override
public void close() {
IO.println("close " + name);
}
}
Res openLog() {
return new Res("log");
}
void main() {
Res log = openLog();
try (log; var _ = new Res("lock")) {
IO.println("writing to " + log.name);
}
}
输出:
open log
open lock
writing to log
close lock
close log
我们用一个传统的类核实过版本:javac --release 8 拒绝 try (r),报 variables in try-with-resources are not supported in -source 8,而 --release 9 接受它。再给 log 赋一次值,javac 就会拒绝,报 variable log used as a try-with-resources resource neither final nor effectively final。这条规则确保被关闭的对象正是你想关闭的那个。
三个要避免的习惯
大多数异常相关的 bug 来自少数几个习惯,其中最糟的一个,是把真正的 bug 藏在一个一声不吭的 catch 后面。下面的循环把价格加起来,跳过读不懂的价格:
record Item(String name, String price) {}
int total(List<Item> items) {
int sum = 0;
for (var item : items) {
try {
sum += Integer.parseInt(item.price().strip());
} catch (Exception e) {
// skip prices we can't read
}
}
return sum;
}
void main() {
var items = new ArrayList<Item>();
items.add(new Item("tea", "3"));
items.add(new Item("cake", "4 euros"));
items.add(new Item("coffee", null));
items.add(new Item("water", "2"));
IO.println("total: " + total(items));
}
输出:
total: 5
"4 euros" 抛出了 NumberFormatException,这正是那个 catch 要处理的。但咖啡的价格是 null,所以 item.price().strip() 抛出了 NullPointerException,这是另一个 bug,而 catch (Exception e) 把它也吞掉了。即使开了 -Xlint:all,javac 也没有对空的 catch 块发出警告。这是三个错误合在一起:
- 空的 catch 块。 至少要把跳过的内容记下日志。如果你确实想忽略某个异常,就在注释里说明原因,并把变量命名为
_,写成catch (NumberFormatException _),让读者知道这是故意的。 - 捕获
Exception。 捕获你预期的类型,这里是NumberFormatException。这样null在第一次运行时就会大声崩溃,就会有人去修数据。 - 用异常处理正常的控制流。 如果错误的价格本来就是正常输入,就先用
if检查。异常应该意味着发生了意料之外的事,而且为每一行都构建一次栈跟踪,也是额外的开销。
要点
- 所有被抛出的东西都继承自
Throwable。Error用于你不去捕获的 JVM 问题,RuntimeException及其子类是非受检异常,其余所有Exception都是受检异常。 - 编译器要求你捕获或声明受检异常。编程错误用非受检异常,调用者能恢复的失败用受检异常。
catch块按从窄到宽排列。finally总会执行,在里面return会悄无声息地吞掉异常。- 抛出异常时,消息要写明规则和出错的值;包装异常时,把原来的异常作为原因传进去。
- 给自定义异常加上
serialVersionUID,并用字段保存处理者需要的事实。 - try-with-resources 按相反顺序关闭资源,在
catch执行之前关闭,即使 try 块抛出异常也一样。close()的失败会成为被抑制的异常。 - 不要留下空的 catch 块,也不要为了掩盖一个没有说清的问题而捕获
Exception。
能处理的就捕获,处理不了的就往上传,关闭的事交给 try-with-resources。