Java 异常与资源生命周期
一次方法调用同时存在两条结果路径:正常返回一个值,或者抛出一个 Throwable,让当前语句突然完成。异常会沿当前线程的调用栈寻找处理器;沿途已经获得的文件、连接、锁或游标仍要释放,另一个线程中的任务失败则需要通过 Future 等结果对象重新交付。
Throwable
├─ Error JVM、链接或资源层的严重问题
└─ Exception
├─ RuntimeException unchecked
└─ 其他 Exception checked
main → application → repository → driver
│
└─ throw StorageTimeout
catch/translate ← stack unwind
acquire A → acquire B → use → close B → close A类型树决定编译器怎样检查,调用栈决定异常能到达哪里,资源作用域决定清理次序。这三层合在一起,才能回答应该捕获什么、保留什么,以及失败后程序还处于什么状态。
Throwable 沿当前线程的调用栈传播
throw 后面是一个 Throwable 对象。语句执行后,抛出点后面的正常路径停止,当前方法不能返回原定结果;运行时根据异常对象的实际类型,在当前方法的受保护区域中寻找第一个兼容 handler。没有匹配项时,当前栈帧退出,继续检查调用者。
static final class StorageFailure extends Exception {
StorageFailure(String message) {
super(message);
}
}
static String load(String id) throws StorageFailure {
if (id.isBlank()) {
throw new StorageFailure("blank order id");
}
return "order:" + id;
}throws StorageFailure 声明了直接调用者在源码层面必须面对的 checked exception。它不抛出对象,也不会自动执行恢复;真正的控制转移只在 throw 或某个调用实际失败时发生。JLS 17 第 11 章规定了异常分类、编译期检查、handler 搜索和运行时处理。
checked 与 unchecked 描述编译期义务
RuntimeException 及其子类、Error 及其子类属于 unchecked;其他异常类属于 checked。编译器要求可能抛出 checked exception 的表达式位于相应 try 中,或由当前方法继续声明。unchecked 可以不写进 throws,运行时仍会以相同方式展开栈。
| 类型 | 常见含义 | 调用者面对的方式 |
|---|---|---|
| checked exception | 外部 I/O、可预期接口失败,且直接调用者可能采取动作 | 捕获处理或继续声明 |
RuntimeException | 参数、状态、不变量或无法要求每层显式声明的失败 | 以具体类型和边界契约说明;不能只靠“免声明”隐藏 |
Error | VM、链接、断言或严重资源问题 | 常规业务代码通常不捕获后继续;入口仍可做最小清理与终止记录 |
分类没有承诺恢复能力。IOException 可能来自一次可重试的临时读取,也可能表示输出只写了一半;IllegalArgumentException 有时能在请求边界转成明确拒绝,有时说明内部调用违反了不变量。能否恢复取决于操作状态和当前层掌握的信息。
catch 按从上到下的声明顺序检查。子类型必须放在父类型之前,否则后面的分支永远不可达;multi-catch catch (A | B failure) 适合处理动作完全相同且彼此不存在子类型关系的异常。捕获 Exception 会吸收多数业务与编程错误,却接不住 Error;捕获 Throwable 范围更大,不适合作为“保证循环永远继续”的手段。
异常对象通常在创建时记录当前线程的栈快照。直接 throw failure 会继续传播同一个对象;构造新异常会记录新的创建位置,并应通过 cause 连接旧对象。只复制 getMessage() 会让原始类型、栈和结构化字段脱离新异常。
finally 参与栈展开,也能覆盖原结果
finally 会在 try 正常完成、返回或抛异常后运行。它适合必须执行的短清理动作;若 finally 自己 return、break、continue 或抛出新异常,先前的返回值或异常可能被新的突然完成原因替换。
static int broken() {
try {
throw new IllegalStateException("primary");
} finally {
return 0; // primary 被丢弃,调用者看到普通返回
}
}不要从 finally 返回,也不要手写一段会无条件覆盖主失败的关闭逻辑。需要管理 AutoCloseable 时,try-with-resources 会把 body 与 close 的失败关系保留下来;需要解锁时,finally 只执行与当前 acquire 成对的 unlock,不在清理路径加入新的业务分支。
含 try/catch/finally 或自动资源管理的方法,class 文件 Code 属性通常包含 exception table。每个表项记录受保护指令区间、handler 位置与可捕获类型;它不保存 Java 源码中的所有语义,也不会让异常穿过线程或进程。JVMS 17 的 Code 属性定义了表项结构。
线程一直找不到 handler 时,会以未捕获异常结束,并把对象交给该线程的 UncaughtExceptionHandler。这是终止观察点,不是回到已经退出的栈帧继续业务的恢复点。进程是否随之退出还取决于是否存在其他非 daemon 线程。
捕获点决定失败能否被正确处理
一个 catch 值得存在,通常因为当前层能做出至少一种明确动作:在不破坏不变量的前提下恢复;跨抽象层翻译成调用者理解的类型;加入只有本层掌握且允许暴露的上下文;结束当前请求、任务或进程。若只会打印一遍再原样抛出,日志重复而状态没有改变。
final class StorageUnavailable extends RuntimeException {
private final String operation;
StorageUnavailable(String operation, Throwable cause) {
super("order storage unavailable", cause);
this.operation = operation;
}
String operation() {
return operation;
}
}
String load(Path path) {
try {
return Files.readString(path);
} catch (IOException failure) {
throw new StorageUnavailable("read-order", failure);
}
}适配器把文件系统的 IOException 翻译成应用层稳定类型,同时通过 cause 保留原失败。operation 可以参与程序判断或安全日志;文件内容、访问令牌和完整用户输入不应为了“上下文完整”塞进消息。异常类型与字段承担机器可判断的语义,message 只提供有限、可读的诊断。
Throwable API区分 message、stack trace、cause 与 suppressed:
| 信息 | 关系 | 典型读取方式 |
|---|---|---|
| message | 当前异常的人类诊断 | getMessage() |
| stack trace | 当前 Throwable 创建时的线程栈快照 | getStackTrace() / 日志异常参数 |
| cause | 当前抽象层失败由更底层哪个失败引起 | getCause() |
| suppressed | 为保留主失败而没有成为最终抛出对象的其他失败 | getSuppressed() |
每一层都重新包装会得到很长但没有新动作的 cause 链。翻译应发生在抽象确实变化的位置,例如 JDBC 异常进入仓储端口、厂商 SDK 异常进入支付接口;同一抽象内部通常直接传播,或只在一次入口日志中附加相关 ID。
checked 或 unchecked 由直接调用者的动作决定
接口调用者必须显式选择恢复、替代或继续声明时,checked exception 能让源码编译器参与约束。调用链很深、调用者无法在每层恢复,或失败表示违反不变量时,具体的 unchecked 类型通常更合适。两者都需要文档、测试和稳定含义;throws Exception 与统一包装成 RuntimeException 都会抹平调用者真正需要区分的动作。
公共方法新增 checked exception 会改变旧源码重新编译时的义务,却不会仅因 throws 子句改变既有 JVM 方法描述符。JLS 17 §13.4.21明确把 throws 变化视为二进制兼容。发布公共库时仍要分别验证:旧源码针对新库能否编译、旧 class 搭配新库能否运行、运行中出现的新异常类型是否改变业务行为。
返回值也应保留语义。Optional.empty() 适合契约内的“没有记录”,不适合伪装数据库超时;空 List 可以表示查询成功但无元素,不能表示调用根本没有完成。若调用者需要同时处理多个独立结果,可使用显式结果类型保存每项成功与失败,而不是只抛出第一个异常后猜哪些动作已经完成。
日志和重试属于看到完整状态的执行边界
Repository、service、controller 每层都 catch/log/throw,同一个 cause 会生成多条堆栈并重复告警。内部层只在需要翻译时构造稳定异常;能够关联请求、任务、操作 ID 和最终响应的边界记录一次完整 cause 链。若中间层必须记录一次独有事实,应使用明确事件并避免再次打印同一堆栈。
重试资格来自操作状态,而不是异常类名。一次连接建立前的临时失败与“服务端已提交、客户端读取响应超时”都可能表现为 I/O 异常,后者盲目重试会重复写入。决定重试前至少要知道操作是否幂等、是否有去重键、当前尝试是否已产生外部效果、总时间与次数预算、下游是否要求退避。多层各自重试会相乘;“首次尝试加两次重试”在三层同时发生时,最坏可放大为 3 × 3 × 3 = 27 次调用。
跨 HTTP 或消息边界后,Java 调用栈已经结束。对外结果应提取稳定错误码、阶段和安全说明,不能序列化本地异常类名或整棵 Throwable 当长期协议。Spring MVC 的错误载体与映射见错误响应与 Problem Detail。
资源关闭和任务取消有各自的失败通道
资源管理先明确所有权。创建文件流、数据库连接或游标的作用域通常拥有关闭责任;只接收一个借用对象并在调用期间使用的方法不应擅自关闭它。所有权转移要体现在方法名、返回类型或文档中,否则一次“好心 close”可能提前结束调用方仍要使用的连接。
owner scope
├─ acquire resource A
├─ acquire resource B
├─ use A + B
├─ close B
└─ close A
borrower method
└─ use only 不接管 closetry-with-resources 保留 body 与 close 的主次关系
资源在 try-with-resources 头中从左到右初始化,只关闭已经成功初始化且非 null 的对象;关闭顺序与初始化顺序相反。第二个资源初始化失败时,第一个仍会关闭,try body 不会执行。JLS 17 §14.20给出了 try、catch、finally 与自动资源管理的完整翻译规则。
try (InputStream input = openInput();
OutputStream output = openOutput()) {
transfer(input, output);
}失败关系取决于谁先抛出:
| 执行情况 | 最终主异常 | suppressed |
|---|---|---|
body 抛 TransferFailure,随后 output/input close 都失败 | TransferFailure | 按关闭发生顺序附加两个 close 异常 |
| body 成功,output close 失败,随后 input close 也失败 | output 的 close 异常 | input 的 close 异常 |
| openOutput 初始化失败,随后 input close 失败 | openOutput 的初始化异常 | input 的 close 异常 |
| body 与所有 close 都成功 | 正常完成 | 空 |
这使 primary 保持最早改变主执行结果的失败,后续清理失败仍可通过 getSuppressed() 观察。手写 finally 若直接抛最后一个 close 异常,往往会覆盖更重要的 body 或初始化失败。
AutoCloseable表示对象可能持有需要及时释放的资源,close() 可以声明 Exception,接口本身不要求幂等。实现应尽量先释放并标记关闭,再报告 close 失败,也应避免从 close 抛 InterruptedException,因为它作为 suppressed 时会破坏中断语义。I/O 的 Closeable把异常收窄为 IOException,并明确要求重复 close 没有效果。
不是每个实现 AutoCloseable 的对象都拥有外部资源。集合来源的普通 Stream 通常无需关闭,Files.lines 等 I/O 来源的 Stream 则要在 try-with-resources 中关闭。具体 API 的关闭契约比接口名称更精确。
中断表达协作取消
Thread.interrupt() 设置目标线程的中断状态;sleep、wait、join 以及阻塞队列等可中断等待检测到信号后,通常抛 InterruptedException 并清除状态。能够继续声明时,直接把 InterruptedException 交给调用者;方法签名不能声明且当前工作必须结束时,恢复状态并退出这次任务:
final class TaskCancelled extends RuntimeException {
TaskCancelled(Throwable cause) {
super("task interrupted", cause);
}
}
String takeNext(BlockingQueue<String> queue) {
try {
return queue.take();
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new TaskCancelled(failure);
}
}恢复中断位让外层仍能观察信号,随后代码应结束当前工作或到达明确的取消检查点。恢复后立即重新进入同一个无限阻塞循环,会消耗 CPU、反复失败或继续执行已取消的副作用。Thread.interrupt还区分 Thread.interrupted() 与 isInterrupted():前者读取并清除当前线程状态,后者只读取指定线程状态。
中断不是强制杀死线程。普通计算必须主动检查,某些 I/O 有自己的关闭或取消协议,资源依旧由原所有者清理。测试取消时,要先证明 worker 已到阻塞点,再发送中断,并在有界等待内确认它退出;仅调用 interrupt() 不能证明工作已停止。
Future 把另一个线程的结果重新交给等待者
异常只沿抛出线程的栈展开。任务提交后,提交者栈和执行者栈已经分开,Future 成为结果通道。Future API为 get 定义了四条不同失败路径:
get 结果 | 含义 | 处理方向 |
|---|---|---|
| 返回值 | 任务正常完成 | 使用结果 |
ExecutionException | 任务自身抛出异常 | 从 cause 读取任务失败,再按调用边界翻译 |
CancellationException | Future 已取消 | 作为取消终态处理,不伪装成业务失败 |
InterruptedException | 等待者线程被中断 | 传播或恢复等待者中断,停止等待 |
TimeoutException | 有界等待到期,任务未在期限内给出结果 | 决定是否取消任务;超时本身不证明任务停止 |
cancel(true) 只是在实现知道执行线程时尝试用中断停止正在运行的任务;任务仍要遵守中断协议。isDone() 同时覆盖正常、异常和取消完成,不能单独证明成功。
CompletableFuture.join() 不声明 InterruptedException,异常完成通常以 CompletionException(cause) 交付,直接取消则表现为 CancellationException。CompletableFuture API还规定其 cancel(mayInterruptIfRunning) 的参数不控制执行线程,因为这个类型本身未必拥有产生结果的计算。需要可中断等待或底层任务取消时,应显式选择能表达该控制关系的执行设施。
反射也会增加一层调用设施。Method.invoke 在访问、接收者或参数不合法时直接抛反射层异常;目标方法已经运行并抛出 Throwable 时,用 InvocationTargetException 包装目标失败。Method.invoke API定义了这一区别。MethodHandle 在 lookup/link 阶段报告成员与访问问题,调用点类型不匹配时抛 WrongMethodTypeException,目标方法的 Throwable 则由 invoke/invokeExact 直接传播,不套 InvocationTargetException。MethodHandle API给出了签名多态调用规则;候选发现、访问和调用计划在注解、反射与运行时元数据继续展开。
用固定 JDK 观察完整和断裂的失败关系
公开实验包含 ExceptionSemanticsDemo.java、run-docker.sh与底层 run.sh。推荐环境是安装 Docker Engine 的 Linux 开发机,执行身份是能够访问 daemon 的普通用户。从仓库根目录运行:
export LAB_DIR="$PWD/docs/.vuepress/public/examples/backend-development/exception-semantics"
test "$(id -u)" -ne 0 || {
echo '请切换到能够访问 Docker 的普通用户' >&2
exit 1
}
test -r "$LAB_DIR/run-docker.sh" || exit 1
bash "$LAB_DIR/run-docker.sh"包装脚本固定 eclipse-temurin:17.0.20_8-jdk@sha256:a27c79d44326d5f689668df5fedfee487652066d2a91e172747056cc7fbee6fc,以宿主 UID/GID 运行,禁用网络,移除 Linux capabilities,开启 no-new-privileges,并把根文件系统和源码目录设为只读。编译只写入 128 MiB /tmp,退出时清理。镜像标签和发行物可从 Eclipse Temurin 官方镜像与 Adoptium Temurin 发布页核对。
底层脚本拒绝 JAVA_TOOL_OPTIONS、JDK_JAVA_OPTIONS 与 _JAVA_OPTIONS 注入,确认 java、javac、javap 都属于 Java 17,再以 --release 17 -Xlint:all -Werror 编译。javap 只截取 translatePreservingEvidence 方法块,断言其中存在 exception table 和 Throwable.addSuppressed 调用。
preserved 模式让 try body 抛 StorageFailure,随后让 close() 抛 IOException。应用异常的 cause 必须仍是 StorageFailure,关闭失败必须出现在这个 cause 的 suppressed 数组,资源状态必须为 closed。lost-cause 只复制消息,脚本确认 cause 为 null 后要求子进程以状态 2 退出。
另两个模式用 CountDownLatch 证明 worker 已到达 Thread.sleep,主线程才发出中断。正确模式恢复中断位并在两秒上界内退出;错误模式吞掉信号,中断位为 false,以状态 3 被拒绝。所有观察成立时输出:
bytecode=Exception-table+addSuppressed
preserved=cause+suppressed+closed
lost-cause=rejected(exit=2)
restored-interrupt=status-restored+worker-exited
swallowed-interrupt=rejected(exit=3)
PASS本机已有 JDK 17 时,可显式选择同一套工具运行底层脚本:
export LAB_DIR="$PWD/docs/.vuepress/public/examples/backend-development/exception-semantics"
export JDK17_HOME="/opt/jdk-17"
test -x "$JDK17_HOME/bin/javap" || exit 1
unset JAVA_TOOL_OPTIONS JDK_JAVA_OPTIONS _JAVA_OPTIONS
bash "$LAB_DIR/run.sh"脚本会打印实际补丁版本,不要求本机伪装成容器发行版。任一成功模式输出不完整、错误模式意外返回 0、字节码方法块缺少目标结构或 worker 超过期限,外层都打印 FAIL: 并以非零状态退出。
故障判断应先确认失败通道,再确定处理者:
| 现象 | 第一观察点 | 下一步 |
|---|---|---|
新异常 getCause() 为 null | 包装构造器是否接收原异常,是否只复制 message | 使用 (message, cause);只在抽象变化处包装并重跑 lost-cause |
| body 异常消失,只剩 close 异常 | 是否在 finally 直接抛 close failure 或 return | 改用 TWR;检查 primary 的 suppressed 和资源最终状态 |
| 文件描述符或连接持续增长 | acquire 与 close 是否在同一所有者作用域,部分初始化失败是否清理 | 收窄资源作用域,使用 TWR;压测时同时观察活动资源数和失败率 |
| catch 后任务继续运行 | 是否吞掉 InterruptedException,是否恢复后又进入循环 | 能声明则传播;否则恢复中断并退出当前任务,再验证 worker 已停止 |
ExecutionException 被当成根因 | getCause() 是否是任务异常,等待者是否另有中断或超时 | 区分任务失败、取消、等待者中断与超时,再在异步边界翻译 |
Future 已 isDone() 仍取值失败 | 是否取消或异常完成 | 使用 isCancelled 与 get 结果判断终态,不能用 done 等同 success |
| 同一异常打印多次 | 是否每层 catch/log/throw | 由拥有完整请求或任务上下文的出口打印一次 cause 链 |
| 重试量远超配置 | client、adapter、scheduler 是否各有 retry | 只保留一个 retry owner,计算总尝试预算并验证幂等/去重 |
| 反射外壳掩盖目标失败 | 是访问/参数问题还是 InvocationTargetException | 设施失败修调用计划;目标失败从 cause 进入原业务处理 |
Docker 在版本输出前失败时,检查普通用户的 daemon 权限、固定摘要是否可拉取和主机平台。出现 FAIL: 后先按提示单独运行对应 mode;不要删除预期失败断言来换取 PASS,也不要用一条 message 包含测试代替 cause、suppressed、关闭状态和线程退出四种独立观察。
权威资料与规范地址
以下资料用于核对 Java 17 的异常检查、栈处理、资源关闭、中断和异步结果契约。实验环境单列官方镜像与发行物入口。
语言、class 文件与 Throwable
| 资料 | 用途 |
|---|---|
| JLS 17 第 11 章 | 异常类型、checked 检查、handler 与运行时处理 |
| JLS 17 §14.20 | try/catch/finally 与 try-with-resources 翻译 |
| JLS 17 §13.4.21 | throws 子句的二进制兼容 |
JVMS 17 Code 属性 | exception table 结构 |
Throwable API | message、stack trace、cause 与 suppressed |
资源、取消与异步结果
| 资料 | 用途 |
|---|---|
AutoCloseable API | 自动关闭、close 失败与中断边界 |
Closeable API | I/O 关闭与幂等契约 |
Thread.interrupt API | 中断状态设置、清除与可中断阻塞 |
Future API | 任务结果、失败、取消、中断与超时 |
CompletableFuture API | 异常完成、join 与取消语义 |
Method.invoke API | 反射设施失败与目标异常包装 |
MethodHandle API | 签名多态调用与目标异常传播 |
实验环境
| 资料 | 用途 |
|---|---|
| Eclipse Temurin 官方镜像 | JDK 容器镜像标签与使用方式 |
| Adoptium Temurin 发布页 | 发行版、平台与补丁核对 |
