异常处理是 Java 区别于多数语言的一大特色,也是工程中最容易"靠吞异常敷衍"的地方。一个系统的健壮性,恰恰取决于错误被如何分类、如何抛出、如何记录。本文从异常体系出发,给出防御式编程的可落地清单。
一、异常体系总览
Throwable
├── Error —— JVM 无法恢复的事(OutOfMemoryError、StackOverflowError)
└── Exception
├── RuntimeException(unchecked)—— 程序可恢复的运行时错误
└── 其他(checked)—— 编译器强制要处理的(IOException、SQLException)
// 关键区别
try {
// checked:编译器逼你 try-catch 或 throws
Files.readAllLines(Paths.get("file.txt"));
// unchecked:可抛出但编译器不强制(如空指针、索引越界)
int x = list.get(100);
} catch (IOException e) {
// 必须处理
}
| 类别 | 例子 | 编译器 | 态度 |
|---|---|---|---|
| Error | OOM、SO | 不回传 | 不要捕获 |
| checked | IOException、SQLException | 强制处理 | 尽量恢复/转义 |
| unchecked | NPE、IllegalState | 可选 | 尽早失败、显式抛出 |
一句话知识点:Error 别 catch(无法恢复);checked 是"可以恢复"的放信号;unchecked 是"你代码写错了"的缺省信号。分对三层,是异常设计的第一步。
二、checked 还是 unchecked:该怎么选
现代工程主流共识:业务异常多用 unchecked(RuntimeException),让调用方不必被迫 catch 无关异常,顶层集中处理。
| 判定 | 建议 |
|---|---|
| 这是"编程错误"(参数错、状态错) | IllegalArgumentException / IllegalStateException |
| 这是"业务规则违反" | 自定义 RuntimeException |
| 必须由调用者立刻动作(如文件不存在) | 可用 checked |
| 需要跨系统(NOT 强制) | 定义业务异常统一结构 |
// 定义一个业务异常
public class BizException extends RuntimeException {
public BizException(String code, String message) { ... }
}
public class OrderService {
public void pay(String orderId, BigDecimal amount) {
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new BizException("AMOUNT_INVALID", "金额必须大于 0"); // 业务异常
}
}
}
一句话知识点:异构代码的本分——业务不合规 → 抛业务异常(RuntimeException),让全局异常处理器(如
@ControllerAdvice)统一收敛,调用链不至于被层层catch污染。
三、正确抛出与捕获异常
3.1 抛出原则
// 推荐:保留根因,必要时包一层并保留 cause
} catch (SQLException e) {
// 不要这样!丢失原有异常
throw new RuntimeException("数据库 error");
// 要这样:带 cause
throw new DbException("query user failed", e);
}
3.2 捕获原则
| 错误 | 正确 |
|---|---|
吞掉异常 catch(E e){} | 至少 log.warn + 说明原因,谁不处理必须 rethrow |
| catch 后就改业务逻辑 | 转译或重新抛出业务异常 |
| catch (Exception) 全包 | 明确具体异常 + 分级处理 |
// 分级、保留链路的示例
try {
validate(input);
} catch (BizException e) {
throw e; // 业务异常透传,交给上层统一处理
} catch (NumberFormatException e) {
throw new BizException("BAD_INPUT", "输入不是合法数字", e); // 包装+原因
} catch (Exception e) {
log.error("unexpected while validate", e); // 兜底但不吞
}
一句话知识点:捕获前先问"我要不要真的处理它"——要恢复就 catch 恢复,要不能让就立刻 rethrow 或包装,绝不 silent swallow。日志里必须带异常对象(
e)才能留上下文。
四、try-with-resources:资源绝不会泄漏
// JDK7+:实现 AutoCloseable 的资源自动关闭
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.execute();
} // 自动 conn.close(); ps.close()(逆序会) ——无论有无异常
// 等价于传统 finally。但 try-with-resources 更安全:
// 若 try 块和 close 都抛异常,后者会加为前者 suppressed。
// 自定义 AutoCloseable
public class Resource implements AutoCloseable {
@Override public void close() { ... }
}
| 方案 | 缺点 |
|---|---|
| 手动 finally close | 易忘、易写错顺序 |
| try-with-resources | 自动逆序关闭,更安全、更短 |
一句话知识点:凡是打开就要记得关(IO、连接、socket)的对象,一律 try-with-resources 声明,让 JVM 帮你兜底,杜绝内存/句柄/连接池耗尽。
五、Optional 与空值防御
5.1 何时用 Optional
Optional 是用来"表达的返回值可能为空"的容器,不该用来包装字段或参数,也不该当 if 的键盘。
// 正确:方法返回可能无值
public Optional<User> findUser(String name) {
return users.stream().filter(u -> u.name().equals(name)).findFirst();
}
// 使用
User u = findUser("x").orElseThrow(() -> new NotFoundException("user x"));
// 避免:把它当成员字段 / 装箱判空
// public Optional<String> name; // ✗ 不推荐
5.2 防御式辅助(早期失败)
public void register(String phone) {
Objects.requireNonNull(phone, "phone can't be null"); // early fail
if (!validator.isPhone(phone)) throw new IllegalArgumentException("phone invalid");
// 剩余逻辑
}
一句话总结:Optional 是"方法返回值可能为空"的语义化容器,配合
orElseThrow/map用,能写出"不隐藏分支、不空指针"的代码;参数空值用Objects.requireNonNull先失败后相信。
六、异常日志与链路追踪
生产系统里,异常必须可追踪、可定位:
// 记录堆栈 链,千万别只打 message
logger.error("order create failed, orderId={}, userId={}", orderId, userId, e);
// 结构化:加入 traceId(SLF4J MDC)
MDC.put("traceId", UUID.randomUUID().toString());
try { ... } finally { MDC.clear(); }
日志要点:
1. 带参数(orderId/userId)和堆栈
2. 不同级别分级(error 才报警)
3. 与链路追踪(traceId/spanId)关联
4. 不要打印敏感字段(密码、token)
一句话总结:异常日志要"上下文+堆栈+可追踪的 ID",否则线上收到告警却不知道"哪个单、哪个用户、哪条链路",没法定位和回滚。
七、防御式编程:把"别人会用错"当预期
| 原则 | 落地 |
|---|---|
| 快速失败 | 参数校验用了(非法输入立即抛) |
| 不可枚举状态 | 明确返回枚举,不使用魔法数 |
| 健壮默认值 | 配置取不到给默认值并在日志 warning |
| 防御 null | Objects.requireNonNull + Optional.ofNullable |
| 异常边界 | 让框架统一处理业务异常,避免侵入各层 |
public class Config {
private final Map<String,String> props;
public String get(String key) {
if (props == null) throw new IllegalStateException("props not initialized");
return props.getOrDefault(key, "") + "";
}
}
测试对异常的态度
- 单元测试应覆盖"异常路径"(非法输入、超时、无数据)。
- 用
assertThrows明确断言异常类型与 message。
@Test
void pay_whenAmountZero_throws() {
assertThrows(IllegalArgumentException.class, () -> service.pay(0));
}
一句话总结:防御式编程 = “默认输入会错、中间态可能没有、依赖可能为空”,提前校验、交快失败、给默认值,并用异常写清楚"为什么不行"。
八、易踩的坑
| 坑 | 症状 | 对策( |
|---|---|---|
吞异常 catch{} | 出错无迹可查 | 至少 log + 重新抛 |
| catch 里再抛出全打印 | 异常链丢失 | 带 cause 链 |
| 资源不 close | 连接/句柄泄漏 | try-with-resources |
| 用 Exception 全族 catch | 掩盖真实问题 | 分类处理 |
| 把业务错误用返回码 | 凌乱 | 抛业务异常 |
| 日志不打印入参 | 无法排障 | 带业务参数 + traceId |
九、总结
| 环节 | 铁律 |
|---|---|
| 分类 | Error 别抓;业务用 Runtime;外部用 checked |
| 抛出 | 带 cause、带上下文,不吞 |
| 资源 | try-with-resources 自动关闭 |
| 空值 | Optional / requireNonNull / early-fail |
| 日志 | error 必带业务参数 + 堆栈 + traceId |
一句话记住:健壮代码的核心是"让错误以正确的方式尽快出现、并能被追溯":该失败的别静默,该记录的别只打一句,该释放的别等 GC——把这句话写进 Review CheckList,异常问题能减掉大半。
延伸阅读
- Java 17+ 核心语法深度指南 — Optional/Record 与异常配合
- Java 测试策略:JUnit 5 + Mockito — assertThrows 与异常路径用例
- Java 性能优化 — 异常开销与性能权衡
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。