11.2 异常处理实战
这是异常机制之后的实战篇。用约 50 分钟跑完 Java 17 示例,你会安排 catch 顺序、用 try-with-resources 管理文件,并设计能保留 cause 与结构化上下文的领域异常。
config.txt 缺失之后
启动程序时,config.txt 不见了。旧代码用一个很大的 catch (Exception) 接住所有错误,然后打印“使用默认配置”。文件不存在时这个策略似乎能工作;文件存在但权限不足、内容损坏或代码出现空指针时,它也会悄悄使用默认值。实际故障被藏起来了。
同一批旧代码还要读取 map.txt,内容是 北区,150,200;客户提交订单时可能余额不足;过期任务要报告 ExpiredTaskException。这些失败属于不同边界,处理方式也不同:能恢复的就地恢复,不能恢复的保留 cause 后传播,资源交给 try-with-resources 关闭。
1. catch 从具体排到通用
一个 try 可能抛出多种异常。JVM 按源码顺序选择第一个类型兼容的 catch。下面的方法只在配置文件缺失时返回 8080,其他读取或格式错误都会向上传播;本例把合法端口限定为 1 到 65535。相关类型需从 java.io、java.nio.file 和 java.nio.charset 导入:
static int loadPort(Path path) {
try {
String input = Files.readString(path, StandardCharsets.UTF_8).strip();
int port = Integer.parseInt(input);
if (port < 1 || port > 65535) {
throw new IllegalArgumentException("port must be in 1..65535");
}
return port;
} catch (NoSuchFileException exception) {
return 8080;
} catch (NumberFormatException exception) {
throw new IllegalArgumentException("port is not an integer", exception);
} catch (IOException exception) {
throw new UncheckedIOException("cannot read port configuration", exception);
}
}NoSuchFileException 是 IOException 的子类,所以子类分支在前。父类放在前面会让子类分支永远不可达,编译器直接拒绝。
catch (Exception) 不是语法错误。它适合少数明确的顶层边界,例如任务执行器要把一次任务标记为失败,随后结束该任务。底层 readConfig 若这么写,会把解析错误、程序缺陷和 I/O 失败混成一句话。
1.1 multi-catch
处理动作完全相同时,可以合并互不继承的异常类型:
catch (NumberFormatException | DateTimeParseException exception) {
reportInvalidConfig(exception);
}备选类型不能有父子关系。NoSuchFileException | IOException 无法编译,因为 IOException 已经包含前者。multi-catch 参数也被视为 final,不能在 catch 内重新赋值。
1.2 catch 住之后做什么
合理的 catch 通常完成一种动作:
- 恢复:缺少可选配置时使用明确的默认值。
- 转换:把 IOException 转成调用方理解的
MapLoadException,并保留 cause。 - 结束当前边界:请求入口记录一次失败,返回错误响应。
只打印一句话再继续,往往既没有恢复,也没有传播。生产日志还需要结构化上下文、级别和统一的记录位置;printStackTrace() 适合临时排查,不是完整的日志策略。
2. 读取 map.txt:资源由谁关闭
旧 helper 直接返回 new BufferedReader(...).readLine()。它没有关闭 Reader,资源可能长期保持打开;不能把 GC 当成及时关闭文件描述符的协议。
现代 Path/Files API 能把路径和编码写清楚:
static String readMap(Path path) throws IOException {
try (BufferedReader reader = Files.newBufferedReader(
path, StandardCharsets.UTF_8)) {
return reader.readLine();
}
}try 括号中的对象必须实现 AutoCloseable。控制流离开 try 时,Java 对已成功取得的非 null 资源调用 close。若初始化后一个资源就失败,前面已取得的资源仍会关闭;未成功取得的资源不在关闭列表里。这适用于正常返回和异常传播。
2.1 多个资源按逆序关闭
try (BufferedReader reader = Files.newBufferedReader(input, UTF_8);
BufferedWriter writer = Files.newBufferedWriter(output, UTF_8)) {
String line = reader.readLine();
if (line != null) writer.write(line);
}这个片段只复制第一行,空文件产生空输出;input 与 output 必须是不同文件,否则打开 writer 时就可能截断输入。资源按声明的逆序关闭:先 writer,后 reader。这与嵌套构造的所有权方向一致。后声明的资源往往依赖先声明的资源,应先释放。
Java 9+ 还允许把已经声明、保持 final 或 effectively final 的资源放进括号:
BufferedReader reader = Files.newBufferedReader(path, UTF_8);
try (reader) {
System.out.println(reader.readLine());
}try 结束后对象已关闭,不要继续读取。
2.2 工作和关闭都失败
假设读取抛出 IOException("读取失败"),随后两个资源的 close 也失败。Java 保留读取异常作为主异常,把关闭异常放进 getSuppressed()。这样调用方不会因为清理失败而失去最初触发栈展开的原因。
手写 finally 很容易反过来:close 抛出的新异常会覆盖读取异常。try-with-resources 已经定义好主次关系,资源代码应优先使用它。
3. 自定义异常表达领域失败
订单需要 50 分,账户里只有 30 分。这里用整数最小货币单位表达固定的小金额,只演示异常契约。IOException、IllegalStateException 都不能直接说明业务上下文。自定义异常可以把所需金额和现有金额作为字段保留下来:
final class InsufficientFundsException extends Exception {
private static final long serialVersionUID = 1L;
private final int required;
private final int available;
InsufficientFundsException(int required, int available) {
super("需要 " + required + " 分,但账户里只有 " + available);
this.required = required;
this.available = available;
}
int required() { return required; }
int available() { return available; }
}调用方可以显示 message,也能用字段决定“还差多少”。不要让业务逻辑从异常文本里解析数字,文本会被翻译和调整。
ExpiredTaskException 需要区分两种语义。如果调用者违反了“任务必须处于可领取状态”的前置条件,RuntimeException 可以表达调用错误;如果正常业务允许任务在确认时过期,返回一个明确的失败结果或 checked exception 可能更便于调用方展示恢复选项。先写调用方要做的事,再选继承树。
3.1 转换时保留 cause
购买模块不想让界面直接依赖 InsufficientFundsException,可以转换为 PurchaseException:
try {
placeOrder(cost, balance);
} catch (InsufficientFundsException exception) {
throw new PurchaseException("购买失败", exception);
}构造器要调用 super(message, cause)。若只传 message,监控和堆栈只能看到“购买失败”,看不到具体的余额不足对象。
异常类不必为每个方法各造一个。名称应表达调用方会采取的处理边界,并携带安全、稳定的上下文。类名以 Exception 结尾是 Java 社区的常见约定,不是编译器要求。
4. 完整实战:区域数据、订单和资源关闭
先准备临时目录;程序内部还会再创建并清理自己的 I/O 临时目录:
chapter11_practice_root=$(mktemp -d)
readonly chapter11_practice_root
mkdir -p "$chapter11_practice_root/out"
cd "$chapter11_practice_root"保存为 ExceptionPracticeDemo.java:
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public class ExceptionPracticeDemo {
public static void main(String[] args) throws Exception {
Path directory = Files.createTempDirectory("ch11-practice-");
Path mapFile = directory.resolve("map.txt");
try (TemporaryFiles temporary = new TemporaryFiles(mapFile, directory)) {
Files.writeString(
temporary.file(), "北区,150,200\n", StandardCharsets.UTF_8);
String mapData;
try (BufferedReader reader = Files.newBufferedReader(
mapFile, StandardCharsets.UTF_8)) {
mapData = reader.readLine();
}
checkEquals("北区,150,200", mapData);
System.out.println("区域数据:" + mapData);
PurchaseException purchaseFailure = expect(
PurchaseException.class,
() -> completePurchase(50, 30));
check(purchaseFailure.getCause() instanceof InsufficientFundsException,
"购买异常必须保留余额不足的 cause");
InsufficientFundsException balanceFailure =
(InsufficientFundsException) purchaseFailure.getCause();
checkEquals(50, balanceFailure.required());
checkEquals(30, balanceFailure.available());
System.out.println("购买失败:" + balanceFailure.getMessage());
List<String> closeOrder = new ArrayList<>();
IOException primaryFailure;
try (CloseProbe first = new CloseProbe("first", closeOrder);
CloseProbe second = new CloseProbe("second", closeOrder)) {
check(first != second, "关闭探针必须是两个对象");
throw new IOException("读取失败");
} catch (IOException exception) {
primaryFailure = exception;
}
checkEquals(List.of("second", "first"), closeOrder);
checkEquals("读取失败", primaryFailure.getMessage());
checkEquals(2, primaryFailure.getSuppressed().length);
checkEquals("关闭 second 失败",
primaryFailure.getSuppressed()[0].getMessage());
checkEquals("关闭 first 失败",
primaryFailure.getSuppressed()[1].getMessage());
System.out.println("关闭顺序:" + closeOrder);
System.out.println("主异常:" + primaryFailure.getMessage()
+ ",suppressed=" + primaryFailure.getSuppressed().length);
}
check(!Files.exists(mapFile) && !Files.exists(directory),
"临时文件必须清理");
System.out.println("异常实战检查通过");
}
static void completePurchase(int cost, int balance) {
try {
placeOrder(cost, balance);
} catch (InsufficientFundsException exception) {
throw new PurchaseException("购买失败", exception);
}
}
static void placeOrder(int cost, int balance)
throws InsufficientFundsException {
if (cost <= 0) {
throw new IllegalArgumentException("cost 必须为正数");
}
if (balance < 0) {
throw new IllegalArgumentException("negative balance");
}
if (balance < cost) {
throw new InsufficientFundsException(cost, balance);
}
}
static final class InsufficientFundsException extends Exception {
private static final long serialVersionUID = 1L;
private final int required;
private final int available;
InsufficientFundsException(int required, int available) {
super("需要 " + required + " 分,但账户里只有 " + available);
this.required = required;
this.available = available;
}
int required() {
return required;
}
int available() {
return available;
}
}
static final class PurchaseException extends RuntimeException {
private static final long serialVersionUID = 1L;
PurchaseException(String message, Throwable cause) {
super(message, cause);
}
}
static final class TemporaryFiles implements AutoCloseable {
private final Path file;
private final Path directory;
TemporaryFiles(Path file, Path directory) {
this.file = file;
this.directory = directory;
}
Path file() { return file; }
@Override
public void close() throws IOException {
IOException failure = null;
for (Path path : List.of(file, directory)) {
try {
Files.deleteIfExists(path);
} catch (IOException exception) {
if (failure == null) failure = exception;
else failure.addSuppressed(exception);
}
}
if (failure != null) throw failure;
}
}
static final class CloseProbe implements AutoCloseable {
private final String name;
private final List<String> closeOrder;
CloseProbe(String name, List<String> closeOrder) {
this.name = name;
this.closeOrder = closeOrder;
}
@Override
public void close() throws IOException {
closeOrder.add(name);
throw new IOException("关闭 " + name + " 失败");
}
}
static <T extends Throwable> T expect(Class<T> type, Runnable action) {
try {
action.run();
} catch (Throwable throwable) {
if (type.isInstance(throwable)) return type.cast(throwable);
throw new AssertionError(
"expected=" + type + ", actual=" + throwable, throwable);
}
throw new AssertionError("expected exception=" + type.getName());
}
static void check(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
static void checkEquals(Object expected, Object actual) {
if (!Objects.equals(expected, actual)) {
throw new AssertionError("expected=" + expected + ", actual=" + actual);
}
}
}编译并运行:
javac --release 17 -encoding UTF-8 -Xlint:all -d out ExceptionPracticeDemo.java
java -cp out ExceptionPracticeDemo预期输出:
区域数据:北区,150,200
购买失败:需要 50 分,但账户里只有 30
关闭顺序:[second, first]
主异常:读取失败,suppressed=2
异常实战检查通过保存需要的输出后清理外层编译目录:
cd
ls -ld -- "$chapter11_practice_root"
rm -r -- "$chapter11_practice_root"示例用临时目录隔离 I/O,不把机器路径打印进输出。TemporaryFiles 也参与 try-with-resources:它依次尝试删除文件和目录,保留清理失败;若正文已经失败,清理异常会附加为 suppressed,不会覆盖主异常。关闭完成后再断言路径不存在。main throws Exception 只用于这个自校验程序的顶层边界,业务入口应根据启动策略报告失败。
4.1 逐步核对
- UTF-8 编码写在读写调用中,
北区,150,200不依赖平台默认编码。 - BufferedReader 在 try-with-resources 结束时关闭。
InsufficientFundsException保存数值字段,PurchaseException保存它作为 cause。CloseProbe按 second、first 的顺序关闭;正文异常仍是主异常,两次 close 失败进入 suppressed 列表。- TemporaryFiles 管理测试夹具清理,并保留关闭失败;可靠业务写入仍需要持久化协议,资源关闭不等于事务提交。
5. finally 的反模式
5.1 finally 中 return
static int hiddenResult() {
try {
return 1;
} finally {
return 2;
}
}调用方只能得到 2。try 中的返回值被覆盖;若 try 抛了异常,异常也会被 return 吞掉。不要在 finally 中写 return、break 或 continue。
5.2 finally 抛出新异常
try {
readMap();
} finally {
closeMap(); // 若这里失败,可能覆盖 readMap 的失败
}资源改用 try-with-resources。其他清理操作若会失败,要设计主异常与清理异常的报告关系,不能随手覆盖。
5.3 把业务提交押在 finally
finally 可能因进程终止而不执行,即使执行也不能撤回已经发出的远程调用。余额扣减、库存转移和文件替换需要事务、幂等操作、补偿或原子文件移动等机制。
6. 常见 catch 反模式
空 catch
try {
loadConfig();
} catch (IOException ignored) {
}只有当失败确实可以忽略,并且变量名、注释或 API 契约解释了原因时,忽略才说得通。例如 Files.deleteIfExists 已把“不存在”设计成返回值,通常不需要空 catch。
log 后原样抛出
catch (IOException exception) {
logger.error("读取失败", exception);
throw exception;
}上层若也记录,会产生重复日志。选择一个拥有请求或任务结果的边界记录;中间层增加上下文时,包装并保留 cause。
用 Exception 控制普通循环
try {
while (true) consume(iterator.next());
} catch (NoSuchElementException end) {
// 用异常表示正常结束
}循环条件应使用 hasNext()。异常栈比普通分支昂贵,语义也不匹配。
吞掉 InterruptedException
try {
queue.take();
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new TaskCancelledException("任务被中断", exception);
}能声明 throws InterruptedException 时直接传播。无法传播时恢复中断标志,再退出当前工作或转成带 cause 的边界异常。空 catch 会让线程池无法按中断协议停止任务。
7. 异常信息与日志边界
有用的异常消息说明操作、对象和可公开的上下文:
读取地图配置失败:file=map.txt, tenant=west不要写入访问令牌、密码、完整连接串或个人数据。异常 message 面向诊断,不应成为程序解析协议;机器要用的字段放进异常属性或结构化错误对象。
日志通常在这些边界出现:HTTP 请求入口、消息消费者、定时任务执行器、命令行 main。底层库负责抛出带 cause 的异常,让边界决定日志级别、用户响应和重试。
重试也不是 catch 后立刻再调用。先判断操作是否幂等,设置次数与退避,并区分永久错误和暂时错误。一次购买请求若已经扣款,盲目重试可能重复扣款。
8. 动手练习
练习 A:完整区域数据加载器
实现 MapLoader.load(Path):
- 用 UTF-8 和 try-with-resources 读取第一行;
- 格式必须是
name,x,y; - 文件缺失抛
MapNotFoundException,格式错误抛InvalidMapFormatException; - 两个异常都保留原 cause 或原始安全字段;
- 返回不可变
MapLocation(name, x, y)。
测试正常的 北区,150,200、缺少列、非数字坐标和不存在文件。测试异常类型与字段,不要断言整段 stack trace。
练习 B:购买与任务
当前 placeOrder 只校验金额,没有扣款状态。进阶练习先为它增加账户余额与实际扣减,再添加请求 ID:限定在单进程、单线程和内存存续期间,同一请求重试返回原结果,同一 ID 换了金额应被拒绝;进程重启和并发幂等暂不包含在这个练习中。再实现 confirmTask:调用方应能区分“不存在”“已过期”和“前置任务未完成”。先设计返回/异常契约,再写类。
练习 C:关闭失败
改造 CloseProbe:正文成功但 close 失败时,close 异常会成为主异常;正文与 close 都失败时,close 异常进入 suppressed。为两条路径分别写断言。
9. 沿着主异常再走一遍
把完整示例中的“读取失败”暂时删掉,只让 CloseProbe.close() 失败:第一个关闭异常现在应成为主异常,后续关闭异常进入 suppressed。恢复正文失败后,主异常又应回到“读取失败”,两个关闭异常按 second、first 的顺序附加。两条路径都写断言,才能看清 try-with-resources 的主次规则。
再给中断示例加一个测试线程。在线程内部捕获 InterruptedException、恢复标志并转成 TaskCancelledException,再由外层测试捕获后断言 Thread.currentThread().isInterrupted() 为 true。若这项检查失败,线程池收到的取消信号已经在你的 catch 中丢失。
最后看一遍异常边界:底层方法提供类型、字段与 cause;请求、任务或命令行入口只记录一次;普通的“不存在”若是预期结果,就用返回类型表达。三层职责分开后,异常才不会变成跨模块的字符串协议。
查阅 Java 17 的正式规则
- JLS 14.20:try、catch、finally 与资源管理。
AutoCloseable与Throwable.getSuppressed:资源关闭和双重失败的契约。Files与StandardCharsets.UTF_8:明确路径、编码和文件操作。Thread.interrupt:中断状态及相关方法的行为。
下一步:泛型
区域数据加载器、购买服务和任务系统都开始出现“同一套逻辑处理不同类型”的需求。下一章用类型参数收拢重复代码,同时保留编译期检查。