程序顺利运行时,方法调用像一条直路:调用、计算、返回。真正决定程序是否可靠的,往往是直路走不通之后的行为。配置文件缺失,要不要换默认值?一条导入记录格式错误,是跳过这一条,还是让整批任务失败?数据库调用失败,底层异常应该原样穿透,还是翻译成当前业务能理解的失败?
Java 的异常机制给这些问题提供了一套控制流协议。throw 报告“当前方法无法按约定完成”,throws 把可能失败的结果写进方法契约,catch 在有能力恢复的边界接管,finally 与 try-with-resources 则负责把清理动作收住。异常对象还携带类型、消息、调用栈、原因链和被抑制异常,让失败能够跨越多层调用而不丢失上下文。
这一章按失败的传播路径来学习:先看异常怎样改变控制流,再辨认 Throwable 层级和 checked/unchecked;随后拆开捕获、恢复、finally、资源关闭、调用栈、异常翻译和自定义异常;最后把它们放进一个可运行的批量导入程序。示例以 Java SE 26 的正式语义为准,带 public class 的完整代码块应保存为同名 .java 文件。
异常处理的目标不是让程序“永远不退出”,而是让失败停在合适的边界:能恢复就恢复,不能恢复就保留原因继续传播,并确保资源和对象状态仍然可控。
先看一个常见但不理想的接口:查询库存失败时返回 -1。调用者如果忘记检查,-1 可能继续参与金额和数量计算;而且一旦“缺货”本来就允许用 -1 表示,失败信号还会和合法数据冲突。异常把正常返回值与失败路径分开:方法要么按契约返回,要么以一个 Throwable 对象突然完成。
static int parsePort(String text) {
int port = Integer.parseInt(text); // 可能抛 NumberFormatException
if (port < 1 || port > 65_535) {
throw new IllegalArgumentException("端口必须在 1 到 65535 之间");
}
return port;
}这里有两种失败。文本不是整数时,Integer.parseInt 抛出 NumberFormatException;整数超出业务范围时,我们主动抛出 IllegalArgumentException。无论哪一种,parsePort 都不会返回一个“凑合用”的端口。
假设 main 调用 loadConfig,loadConfig 再调用 parsePort。异常从 parsePort 抛出时,当前语句之后的代码不再执行,parsePort 也不会从调用点恢复。运行时沿当前线程的动态调用链向外寻找第一个匹配的 catch。寻找过程中,尚未完成的方法会逐层突然结束,这就是调用栈展开。
static void loadConfig(String text) {
int port = parsePort(text);
System.out.println("使用端口:" + port); // parsePort 失败时跳过
}
public static void main(String[] args) {
try {
loadConfig("abc");
} catch (NumberFormatException e) {
System.out.println("端口必须写成整数");
catch 正常完成后,控制流从整个 try-catch 后面继续,而不是回到 parsePort 的失败语句重试。若一路都找不到处理器,未捕获异常处理器会接手;对只有 main 线程的简单程序来说,通常表现为打印异常和栈轨迹,然后该线程终止。

异常中断当前正常路径,沿调用栈向外寻找第一个匹配处理器;处理完成后从整个 try-catch 之后继续。
异常适合“方法无法履行约定”的路径,不适合替代普通分支。列表中没找到元素,如果 API 已把“找不到”定义成常见结果,可以返回 Optional、空集合或清楚的状态对象;循环读数据时也应先用 hasNext、边界条件或协议结束标志。把每次循环结束都实现成抛异常,会把预期流程伪装成失败,也增加理解和运行成本。
只有 Throwable 及其子类实例能被 throw 抛出,也只有这些类型能写在 catch 参数里。它有两条直接分支:Error 与 Exception。分类的关键不是“异常发生在编译期还是运行期”,而是类型在继承树中的位置。
Throwable
├─ Error unchecked
│ ├─ OutOfMemoryError
│ └─ StackOverflowError
└─ Exception
├─ RuntimeException unchecked
│ ├─ NullPointerException
│ ├─ IllegalArgumentException
│ │ └─ NumberFormatException
│ └─ IndexOutOfBoundsException
└─ IOException checked
└─ FileNotFoundExceptionError 表示普通应用通常不应尝试捕获的严重问题,例如虚拟机资源耗尽或链接失败。catch (Throwable e) 会连这些问题也接住,程序却往往没有足够资源或可信状态来恢复。除非你正在编写容器、监控边界等确有策略的底层设施,否则不要把 Error 当成普通业务失败处理。
unchecked 类型包括 RuntimeException 及其子类,以及 Error 及其子类。其余 Throwable 子类属于 checked 类型。方法可能把 checked 异常传出时,编译器要求它在本方法内被捕获,或在 throws 中声明;unchecked 异常可以不声明。

checked/unchecked 由异常类在继承树中的位置决定,不表示异常发生在“编译时”还是“运行时”。
“checked 异常就是编译时异常”容易误导。checked 异常仍然在程序运行时被创建和抛出;“checked”说的是编译器会检查捕获或声明是否完整。
可以先问调用者是否有一条合理、常见的恢复路径。外部文件暂时不可读,调用者可能换文件、重试或向上报告,checked IOException 能把这种失败写入契约。调用者违反方法前置条件,例如把负数传给只接受正数的参数,通常使用 unchecked IllegalArgumentException,因为修复方式是改正调用代码,而不是在每一层机械捕获。
这不是绝对的“外部失败/程序错误”二分法。库的抽象层、调用频率、恢复能力和兼容性都会影响选择。最重要的是同一 API 保持一致,并让调用者能从类型和文档看出该做什么。
throw 是可执行语句,后面跟一个异常对象;执行后当前路径突然结束。throws 写在方法或构造器声明中,列出可能越过该边界的 checked 异常类型。它只是契约,不会创建或抛出对象。
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
static String loadToken(Path path) throws IOException {
String token = Files.readString(path).trim();
if (token.isEmpty()) {
throw new IllegalStateException("令牌文件内容为空");
}
return token;
}Files.readString 可能抛出 checked IOException,所以 loadToken 选择继续声明;空内容违反当前配置状态,代码主动抛出 unchecked IllegalStateException。把 unchecked 异常也写进 throws 在语法上允许,但它只起文档作用,编译器不会因此要求调用者捕获。
static void start(Path path) throws IOException {
String token = loadToken(path);
connect(token);
}如果 loadToken 抛出 IOException,token 不会得到值,connect 不会调用,start 也以同一个异常突然结束。调用方若没有捕获,就继续向外传播。运行时寻找的是动态调用链,不是“源码中离 throw 最近的 catch”。
子类重写方法时,可以不抛 checked 异常,也可以抛父类声明异常的同类或更窄子类,但不能增加父类契约没有允许的新 checked 异常:
interface ConfigSource {
String read() throws java.io.IOException;
}
final class MemoryConfig implements ConfigSource {
@Override
public String read() { // 可以不抛 checked 异常
return "mode=test";
}
}原因在于可替换性:只按 ConfigSource 编译的调用者只准备处理 IOException。如果实现类突然扩大为任意 Exception,原调用者的处理契约就被破坏。unchecked 异常不受这条声明限制,但仍应遵守行为约定。
一个 try 可以跟多个 catch。异常发生时,运行时拿异常对象的实际类型依次检查处理器,选择第一个可赋值兼容的参数类型。被选中的 catch 执行一次,后面的处理器不再检查。
try {
int page = Integer.parseInt(input);
printPage(page);
} catch (NumberFormatException e) {
System.out.println("页码必须是整数");
} catch (IllegalArgumentException e) {
System.out.println("页码超出允许范围:" + e.getMessage());
}NumberFormatException 是 IllegalArgumentException 的子类,因此必须放在前面。若先写父类,后面的子类处理器永远不可能被选中,编译器会报错。捕获顺序应从具体到一般,但更根本的问题是:不同处理器是否真的有不同恢复策略。

多个处理器从左到右检查,只执行第一个匹配项;具体类型应放在一般类型之前。
当几种互不具有父子关系的异常采用完全相同处理,可以用 | 合并:
try {
importData(path);
} catch (java.nio.file.NoSuchFileException |
java.nio.file.AccessDeniedException e) {
System.out.println("文件不可用,请重新选择:" + e.getFile());
}多捕获的备选类型不能存在父子关系,例如 IOException | FileNotFoundException 非法,因为前者已经覆盖后者。多捕获参数隐式为 final,不能在处理器里重新赋值。若两种失败需要不同提示、重试次数或状态更新,就应保留独立 catch。
把几十行无关操作放进一个大 try,即使捕获到类型,也难判断哪一步失败、哪些副作用已经发生。更好的做法是让 try 覆盖一个清楚的原子意图,并在修改状态前完成校验:
try {
Order order = parser.parse(line); // 只处理当前记录
accepted.add(order); // parse 成功后才提交
} catch (OrderFormatException e) {
rejected.add(line);
}如果先把半成品加入 accepted 再解析剩余字段,异常会留下不完整状态。异常处理和状态一致性必须一起设计。
捕获不等于处理。打印一句“出错了”然后继续,可能只是把问题藏起来。一个有效处理器至少要做出一种明确决策:修正输入后重试、使用经过确认的替代值、跳过一个独立失败单元、把当前操作回滚,或保留原因翻译后继续上抛。
以“用户输入年龄”为例,可以把工作拆成三层:
NumberFormatException。IllegalArgumentException 或领域异常。static int parseAge(String text) {
int age = Integer.parseInt(text.trim());
if (age < 0 || age > 150) {
throw new IllegalArgumentException("年龄必须在 0 到 150 之间");
}
return age;
}
static int askAge(java.util.Scanner in) {
while (true) {
System.out.
parseAge 不知道输入来自终端、网页还是文件,所以它只验证并报告失败;askAge 掌握交互,可以循环重试。若输入来自批量文件,合适策略可能变成记录行号并跳过坏行,而不是等待键盘输入。
对可在本地稳定判断的前置条件,直接校验通常更清楚,例如索引范围、null、空字符串和金额范围。但不要用“先检查存在,再打开文件”替代对 IOException 的处理:检查与实际打开之间文件可能变化,权限也可能变化。外部世界具有竞争条件,真正的操作仍然必须处理失败。
如果订单扣款失败,返回 true 或空对象让上层继续发货,会把原始失败变成更严重的数据错误。没有恢复能力时,让异常传播比“吞掉后继续”更可靠。应用最外层可以把内部异常转换成用户可理解的响应,同时记录完整诊断信息;用户提示和开发诊断不应混为同一字符串。
在 Java 语言控制流中,finally 会在 try 以及已执行的 catch 之后运行,不论前面是正常结束,还是因 return、break、continue 或异常而突然结束。因此它适合做必须覆盖多条离开路径的收尾。
static int example() {
try {
System.out.println("try");
return 1;
} finally {
System.out.println("finally");
}
}返回值先计算为 1,随后执行 finally,最后方法返回 1。但“finally 绝对必执行”并不准确:如果 JVM 被立即终止、宿主进程被强制杀死、机器断电,Java 代码没有机会继续运行,finally 和 try-with-resources 的关闭都无法发生。System.exit 会启动 JVM 关闭序列并阻塞当前调用;最终 JVM 终止时,当前线程剩余的 finally 不会继续执行。需要跨进程保证的数据应依赖事务、持久化协议和幂等恢复,而不是一句 finally。
static int dangerous() {
try {
return 1;
} finally {
return 2; // 覆盖 try 的返回值
}
}dangerous() 返回 2。同理,如果 try 正在抛异常,而 finally 又执行 return 或抛出另一个异常,原来的离开原因会被丢弃。这会让真正故障消失,所以不要在 finally 中 return,也要谨慎处理可能失败的清理动作。
手写 finally 关闭资源很容易遇到三个问题:资源可能根本没成功创建;close() 自身也可能抛异常;多个资源需要按依赖顺序关闭。只要资源实现 AutoCloseable,优先使用 try-with-resources。finally 更适合无法表示成可关闭资源的状态恢复,例如解锁某个必须配对的自定义状态,但即使如此也应先寻找更安全的结构化 API。
不要写 finally { return ...; }。它可以覆盖正常返回、break、continue 或正在传播的异常,让调用者看到完全不同的结果。
资源通常有依赖关系:先打开底层输出流,再包一层缓冲 writer;关闭时必须反过来,先让外层刷新,再关闭底层。try-with-resources 会按声明从左到右初始化,按相反顺序关闭所有成功初始化且非 null 的资源。
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
static long countNonBlank(Path path) throws IOException {
try (BufferedReader reader =
Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
return reader.lines()
.filter(line -> !line.isBlank())
.count();
}
reader 无论正常返回还是读取失败都会尝试关闭。资源变量的作用域限定在结构内。Java 9 以后,也可以在资源头引用外部已经声明且为 final 或 effectively final 的资源变量,但新代码仍要保证所有权清楚:进入结构后,关闭责任就交给它。

资源从左到右初始化、从右到左关闭;主体失败保留为主异常,关闭失败进入 suppressed。
假设 try 主体抛出异常,随后 close() 也失败。调用者最需要知道的是业务主体为什么失败,因此主体异常继续作为主异常,关闭失败被加入它的 suppressed 列表:
try {
importFile(path);
} catch (Exception e) {
System.err.println("主异常:" + e);
for (Throwable suppressed : e.getSuppressed()) {
System.err.println("关闭时还发生:" + suppressed);
}
}如果主体正常、只有关闭失败,则关闭异常会成为传播结果。多个资源都关闭失败时,由于右侧资源先关,它的异常先成为主关闭异常,后续关闭失败进入 suppressed 列表。一个资源关闭失败不会阻止其他已初始化资源继续尝试关闭。
AutoCloseable.close() 的声明可以抛 Exception,实现类应尽量缩窄异常类型或不抛异常。java.io.Closeable 是更具体的 I/O 资源接口,close() 抛 IOException,并约定重复关闭没有效果。不要因为“垃圾回收最终会处理对象”而延迟关闭文件、套接字、数据库连接等外部资源;内存可达性和操作系统资源生命周期是两件事。
还要留意 new Scanner(System.in):关闭 Scanner 会连带关闭底层 System.in。如果整个应用后面还要读标准输入,就不应在一个局部方法里擅自关闭这个共享资源,资源所有权应由最外层统一管理。
一份完整异常诊断通常有三条线索:调用栈回答“失败沿哪些调用走到这里”,cause 回答“这个高层失败由哪个底层失败引起”,suppressed 回答“为了保留主失败,还有哪些次要失败没有被传播为主异常”。
典型输出可以这样阅读:
OrderImportException: 导入订单失败
at OrderService.importFile(OrderService.java:42)
at App.main(App.java:12)
Caused by: java.nio.file.NoSuchFileException: orders.csv
at ...第一行先看异常类型和消息;随后从最上面的业务代码栈帧开始定位抛出或包装位置;Caused by 再告诉你底层是文件不存在。栈轨迹顶部通常更接近异常创建/抛出点,下面是逐层调用者。框架反射、线程池和代理栈帧可能很多,不要机械地只看第一行,也不要从最底部开始猜。

栈轨迹记录调用路径,cause 记录抽象层之间的因果关系,suppressed 保留没有覆盖主失败的次要异常。
getMessage() 只返回详细消息,允许为 null。toString() 通常包含异常类名和消息。printStackTrace() 把异常、栈帧、suppressed 和原因链输出到标准错误流。getStackTrace() 提供结构化的 StackTraceElement[],适合工具处理。在真实服务里,优先把异常对象交给日志框架,而不是只记录 e.getMessage();后者会丢掉类型、栈和 cause。对终端用户则不应直接展示内部路径、SQL、令牌或完整栈轨迹。可以给用户一个稳定提示和追踪编号,把完整诊断留在受控日志中。
排查时先保存原始输入和完整异常,再寻找 cause 链中最底层的具体失败,以及每一层最靠上的业务栈帧。NullPointerException 出现在 InvoiceService.total,并不自动说明 JVM 有问题;它说明该行使用了 null,还要继续追踪这个值为何到达那里。栈轨迹是调用路径证据,不是根因结论。
当前层没有恢复能力时,可以原样重新抛出:
try {
writeReport();
} catch (java.io.IOException e) {
audit("报表写入失败", e);
throw e; // 同一个对象,原栈轨迹保留
}throw e 与 throw new IOException(e.getMessage()) 完全不同。后者创建新对象,若没有把 e 设为 cause,调用者会失去底层类型、原始栈和完整因果关系。
Java 会对 final 或 effectively final 的 catch 参数做更精确的异常分析。即使参数声明为 Exception,编译器也能根据 try 中实际可能抛出的 checked 类型,推导重新抛出的集合:
import java.io.IOException;
import java.sql.SQLException;
static void refresh() throws IOException, SQLException {
try {
loadFile(); // throws IOException
updateIndex(); // throws SQLException
} catch (Exception e) {
audit("刷新失败", e);
throw e; // e 未被重新赋值,可精确推导
}
}这里不必把声明扩大成 throws Exception。若在单一 catch 中给 e 重新赋值,它不再 effectively final,就失去这种精确推导。多捕获参数本身隐式 final。
业务服务不应强迫调用者理解 JDBC、文件格式或第三方 SDK 的全部细节。可以把底层异常翻译成当前抽象层的异常,同时保留 cause:
final class OrderStoreException extends RuntimeException {
OrderStoreException(String message, Throwable cause) {
super(message, cause);
}
}
static Order loadOrder(long id) {
try {
return repository.find(id);
} catch (java.sql.SQLException e) {
throw new OrderStoreException("读取订单 " +
消息增加当前层上下文,cause 保留底层证据。不要每经过一层就无意义包装一次,否则原因链会变长而信息没有增加。也不要把用户密码、完整令牌或敏感数据拼进异常消息。
标准异常已经能清楚表达非法参数、非法状态、I/O 失败等通用情况时,不必为每一条提示新建类型。自定义异常的价值在于给失败一个稳定的领域含义,让调用者能按类型采取动作,例如 QuotaExceededException、OrderFormatException 或 PaymentDeclinedException。
public final class OrderFormatException extends Exception {
private final int lineNumber;
public OrderFormatException(int lineNumber, String message) {
super(message);
this.lineNumber = lineNumber;
}
public OrderFormatException(
int lineNumber, String message, Throwable cause) {
super(message, cause);
这个类型选择 checked,是因为批量导入调用者可以记录坏行并继续;它还把行号保存为结构化字段,调用者不必从中文消息里正则提取。类名以 Exception 结尾,构造器同时支持消息和 cause,字段创建后不变。
如果 MissingColumnException、BadPriceException 和 UnknownCategoryException 最终都只会“记录行号并跳过”,一个带错误码或字段信息的 OrderFormatException 可能更简单。反过来,如果“库存不足”要提示改数量,而“支付网络超时”要重试,它们应该是不同类型,因为调用者动作不同。
enum ImportProblem {
MISSING_COLUMN,
INVALID_NUMBER,
UNKNOWN_CATEGORY
}可把稳定的机器判断放在枚举字段,把面向人的消息留给展示层。不要让业务逻辑依赖 getMessage() 的具体中文文案。
扩展 Exception 且不落在 RuntimeException 分支中,会得到 checked 类型;扩展 RuntimeException 得到 unchecked 类型。不要因为“省得写 throws”就一律选 unchecked,也不要把每个内部细节都做成 checked 逼调用者机械捕获。先设计恢复责任,再决定继承哪一支。
异常代码最危险的地方往往不是“没 catch”,而是看似处理了,实际上丢失了状态或证据。
try {
save(order);
} catch (Exception e) {
// 什么也不做
}
sendConfirmation(order); // 可能谎称保存成功如果确实允许忽略某个特定失败,应只捕获那个具体类型,并用注释说明为什么安全,必要时留下指标。捕获宽泛 Exception 后静默继续,既可能吞掉程序 bug,也会制造错误成功状态。
catch (Throwable) 会覆盖普通异常与严重 Error;catch (Exception) 虽不捕获 Error,仍可能把 NullPointerException 等程序 bug 和可恢复 I/O 失败混在一起。应用最外层可以有兜底处理器,用于记录、转换响应和保护进程边界,但它不应该假装所有失败都已恢复。
// 不推荐:靠越界结束循环
try {
for (int i = 0; ; i++) {
consume(values[i]);
}
} catch (ArrayIndexOutOfBoundsException ignored) {
}正确写法是 i < values.length。前一种写法会把真正的索引 bug 也当成正常结束,还让读者难以看见边界。
log(e.getMessage()) 丢掉类型、栈和 cause。finally 中抛新异常或返回,会覆盖原始结局。
可靠处理必须有明确动作:恢复、隔离、翻译或继续传播;“捕获过”不等于“处理好”。
下面的程序把前面的原则组合起来。它读取 UTF-8 简化 CSV,每行格式是 商品编码,价格,不处理引号与字段内逗号。一条记录格式错误时,程序记录行号并继续;文件无法读取时,当前任务整体失败并由应用边界报告。读取器由 try-with-resources 关闭,数字解析失败被翻译为带 cause 的领域异常,列表只在整行解析成功后才更新。
import java.io.BufferedReader;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
public class ProductImportDemo {
record Product(String code, BigDecimal price) {
Product {
if (code == null || code.isBlank()) {
throw new IllegalArgumentException
parseLine 只负责一行,失败不会污染已接收列表;importFile 掌握“坏行可跳过”的批处理策略,因此只在这里捕获 RowFormatException。IOException 代表整个输入源不可用,本层无法换文件,于是继续声明给 main。main 是当前应用边界,负责把内部失败转换成用户提示并保留诊断。
至少覆盖这些场景:空文件、只有空行、合法行、字段缺失、字段过多、空编码、负价格、非法数字、合法行与坏行混排、文件不存在、无读取权限,以及读取中途失败。断言不只看“抛没抛”,还要看已接收列表是否保持一致、行号是否准确、cause 是否为 NumberFormatException、资源是否关闭、用户提示是否没有泄露敏感信息。
先把失败按作用域分组:单行失败、整个文件失败、程序配置失败。每组对应不同恢复边界。
再验证状态提交时机。只有 parseLine 完整返回后才把 Product 加入列表,异常路径不会留下半条记录。
最后检查诊断链。领域消息说明哪一行和哪条规则失败,cause 保留底层数字解析证据,I/O 栈则留给应用日志。
写完异常代码后,可以沿着一次失败的全路径检查:
catch 是否具体,顺序是否从子类到父类,多捕获是否只有同一策略?finally 是否含 return 或会覆盖原异常的操作?是否错误地把它当作跨进程保证?异常机制真正带来的价值,是把“失败”从散落的特殊返回值和随手打印,变成可组合的类型契约与控制流。代码不需要捕获一切;它需要在最了解恢复动作的地方捕获,在其他地方诚实传播,并让资源、状态和诊断都经得住失败路径。