Java 基础体系 · 第 7/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Java 异常处理:受检异常、错误链、资源关闭和 API 契约
Java 异常处理不只是“发生异常时打印日志”。它同时规定了四件事:
- 哪些异常必须在编译期被处理;
- 一个异常如何携带另一个异常的原因;
- 资源在正常路径和失败路径上如何关闭;
- API 如何向调用者表达失败,以及调用者能否据此稳定编程。
本文以 Java 25 为范围,重点讨论 Throwable 体系、受检异常、异常链、try 语句、try-with-resources、API 的异常契约,以及它们在 I/O、测试和并发代码中的边界。
一、异常处理的运行模型
1. Throwable 是异常传播的根类型
Java 中能够通过 throw 抛出、通过 catch 捕获的对象,必须是 Throwable 的实例:
public class Demo {
public static void main(String[] args) {
throw new IllegalStateException("state is invalid");
}
}
Throwable 主要分为两个分支:
Throwable
├── Error
└── Exception
└── RuntimeException
这是概念上的主要结构,实际 JDK 还包含其他直接或间接子类。
Error表示通常由 JVM、运行时环境或严重系统条件导致的问题,例如OutOfMemoryError、StackOverflowError。Exception表示应用程序通常可能处理或向上层报告的异常。RuntimeException表示运行时异常,通常用于编程错误或无法由编译器强制处理的失败,例如NullPointerException、IllegalArgumentException、IllegalStateException。
这里的“通常”很重要。Java 语法允许捕获 Error,也允许主动抛出 RuntimeException;但捕获一个 OutOfMemoryError 后继续运行,往往并不能恢复程序的一致状态。
2. 异常传播是沿调用栈向上的控制转移
假设调用关系为:
main()
└── service()
└── repository()
└── Files.readString()
repository() 中发生异常时,JVM 会按以下顺序寻找匹配的处理器:
- 在当前方法中寻找可处理该异常的
catch; - 如果没有,终止当前方法,返回调用者;
- 在调用者中继续寻找;
- 直到找到匹配处理器,或者线程的未捕获异常处理器接管。
例如:
static void repository() {
throw new IllegalStateException("database unavailable");
}
static void service() {
repository();
}
public static void main(String[] args) {
service();
}
如果没有任何 catch,异常会一直向上传播,最终由线程的未捕获异常处理器处理。常见实现会向标准错误输出堆栈信息,但“如何输出”属于实现行为,不应把它当作业务 API 契约。
异常传播时,异常对象通常携带:
- 异常类型;
- 异常消息;
- 创建异常时记录的堆栈轨迹;
- 原因异常
cause; - 在 try-with-resources 中产生的被抑制异常
suppressed exceptions。
二、受检异常:编译期强制的异常契约
1. 什么是受检异常
根据 Java 语言规范的异常检查规则,除 RuntimeException 及其子类、Error 及其子类之外,其他直接或间接继承 Throwable 的异常属于受检异常。
例如:
class ConfigurationException extends Exception {
ConfigurationException(String message) {
super(message);
}
}
ConfigurationException 是受检异常,因为它继承自 Exception,且不是 RuntimeException 的子类。
相反:
class InvalidOrderException extends RuntimeException {
InvalidOrderException(String message) {
super(message);
}
}
InvalidOrderException 是非受检异常。
受检与非受检不是“严重程度”的划分:
- 受检异常不一定比运行时异常严重;
- 运行时异常也不一定可以忽略;
- 这个分类首先描述的是编译器是否强制调用者显式处理潜在异常。
2. 受检异常必须被捕获或声明
下面的代码无法通过编译:
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
class ReadDemo {
static String load(Path path) {
return Files.readString(path);
}
}
Files.readString(Path) 声明可能抛出 IOException。IOException 是受检异常,因此 load 必须捕获它,或者在自己的方法声明中继续声明:
static String load(Path path) throws IOException {
return Files.readString(path);
}
也可以在本层处理:
static String loadOrDefault(Path path) {
try {
return Files.readString(path);
} catch (IOException e) {
return "";
}
}
这两种写法表达的契约不同:
throws IOException:当前方法不负责决定如何处理 I/O 失败,将决定权交给调用者;catch (IOException e):当前方法已经决定了失败后的行为,例如返回默认值、转换异常、重试或记录状态。
如果既不捕获,也不声明,编译器会报告类似“未报告的异常,必须被捕获或声明”的错误。
3. 编译器检查的核心条件
可以把方法中的异常检查简化为如下条件:
如果一个受检异常 E 可能从方法体正常退出路径传播,
那么方法必须:
1. 在某个可达 catch 中处理 E;或
2. 在 throws 子句中声明 E 的超类型。
这里有三个关键点。
第一,必须是受检异常
static void parse(String text) {
throw new IllegalArgumentException("bad input");
}
不需要 throws IllegalArgumentException,因为它是非受检异常。
虽然可以写:
static void parse(String text) throws IllegalArgumentException {
throw new IllegalArgumentException("bad input");
}
但这不会让调用者获得编译期强制处理。throws 对非受检异常主要是文档作用。
第二,异常必须具有可达传播路径
static void example() {
if (false) {
throw new Exception("unreachable");
}
}
这里的 throw 不构成可执行路径,因此不会产生通常意义上的“必须处理”要求。
第三,捕获类型必须能够匹配抛出类型
static void load() {
try {
throw new java.io.FileNotFoundException();
} catch (java.io.IOException e) {
// 可以捕获,因为 FileNotFoundException 是 IOException 的子类
}
}
反过来,不能用不相关的异常类型捕获:
try {
throw new java.io.IOException();
} catch (java.sql.SQLException e) {
// 编译错误:异常类型不可能匹配
}
4. throws 不是运行时抛出动作
以下两部分作用不同:
static void read() throws java.io.IOException {
throw new java.io.IOException("read failed");
}
throws IOException是方法声明中的异常契约,告诉编译器和调用者该方法可能传播什么受检异常;throw new IOException(...)是运行时实际创建并抛出异常对象的动作。
throws 本身不会创建异常,也不会自动处理异常。
三、try、catch、finally 与控制流
1. catch 按顺序匹配
多个 catch 会从上到下进行匹配,先匹配到的处理器执行:
try {
throw new java.io.FileNotFoundException();
} catch (java.io.FileNotFoundException e) {
System.out.println("file not found");
} catch (java.io.IOException e) {
System.out.println("other I/O error");
}
不能把父类型放在子类型前面:
try {
throw new java.io.FileNotFoundException();
} catch (java.io.IOException e) {
// 已经可以匹配 FileNotFoundException
} catch (java.io.FileNotFoundException e) {
// 编译错误:永远不可达
}
2. 多重捕获表示“同一种处理逻辑”
如果多个异常的处理逻辑完全相同,可以使用多重捕获:
try {
process();
} catch (java.io.IOException | java.text.ParseException e) {
throw new IllegalStateException("input processing failed", e);
}
多重捕获中的类型不能存在父子关系,例如下面是非法的:
catch (java.io.IOException | java.io.FileNotFoundException e) {
}
因为 FileNotFoundException 已经被 IOException 覆盖。
多重捕获参数在语言层面具有近似隐式不可重新赋值的性质,不能在处理器中把它重新赋成另一个异常对象:
catch (IOException | ParseException e) {
// e = new IOException(); // 编译错误
}
3. finally 通常用于无论成败都要执行的动作
Lock lock = ...;
lock.lock();
try {
updateState();
} finally {
lock.unlock();
}
只要控制流离开 try,finally 通常都会执行,包括:
try正常结束;try中抛出异常;try中执行return;try中执行break或continue。
但 finally 不是“绝对执行”的保证。例如:
- JVM 在执行前进程被强制终止;
System.exit结束虚拟机;- JVM 崩溃或宿主机断电;
- 线程卡死,根本没有离开
try。
4. 不要在 finally 中返回或抛出无关异常
下面的 return 会覆盖 try 中的返回值,也可能覆盖原本正在传播的异常:
static int bad() {
try {
throw new IllegalStateException("original");
} finally {
return 0;
}
}
调用 bad() 的结果是 0,原始异常被吞掉。
类似地:
static void alsoBad() {
try {
throw new IllegalStateException("original");
} finally {
throw new RuntimeException("cleanup failed");
}
}
最终传播的是 "cleanup failed",原始异常丢失。资源关闭应优先使用 try-with-resources;如果必须使用 finally,就要明确处理关闭失败与原始失败的关系。
四、异常链:保留失败的因果关系
1. 什么是原因异常
当低层异常不适合直接暴露给上层时,通常需要创建一个更符合当前抽象层次的新异常,同时保留原始异常作为原因:
class UserRepositoryException extends Exception {
UserRepositoryException(String message, Throwable cause) {
super(message, cause);
}
}
使用:
static String loadUserName(java.nio.file.Path path)
throws UserRepositoryException {
try {
return java.nio.file.Files.readString(path);
} catch (java.io.IOException e) {
throw new UserRepositoryException(
"无法加载用户资料: " + path, e);
}
}
这里形成了一条原因链:
UserRepositoryException
└── cause: IOException
上层看到的是领域相关的 UserRepositoryException,诊断时仍然可以追溯到底层 IOException。
2. 直接传入原因,而不是只拼接消息
错误做法:
catch (IOException e) {
throw new UserRepositoryException(
"load failed: " + e.getMessage());
}
这只复制了文字,丢失了:
- 原始异常类型;
- 原始堆栈轨迹;
- 原始异常的原因链;
- 机器可判断的异常信息。
正确做法:
catch (IOException e) {
throw new UserRepositoryException("load failed", e);
}
异常消息面向人,异常类型和原因链面向程序与诊断工具。不能用消息文本替代类型契约。
3. initCause 用于延迟设置原因
除了构造器,还可以使用:
Throwable wrapper = new RuntimeException("wrapper");
wrapper.initCause(original);
initCause 通常只能成功设置一次原因。如果异常已经在构造器中指定原因,或者原因已被设置,再次调用会抛出 IllegalStateException。
自定义异常优先提供带 cause 的构造器,因为它能在对象创建时建立完整链条:
class DataAccessException extends RuntimeException {
DataAccessException(String message, Throwable cause) {
super(message, cause);
}
}
4. getCause() 与 getSuppressed() 表达不同关系
这两种关系不能混淆。
原因链:一个失败由另一个失败解释
业务异常
└── cause: 数据库异常
它通常表示“当前异常是对底层异常的抽象转换”。
被抑制异常:主失败发生后,清理动作也失败
主异常:读取失败
└── suppressed: 关闭文件失败
它表示两个异常同时存在,但主异常优先传播。
诊断代码可以分别查看:
static void printDetails(Throwable error) {
System.err.println(error);
for (Throwable suppressed : error.getSuppressed()) {
System.err.println("suppressed: " + suppressed);
}
Throwable cause = error.getCause();
while (cause != null) {
System.err.println("caused by: " + cause);
cause = cause.getCause();
}
}
不要简单地递归打印所有关系而不区分 cause 和 suppressed,否则会把“失败原因”和“清理失败”混成一条线。
五、try-with-resources:资源关闭的确定性规则
1. 资源是什么
资源通常是需要显式释放的对象,例如:
- 文件输入输出流;
Reader、Writer;- socket 和网络流;
- 数据库连接、语句、结果集;
- 压缩流;
- 锁或其他实现了
AutoCloseable的对象。
try-with-resources 要求资源实现 AutoCloseable:
public interface AutoCloseable {
void close() throws Exception;
}
Closeable 继承自 AutoCloseable,其 close() 声明为 IOException,因此更适合 I/O 资源。
2. 基本语法与生命周期
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
static String read(Path path) throws IOException {
try (var reader = Files.newBufferedReader(path)) {
return reader.readLine();
}
}
生命周期是:
创建资源
↓
执行 try 主体
↓
按声明顺序的逆序关闭资源
↓
决定最终传播哪个异常
资源会在离开 try 主体时关闭,包括主体执行 return 或抛出异常的情况。
3. 多个资源按逆序关闭
try (var input = Files.newInputStream(source);
var output = Files.newOutputStream(target)) {
input.transferTo(output);
}
关闭顺序为:
先关闭 output
再关闭 input
原因是后声明的资源通常依赖先声明的资源,逆序关闭可以先释放更内层的依赖。
如果资源创建过程如下:
try (Resource a = openA();
Resource b = openB();
Resource c = openC()) {
use(a, b, c);
}
若 openC() 失败:
a已创建;b已创建;c创建失败;- 已创建的
b关闭; - 已创建的
a关闭; - 原始创建失败继续传播,关闭失败作为被抑制异常附加。
4. 主体异常与关闭异常的优先级
下面的完整示例使用自定义资源展示规则:
public class SuppressedDemo {
static final class FailingResource implements AutoCloseable {
@Override
public void close() {
throw new IllegalStateException("close failed");
}
void use() {
throw new IllegalArgumentException("use failed");
}
}
public static void main(String[] args) {
try (var resource = new FailingResource()) {
resource.use();
} catch (Exception e) {
System.out.println("primary: " + e.getMessage());
for (Throwable suppressed : e.getSuppressed()) {
System.out.println("suppressed: " + suppressed.getMessage());
}
}
}
}
预期输出类似:
primary: use failed
suppressed: close failed
控制流可以逐步写成:
- 创建
FailingResource; - 执行
use(),产生IllegalArgumentException; - 开始关闭资源;
close()产生IllegalStateException;- 因为主体已经有主异常,关闭异常被加入主异常的 suppressed 列表;
- 主异常继续传播。
如果主体没有异常:
try (var resource = new FailingResource()) {
// 正常结束
}
此时 close() 抛出的异常就是最终传播的异常。
5. try-with-resources 的等价结构
概念上,编译器会把资源管理转换为类似下面的结构:
Resource resource = acquire();
Throwable primary = null;
try {
use(resource);
} catch (Throwable t) {
primary = t;
throw t;
} finally {
if (resource != null) {
if (primary != null) {
try {
resource.close();
} catch (Throwable closeFailure) {
primary.addSuppressed(closeFailure);
}
} else {
resource.close();
}
}
}
这不是精确的编译器输出,但能说明核心规则:
- 资源必须关闭;
- 主体异常优先;
- 关闭异常不会被静默丢弃;
- 关闭异常会附加到主异常的 suppressed 列表。
6. Java 9 起可使用有效最终变量
资源不一定必须在括号内声明。只要变量是 final 或 effectively final,就可以使用:
var input = Files.newInputStream(Path.of("input.txt"));
try (input) {
input.transferTo(System.out);
}
input 在 try 之前没有再次赋值,因此是有效最终变量。
如果之后重新赋值,则不能作为资源:
var input = Files.newInputStream(Path.of("input.txt"));
input = Files.newInputStream(Path.of("other.txt"));
try (input) { // 编译错误
}
7. close() 是否幂等是资源实现的契约
try-with-resources 会负责一次关闭,但程序中也可能存在其他关闭路径。某些资源的 close() 重复调用是安全的,某些资源则可能抛出异常或产生状态问题。
因此,自定义 AutoCloseable 时应明确:
close()是否允许重复调用;- 关闭后其他方法的行为;
close()可能抛出哪类异常;- 关闭失败是否意味着资源状态未知。
六、finally 与 try-with-resources 的选择边界
旧式资源代码通常写成:
var input = Files.newInputStream(path);
try {
return input.read();
} finally {
input.close();
}
它至少存在两个问题:
close()的受检异常可能覆盖主体中的异常;- 多个资源会产生复杂的嵌套
try/finally,容易漏关或覆盖异常。
try-with-resources:
try (var input = Files.newInputStream(path)) {
return input.read();
}
表达的是更精确的资源协议:主体失败是主失败,关闭失败作为 suppressed exception 保留。
但这不代表 finally 没有用途。它适合不属于 AutoCloseable 生命周期的动作,例如:
boolean previous = transactionActive();
try {
setTransactionActive(true);
execute();
} finally {
setTransactionActive(previous);
}
这里恢复线程上下文或状态,不一定对应一个需要 close() 的资源。
七、I/O 与 NIO 中的异常边界
1. 路径检查不能替代实际操作
下面的代码存在竞态:
if (Files.exists(path)) {
return Files.readString(path);
}
exists 返回 true 与 readString 执行之间,文件可能被删除、权限可能改变、符号链接目标可能变化。因此,真正的操作仍然必须处理 IOException。
更直接的方式是:
static String readRequired(Path path) throws IOException {
try {
return Files.readString(path);
} catch (java.nio.file.NoSuchFileException e) {
throw new IOException("required file is missing: " + path, e);
}
}
这里将更具体的异常转成当前方法的契约,仍然保留原始原因。
2. Stream、Channel、Buffer 的失败位置不同
Java I/O 的异常可能发生在多个阶段:
获取资源
├── 打开文件 / 建立连接:可能失败
├── 读取或写入:可能失败
├── flush:可能失败
└── close:可能失败
使用 OutputStream 时,write() 成功不必然表示数据已经到达最终介质;缓冲层可能还未 flush()。因此 flush() 和 close() 也是潜在失败点。
使用 Channel 时,read() 或 write() 返回值还表达了数据流状态:
- 正数:实际读写的字节数;
0:本次操作没有进展,非阻塞场景尤其常见;-1:输入端到达末尾,通常表示 EOF。
异常与返回值是两套不同的信息:
int count = channel.read(buffer);
if (count == -1) {
// EOF,不是 IOException
}
不能把 -1 当作异常,也不能因为没有异常就认为一定读到了数据。
3. Buffer 的状态错误通常是运行时异常
ByteBuffer 的 position、limit、capacity 共同决定可读写区域。典型流程是:
ByteBuffer buffer = ByteBuffer.allocate(1024);
channel.read(buffer); // 写入模式:position 增加
buffer.flip(); // 切换到读取模式:limit = 旧 position,position = 0
while (buffer.hasRemaining()) {
channel.write(buffer);
}
如果遗漏 flip(),写入操作留下的 position 不会自动变成读取边界,可能导致没有数据可写或读到错误区域。这类错误通常表现为 BufferUnderflowException、BufferOverflowException 或逻辑错误,而不是受检 I/O 异常。
因此,API 契约不仅包括 throws,还包括状态转换前置条件:
- 谁负责调用
flip(); - 方法返回后 buffer 处于读模式还是写模式;
- 是否允许部分读写;
- 发生异常后资源和 buffer 是否仍可复用。
八、异常转换:跨层传播时保持抽象边界
1. 低层异常不应无条件泄漏到领域层
假设业务层直接暴露:
User loadUser(Path path) throws IOException
这会把文件系统细节写入业务 API。将来数据源改成数据库或远程服务时,调用者可能被迫理解多个基础设施异常。
可以在边界处转换:
class UserLoadException extends RuntimeException {
UserLoadException(String message, Throwable cause) {
super(message, cause);
}
}
static User loadUser(Path path) {
try {
String json = Files.readString(path);
return parseUser(json);
} catch (IOException e) {
throw new UserLoadException("读取用户资料失败: " + path, e);
} catch (ParseException e) {
throw new UserLoadException("解析用户资料失败: " + path, e);
}
}
转换需要满足两个条件:
- 新异常表达当前层能理解的失败语义;
- 原始异常通过
cause保留,便于诊断。
2. 不要把所有异常都包装成同一种异常
下面的代码会破坏调用者区分故障的能力:
try {
return Files.readString(path);
} catch (Exception e) {
throw new UserLoadException("failed", e);
}
它会把编程错误、线程中断、严重错误等也混在一起。通常应只捕获能够合理处理或转换的类型:
catch (IOException e) {
// 转换为存储层异常
}
对于 InterruptedException,一般不能简单包装后继续执行。应恢复中断标志:
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new UserLoadException("线程被中断", e);
}
InterruptedException 表示协作式取消信号。捕获后如果不恢复中断状态,上层的取消逻辑可能无法观察到该信号。
九、API 契约:异常是接口的一部分
1. 受检异常是编译器可验证的契约
下面的 API 明确要求调用者处理存储失败:
interface UserStore {
User find(String id) throws UserStoreException;
}
调用者必须:
try {
User user = store.find("u-1");
} catch (UserStoreException e) {
// 选择重试、返回错误响应或终止当前操作
}
这种契约适用于调用者能够采取有意义行动的失败,例如:
- 文件不存在;
- 外部服务暂时不可用;
- 输入格式不符合协议;
- 事务提交失败;
- 权限不足。
如果调用者只能在每一层机械地记录并重新抛出,受检异常会增加样板代码,却没有增加决策价值。
2. 非受检异常仍然必须记录在 API 文档中
IllegalArgumentException 不要求调用者捕获,但方法仍然应说明参数条件:
/**
* @param timeoutMillis 必须大于等于 0
* @throws IllegalArgumentException 当 timeoutMillis 小于 0 时
*/
void setTimeout(long timeoutMillis);
非受检异常也属于行为契约,只是编译器不强制调用者处理。
3. 重写方法不能扩大受检异常范围
interface Reader {
String read() throws IOException;
}
class FileReader implements Reader {
@Override
public String read() throws FileNotFoundException {
return "...";
}
}
这是合法的,因为 FileNotFoundException 是 IOException 的子类。
下面不合法:
class NetworkReader implements Reader {
@Override
public String read() throws SQLException {
return "...";
}
}
SQLException 不是 IOException 的子类,重写方法扩大了受检异常范围。否则调用者只根据 Reader 类型编写代码时,编译器无法保证其处理完整。
重写方法可以:
- 不声明受检异常;
- 声明更具体的受检异常;
- 声明父方法已允许的受检异常;
- 任意抛出非受检异常。
这体现了可替换性:调用者按照父类型契约使用对象,子类型不能要求调用者额外处理新的受检异常。
4. 异常类型应表达可分类的语义
不推荐让调用者通过消息判断:
if (e.getMessage().contains("timeout")) {
retry();
}
消息可能变化、国际化或为空。更稳定的方式是使用类型或结构化字段:
class RemoteCallException extends Exception {
private final boolean retryable;
RemoteCallException(String message, boolean retryable, Throwable cause) {
super(message, cause);
this.retryable = retryable;
}
boolean isRetryable() {
return retryable;
}
}
调用者基于 isRetryable() 做决策,而不是解析日志文本。
十、异常策略:处理、传播、转换与恢复
一个方法面对异常时,通常有四种不同动作。
1. 处理
方法能够完成恢复或给出替代结果:
static String readOptional(Path path) {
try {
return Files.readString(path);
} catch (NoSuchFileException e) {
return "";
} catch (IOException e) {
throw new UncheckedIOException("读取失败", e);
}
}
这里仅将“文件不存在”定义为空内容;其他 I/O 失败仍然传播。
2. 传播
当前层没有足够上下文决定如何处理:
static byte[] download(URI uri) throws IOException {
// 让上层决定 HTTP 响应、重试或降级策略
return ...;
}
3. 转换
底层类型不应成为当前抽象的公开契约:
catch (IOException e) {
throw new DocumentLoadException("文档加载失败", e);
}
4. 恢复
恢复必须保证业务状态仍然一致。下面的重试不能只根据“捕获了异常”决定:
for (int attempt = 1; attempt <= 3; attempt++) {
try {
return remoteCall();
} catch (RemoteCallException e) {
if (!e.isRetryable() || attempt == 3) {
throw e;
}
}
}
throw new AssertionError("unreachable");
只有明确知道操作可重试时才能重试。对扣款、创建订单等非幂等操作,网络超时并不代表服务端没有执行成功,盲目重试可能导致重复副作用。
十一、异常与并发:失败可能被包装或延迟观察
1. Future.get() 会包装任务异常
ExecutorService executor = Executors.newSingleThreadExecutor();
try {
Future<String> future = executor.submit(() -> {
throw new IOException("read failed");
});
future.get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("等待任务时被中断", e);
} catch (ExecutionException e) {
Throwable cause = e.getCause();
// cause 通常是任务实际抛出的 IOException
throw new IllegalStateException("异步任务失败", cause);
} finally {
executor.shutdown();
}
这里调用 submit 的线程不会立即看到任务中的 IOException。异常被保存到 Future,直到 get() 时以 ExecutionException 形式报告。
因此诊断时要区分:
ExecutionException —— Future API 的包装异常
└── cause —— 异步任务实际失败
2. CompletableFuture 也可能包装异常
在异步链中:
CompletableFuture
.supplyAsync(this::load)
.thenApply(this::parse)
.exceptionally(error -> {
// error 可能是 CompletionException
Throwable cause = error.getCause() != null
? error.getCause()
: error;
return fallback(cause);
});
具体包装形式取决于观察 API 和失败发生的位置。公共错误处理代码通常需要沿 cause 链检查真正原因,但不能无条件剥掉所有包装,否则可能丢失异步阶段的语义。
3. 中断不是普通业务异常
处理中断时应保留中断状态:
try {
queue.take();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
如果直接:
catch (InterruptedException e) {
log.warn("interrupted", e);
}
线程的中断标志已经被 InterruptedException 清除。上层可能因此继续等待、无法停止或无法取消任务。
并发代码中的异常还可能发生在线程边界之外:
- 工作线程异常不会自动抛到提交线程;
- 线程池任务可能只在
Future.get()时暴露; - 未捕获异常可能由线程的
UncaughtExceptionHandler处理; - 取消可能表现为
CancellationException或InterruptedException,不能统一当作普通失败。
十二、日志、堆栈和异常信息
1. 记录异常对象,而不是只记录消息
推荐:
logger.error("加载订单 {} 失败", orderId, e);
不推荐:
logger.error("加载订单失败: {}", e.getMessage());
前者保留类型、堆栈、原因链和 suppressed exceptions;后者通常只留下不可检索的文本。
2. 不要在每层重复打印同一个异常
如果底层已经记录并向上抛出,上层再次完整打印,可能产生重复堆栈。常见策略是:
- 在最接近能够采取措施的边界记录;
- 中间层转换异常时只添加上下文并保留 cause;
- API 边界向客户端返回安全、稳定的错误结构;
- 不把内部路径、SQL、凭据或网络细节暴露给外部用户。
3. 异常消息中的数据也有风险
文件路径、用户输入、远程响应等内容可能包含敏感信息或控制字符。消息应提供诊断上下文,但要避免泄露:
- 访问令牌;
- 密码;
- 完整连接字符串;
- 未脱敏的个人数据;
- 内部拓扑和文件系统细节。
十三、测试异常契约
异常行为是 API 行为的一部分,应当被测试,而不是只测试成功路径。
1. JUnit 5 测试受检异常
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertSame;
import java.io.IOException;
import org.junit.jupiter.api.Test;
class DocumentServiceTest {
@Test
void preservesCauseWhenTranslatingIOException() {
DocumentLoadException error = assertThrows(
DocumentLoadException.class,
() -> service.load(Path.of("missing.txt")));
assertInstanceOf(IOException.class, error.getCause());
}
private final DocumentService service = new DocumentService();
}
测试重点不是只断言“抛了异常”,还应断言:
- 异常类型;
- 消息中是否包含必要上下文;
getCause()是否保留底层异常;- suppressed 异常是否存在;
- 中断标志是否恢复;
- 资源是否确实关闭。
2. 验证 suppressed exception
@Test
void recordsCloseFailureAsSuppressed() {
Exception error = assertThrows(
Exception.class,
() -> useFailingResource());
assertEquals("use failed", error.getMessage());
assertEquals(1, error.getSuppressed().length);
assertEquals("close failed",
error.getSuppressed()[0].getMessage());
}
这个测试能验证代码确实使用了 try-with-resources 的异常优先级,而不是错误地让关闭异常覆盖主体异常。
3. Mockito 适合验证关闭交互,但不能替代真实 I/O
@Test
void closesReader() throws Exception {
Reader reader = mock(Reader.class);
when(reader.read()).thenReturn(-1);
// 调用使用 reader 的组件
verify(reader).close();
}
这能验证生命周期交互,但不能证明真实文件、权限、EOF、部分读写和关闭失败行为。涉及文件系统边界时,临时目录测试通常更有价值;涉及数据库或网络协议时,可使用 Testcontainers 等真实依赖环境验证异常边界。
4. 不要让测试只断言消息文本
消息文本可以断言关键上下文,但不应成为唯一契约:
assertEquals("missing document", error.getMessage());
更稳定的断言通常是:
assertInstanceOf(DocumentLoadException.class, error);
assertInstanceOf(NoSuchFileException.class, error.getCause());
如果消息属于对外协议的一部分,例如 REST 错误码描述,则应将其作为明确契约设计,而不是偶然依赖异常默认消息。
十四、常见错误及其失败表现
错误一:捕获后什么都不做
try {
Files.readString(path);
} catch (IOException ignored) {
}
失败表现是:
- 调用者误以为操作成功;
- 状态可能只完成了一半;
- 真正故障没有日志和指标;
- 后续错误远离根因。
只有在异常确实代表“可安全忽略”的情形下,空处理才合理,而且应通过变量名、注释或结构让这个决定可审查。
错误二:重新抛出时丢失 cause
catch (IOException e) {
throw new ServiceException("service failed");
}
失败表现是堆栈只显示业务异常,无法定位是权限、文件不存在、连接关闭还是磁盘故障。
错误三:用 Exception 或 Throwable 作为日常控制结构
try {
execute();
} catch (Exception e) {
return fallback();
}
这可能把参数错误、编程错误、线程中断和真正可恢复的外部失败混为一谈。应优先捕获最窄的、确实能处理的类型。
错误四:手写资源关闭逻辑覆盖主异常
Exception primary = null;
try {
use();
} catch (Exception e) {
primary = e;
throw e;
} finally {
close(); // 可能覆盖 primary
}
除非有非常明确的特殊需求,否则应改用 try-with-resources。
错误五:捕获中断后继续执行
catch (InterruptedException e) {
log.info("interrupted");
}
continueWork();
这会破坏取消协议。若当前方法不能继续,应恢复中断并返回或抛出;若确实能完成清理,也应在清理后保持中断语义。
错误六:以为 finally 一定执行
finally 不能保护进程被终止、JVM 崩溃、宿主机断电或线程永远阻塞的情况。需要跨进程可靠性的操作不能只依赖内存中的清理代码,还需要事务、日志、恢复协议或外部协调机制。
十五、建立异常 API 的推导方法
设计一个方法的异常契约时,可以按以下顺序推导,而不是先决定“全部用 checked”或“全部用 unchecked”。
第一步:列出可能失败的操作
例如:
解析输入
读取文件
调用远程服务
写入数据库
提交事务
关闭资源
第二步:区分失败是否可由调用者采取行动
输入非法 → 返回参数错误或抛出明确的参数异常
文件不存在 → 调用者可能选择创建、降级或返回 404
远程超时 → 可能重试,但要考虑幂等性
JVM 内存耗尽 → 通常不是当前方法可恢复的业务失败
第三步:确定抽象层次
底层:
throws IOException
存储层:
throws UserStoreException
HTTP 边界:
转为稳定的错误码和响应体
每经过一层,异常类型可以变化,但 cause 应保留,除非原始信息确实不能安全保留或属于敏感数据。
第四步:定义主异常和附加异常的优先级
对于资源型操作,应遵守:
主体失败 + 关闭失败
→ 主体异常传播,关闭异常 suppressed
主体成功 + 关闭失败
→ 关闭异常传播
对于批量操作,则可能需要显式设计多个失败结果,而不是只抛出第一个异常:
record ItemFailure(String id, Throwable error) {}
异常适合表示当前操作无法按其契约完成;如果业务本身允许部分成功,结果类型通常比异常更能表达完整状态。
第五步:定义测试可观察行为
至少应明确:
- 抛出哪一种异常;
- 是否保留 cause;
- 是否保留 suppressed;
- 是否关闭资源;
- 是否恢复中断;
- 哪些失败可重试;
- 哪些消息或错误码对外稳定。
十六、一个完整的分层示例
下面展示文件读取、异常转换、资源管理和测试关注点如何组合。
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
record User(String id, String name) {}
class UserDocumentException extends Exception {
UserDocumentException(String message, Throwable cause) {
super(message, cause);
}
}
class UserDocumentStore {
User load(Path path) throws UserDocumentException {
final String content;
try (var reader = Files.newBufferedReader(path)) {
content = reader.readLine();
} catch (IOException e) {
throw new UserDocumentException(
"无法读取用户文档: " + path, e);
}
if (content == null || content.isBlank()) {
throw new UserDocumentException(
"用户文档为空: " + path, null);
}
String[] fields = content.split(",", -1);
if (fields.length != 2) {
throw new UserDocumentException(
"用户文档格式错误: " + path, null);
}
return new User(fields[0], fields[1]);
}
}
其错误路径如下:
newBufferedReader 失败
→ IOException
→ UserDocumentException(cause = IOException)
readLine 返回 null
→ UserDocumentException
→ 没有底层 cause,因为这是业务校验失败
readLine 成功但格式错误
→ UserDocumentException
这个设计区分了两类失败:
- I/O 失败:由
IOException提供底层原因; - 文档内容不符合业务格式:本层直接生成领域异常。
如果读取主体失败、关闭也失败,try-with-resources 会把关闭异常放入原始 IOException 的 suppressed 列表,然后该 IOException 作为 UserDocumentException 的 cause 保存下来,关系仍然完整:
UserDocumentException
└── cause: IOException
└── suppressed: close failure
十七、规范保证、实现行为与经验建议的边界
以下内容属于 Java 语言或核心库契约,可以依赖:
- 受检异常必须被捕获或声明;
catch按源代码顺序匹配;- 重写方法不能扩大受检异常范围;
- try-with-resources 按逆序关闭资源;
- 主体异常优先传播,关闭异常进入 suppressed 列表;
Throwable提供 cause 和 suppressed exception 机制;InterruptedException的处理会影响线程中断协议。
以下内容不要当作语言保证:
- 堆栈信息一定输出到哪个日志系统;
- 未捕获异常一定导致整个进程退出;
- 某个具体资源实现一定允许重复
close(); - 某个文件系统操作在所有平台上具有相同的错误类型;
- 某个网络异常一定表示请求未在远端执行。
经验上,异常处理应围绕“谁有能力决定下一步”来分层:
- 有能力恢复,就处理;
- 没有能力恢复,就传播;
- 需要改变抽象,就转换并保留 cause;
- 需要清理资源,就使用 try-with-resources;
- 需要对外稳定表达,就使用明确的异常类型、错误码或结果结构;
- 需要支持取消,就正确处理并恢复中断状态。
异常处理的目标不是让代码“永不抛异常”,而是在失败发生时保持三个信息不丢失:失败是什么、为什么失败、失败后资源和状态处于什么状态。当受检异常表达清晰的调用责任,错误链保留因果关系,资源关闭遵守主异常优先规则,API 契约便能同时服务于编译器、调用者、测试和生产诊断。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Java 泛型完整基础:类型擦除、通配符、PECS、边界和反射
- 下一篇:Java I/O 与 NIO:Stream、Channel、Buffer、文件和网络边界
- 延伸:Java 测试体系:JUnit 5、AssertJ、Mockito、Testcontainers 和并发测试
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论