异常处理最佳实践
概述
在掌握 异常体系、异常处理语法 和 自定义异常 之后,还需要在工程实践中遵循一些最佳实践,才能让异常机制真正提升程序的健壮性和可维护性。本文从「何时处理」「如何记录」「资源与异常链」「受检与非受检的选择」等角度总结常见原则,并指出需要避免的反模式。
核心原则概览
| 原则 | 说明 |
|---|---|
| 明确责任边界 | 在「能处理且有价值」的层级处理异常;否则声明抛出或包装后抛出,由上层或统一入口处理。 |
| 不要吞掉异常 | 捕获后至少要记录日志或重新抛出,避免空的 catch 导致问题被隐藏。 |
| 资源用 try-with-resources | 对实现了 AutoCloseable 的流、连接等,用 try-with-resources 自动关闭,避免泄漏。 |
| 保留异常链 | 包装异常时使用带 cause 的构造方法,便于排查根因。 |
| 用日志而非仅 printStackTrace | 生产环境应使用日志框架记录异常信息与堆栈,便于分级与检索。 |
下面按「该做的」和「不该做的」分别说明。
该做的:推荐实践
在能处理的层级处理异常
只有在当前层级能做出有意义处理(如重试、降级、转换错误码、记录并返回友好提示)时才 catch;否则应 throws 交给调用方,或包装成业务异常后抛出,由上层或全局异常处理器统一处理。
// 推荐:当前方法无法恢复,声明抛出,由调用方或统一异常处理负责
public String loadConfig(String path) throws IOException {
return Files.readString(Path.of(path));
}
// 推荐:当前层能处理(如返回默认值),在此处 catch
public int parsePort(String value) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
return 8080; // 有意义的降级
}
}捕获后至少要记录或重新抛出
不要吞掉异常:catch 块里若什么都不做,问题会被隐藏,排查困难。
// 不推荐:吞掉异常,无法发现错误
try {
doSomething();
} catch (IOException e) {
// 空 catch,危险
}
// 推荐方式 1:记录日志后根据业务决定是否再抛出
try {
doSomething();
} catch (IOException e) {
log.warn("操作失败,使用默认配置", e);
applyDefaultConfig();
}
// 推荐方式 2:无法处理时包装后重新抛出,保留原因链
try {
doSomething();
} catch (IOException e) {
throw new BusinessException("加载配置失败", e); // cause 保留 e
}提示
生产代码中应使用 SLF4J + Logback 等日志框架的 log.error("描述", e) 记录异常,避免仅用 e.printStackTrace() 输出到标准错误。
使用 try-with-resources 管理资源
对 InputStream、OutputStream、Reader、Writer、Connection 等实现了 AutoCloseable 的资源,优先使用 try-with-resources,确保发生异常时也会自动关闭,避免资源泄漏。
// 推荐:自动关闭,异常时也会正确释放
try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
String line;
while ((line = br.readLine()) != null) {
process(line);
}
}
// 不推荐:手动 close,若中间抛异常可能未执行 close(即使写 finally 也容易漏、代码冗长)
BufferedReader br = new BufferedReader(new FileReader("data.txt"));
try {
// ...
} finally {
br.close(); // 不如 try-with-resources 简洁可靠
}包装异常时保留原因链(cause)
将底层异常(如 IOException、SQLException)包装成业务异常时,应使用 带 cause 的构造方法,这样堆栈中会保留完整原因链,便于定位根因。
// 推荐:保留 cause,堆栈中能看到原始 IOException
try {
return Files.readString(Path.of(path));
} catch (IOException e) {
throw new ConfigLoadException("加载配置失败: " + path, e);
}
// 不推荐:仅传 message,丢失了底层异常信息
} catch (IOException e) {
throw new ConfigLoadException("加载配置失败"); // e 被丢弃
}自定义异常时应提供 (String message, Throwable cause) 构造方法,并在 自定义异常 中调用 super(message, cause)。
选择受检异常还是非受检异常
- 受检异常:调用方必须处理(try-catch 或 throws),适合「可恢复的、预期可能发生的」情况(如文件不存在、网络超时)。
- 非受检异常:不强制处理,适合「编程错误、前置条件不满足、非法状态」等(如空指针、非法参数)。
自定义业务异常时:若希望强制调用方处理(如「格式不支持」),用受检异常;若由上层或全局统一处理(如「用户不存在」),用非受检异常(继承 RuntimeException)更常见,可减少无意义的 throws 和 catch。
不该做的:常见反模式
避免空的 catch 或仅 printStackTrace
// 反模式:空 catch
try {
risky();
} catch (Exception e) {}
// 反模式:仅打印堆栈,生产环境难以集中收集与检索
} catch (Exception e) {
e.printStackTrace();
}应改为:记录日志(含堆栈),或包装后重新抛出。
避免过度使用 catch (Exception e)
在业务代码里笼统地 catch (Exception e) 会吞掉所有异常(包括本应暴露的编程错误),并难以针对不同异常做不同处理。应尽量捕获具体类型,必要时再补一层 catch (Exception e) 做兜底并记录日志后重新抛出。
// 不推荐:一把抓,无法区分 IO 错误与编程错误
try {
readFile();
parseData();
} catch (Exception e) {
log.error("出错", e);
return null;
}
// 推荐:区分具体异常类型,无法处理的再抛出
try {
readFile();
parseData();
} catch (IOException e) {
log.warn("文件读取失败", e);
return loadDefault();
} catch (NumberFormatException e) {
throw new IllegalStateException("数据格式错误", e); // 编程/数据错误,应暴露
}注意
在框架层或边界层(如全局异常处理器)中,用 catch (Exception e) 做统一兜底是可以的,但应记录完整日志并酌情转换为统一错误响应,而不是静默吞掉。
不要用异常做正常流程控制
异常机制是有成本的(构造堆栈、查找 catch)。正常分支应用 if/返回值/Optional 等表达,不要用「抛异常再 catch」来代替。
// 反模式:用异常表示「未找到」,正常流程走 catch
try {
User u = findUser(id);
return u.getName();
} catch (UserNotFoundException e) {
return "未知用户";
}
// 推荐:用返回值或 Optional 表达「可能不存在」
User u = findUser(id); // 返回 Optional<User> 或 null(并约定语义)
return u != null ? u.getName() : "未知用户";若「未找到」在业务上确实是异常情况,用非受检异常由上层统一处理也可接受,但不要用异常去驱动正常分支逻辑。
不要捕获 Error
Error 及其子类(如 OutOfMemoryError、StackOverflowError)表示 JVM 或系统级严重错误,业务代码不应捕获它们,捕获后也难以做出可靠恢复。参见 异常体系 - Error。
综合示例:分层中的异常处理
下面用「配置加载 + 解析」串联:资源用 try-with-resources、包装时保留 cause、能处理的层处理、不能处理的声明抛出。
// 底层:只负责 IO,无法处理时声明抛出
public static String readFileContent(String path) throws IOException {
try (BufferedReader br = Files.newBufferedReader(Path.of(path))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) {
sb.append(line).append("\n");
}
return sb.toString();
}
}
// 中层:将 IOException 包装为业务异常并保留 cause,便于上层统一处理
public static AppConfig loadConfig(String path) {
try {
String content = readFileContent(path);
return parseConfig(content);
} catch (IOException e) {
throw new ConfigLoadException("加载配置失败: " + path, e);
}
}
// 上层或全局:捕获业务异常,记录日志并返回友好错误(如 HTTP 500 与统一错误码)
try {
AppConfig config = loadConfig("app.conf");
run(config);
} catch (ConfigLoadException e) {
log.error("配置加载失败", e); // 含 cause 链
return errorResponse("CONFIG_ERROR", "配置不可用");
}小结
| 场景 | 建议 |
|---|---|
| 能处理且有价值 | 在当前层 catch 并处理(重试/降级/记录后返回) |
| 不能处理 | throws 或包装后抛出,保留 cause,由上层或全局处理 |
| 资源(流、连接) | 使用 try-with-resources |
| 记录异常 | 用日志框架记录 message + 堆栈,避免空 catch 或仅 printStackTrace |
| 自定义异常 | 受检/非受检按「是否强制调用方处理」选择;包装时传 cause |
| 避免 | 空 catch、过度 catch(Exception)、用异常做正常流程控制、捕获 Error |
相关链接
- 异常体系 — Throwable、Error、Exception 与受检/非受检
- 异常处理 — try-catch-finally、throws、try-with-resources
- 自定义异常 — 业务异常设计与异常链
- Oracle 教程 - 异常