Java 异常处理与防御式编程实战

系统讲解 Java 异常体系与防御式编程:异常层次与选择、checked/unchecked 取舍、try-with-resources 与资源释放、异常日志与链路追踪、Optional 与早期失败,并用实战场景演示健壮代码写法。

异常处理是 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) {
    // 必须处理
}
类别例子编译器态度
ErrorOOM、SO不回传不要捕获
checkedIOException、SQLException强制处理尽量恢复/转义
uncheckedNPE、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
防御 nullObjects.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」更多文章

  1. Java 日志体系与工程实践完整指南
  2. Java 虚拟机类加载机制与字节码深度解析
  3. Java 网络编程与 NIO 实战