Java · 异常体系与实践

Throwable 家族树与受检边界 — try-with-resources 的 suppressed 不丢账, fillInStackTrace 是异常真正的性能账单

Throwable 家族树 (三分法) Throwable Error — 别 catch Exception — 可处理 OutOfMemoryError StackOverflowError JVM 级致命伤 唯一正解: 快速失败 别带着坏状态算账 RuntimeException 非受检 NPE / IllegalArgument IllegalStateException IOException / SQLException 受检 = 编译器强迫 catch 或 throws 非受检 = 程序 bug, 修代码优先于捕获 ServiceException 上层 NotFound 底层 cause new X("上下文", e) 保链 — 日志沿 cause 打全现场 catch 之前先问自己三句 1. 这个异常我能恢复吗? 不能就别 catch 2. catch 的是具体类型吗? 别 Exception 一把抓 3. catch 之后干嘛? 翻译/记日志/重试/重抛 例: 超时可重试, 参数错直接抛给全局兜底 try-with-resources 关闭时序 try (A a = open(); B b = open()) { work(); } ① try 体抛出 E_main (业务异常) 先记账挂起, 等资源关完再抛 — 不会丢 ② 反向 close: 先关 B (后声明先关) 嵌套资源 B 持有 A 的句柄, 关反了 use-after-close ③ 再关 A ④ close(B) 也抛了 E_close ? 手写 finally: E_close 直接顶掉 E_main — 主异常丢了 ⑤ 最终抛出: E_main + suppressed[0] 编译器自动 addSuppressed, 两个异常都保留 e.getSuppressed() 取被压制的异常数组 等价展开 (编译器替你写的) 手写要 catch 住 close 的异常再 addSuppressed, 嵌套两层 try 才能等价 — 没人愿意手写 多资源声明的关闭顺序 = 逆序 (栈式) JDK 9 起支持 effectively final 变量直接引用 自定义资源复用同一机制 分布式锁/跨行事务/连接: implements AutoCloseable close() 必须幂等 — 它可能被调用多次 异常的性能账单 new Exception() 的真开销 构造器里 fillInStackTrace() 同步抓整条栈 成本随栈深线性涨, 抛出前就已付掉 深栈 200 帧 (Web 一层调用) ~1.5 μs 中栈 30 帧 (普通服务方法) ~0.4 μs 无栈异常 (override 不抓栈) ~20 ns 量级示意 (JMH): 抓栈是大头, JIT 救不了 高频路径两板斧 1. 无栈: fillInStackTrace() { return this; } 2. 哨兵: static final 预分配单例, 零构造 适用: 只看类型不看 message 的控制信号 哨兵异常的边界 单例共享: 抛出点不能塞 message/上下文 调用方只能靠类型 catch 分流 message 要带上下文 '查询失败' 不如 'order 10086 未找到, userId=42' 排障省一轮复现 反模式: 异常当流程控制 解析热路径每行 throw: 百万次/秒 ≈ 每秒 1.5 秒纯抓栈 CPU, 吞吐直接腰斩 正解: Optional/返回码, 必须抛就用无栈哨兵 栈轨迹只在"真异常"时才有价值 Legend Throwable/最终异常 Error·高危路径 资源关闭/推荐 Exception/受检分支 非受检/警示

三分法与捕获边界

  • • Error 是 JVM 级致命伤, 唯一正解是快速失败
  • • RuntimeException 多是程序 bug, 修代码别修 catch
  • • 受检异常是外部故障, 编译器强迫你想清楚

try-with-resources 的隐藏礼盒

  • • 反向 close: 后声明的资源先关 (栈式)
  • • 主异常不丢: close 抛的进 suppressed 数组
  • • 锁/事务/连接 implements AutoCloseable 即可复用

异常的性能账单

  • • 大头是 fillInStackTrace 抓整条栈, 不是 throw
  • • 热路径用无栈异常/预分配哨兵, 量化到 μs
  • • message 带上下文, 全局兜底 + 日志打全链

💡 一句话理解

Java 异常的本质是"可抛出的控制流信号": Throwable 家族分成 Error(JVM 的白旗, 别碰)、受检异常(编译器强迫你处理的外部故障)、非受检异常(你自己的 bug) — catch 的边界就是"我能恢复的边界"。而 try-with-resources 是编译器替你写的一段完美 finally: 反向关闭资源, 主异常优先, close 抛的异常 addSuppressed 进旁挂数组, 一个都不丢。最后记住性能账单: 异常贵在构造时的 fillInStackTrace(), 热路径上把异常当流程控制, 每一次 throw 都在同步抓整条调用栈。

🧠 必知必会 必考 & 必会

三分法
Throwable → Error(OutOfMemoryError/StackOverflowError, JVM 级致命) 与 Exception; Exception 再分 RuntimeException(非受检) 与其余受检(IOException/SQLException)。捕获边界按"能否恢复"划, 不按"能不能编译过"划。
new OutOfMemoryError("x") instanceof Exception; // → false: Error 不归 Exception 管
new NullPointerException() instanceof Exception;     // → true: 非受检也是 Exception
// 受检 IOException: 不 catch 也不 throws → 编译不过
checked 之争
受检异常让调用方在编译期面对故障; 但大批量改签名导致 API 腐化, lambda 里更是没法直接抛 — Kotlin/C#/现代框架基本都走非受检 + 文档/全局兜底。新代码惯例: 业务异常 extends RuntimeException。
void api() throws IOException { read(); }  // 受检: 调用方被迫处理/上抛
list.forEach(x -> read(x));              // ✗ lambda 里抛 checked → 编译不过
// 新代码惯例: 业务异常 extends RuntimeException, 边界统一收口
异常链 cause
底层异常翻译成上层语义时必须保链: new ServiceException("创建订单失败", e) 或先构造再 initCause(e)(只能调一次)。日志框架沿 cause 链打印 "Caused by", 丢了链等于丢了现场。
try { dao.find(oid); }
catch (SQLException e) {
    throw new ServiceException("订单查询失败 oid=" + oid, e);  // 关键: e 作 cause
}
// 日志 → Caused by: SQLException ... 原始现场全保留
try-with-resources 展开
资源类实现 AutoCloseable, 编译器生成: 逆序 close + 每个.close 的异常 addSuppressed 到主异常, 手写等价 finally 需要两层嵌套 try — 没人该手写。
try (A a = openA(); B b = openB()) {  // 关键: 逆序 close, 先 b 后 a
    work();
}   // close 抛的异常 addSuppressed 到主异常, 一个不丢
suppressed 异常
主异常里挂的"旁账": getSuppressed() 取数组。典型来源是 close() 抛的异常; finally 里手动 throw 会直接顶掉主异常(无 suppressed), 这正是两者行为的分水岭。
try { ... } catch (Exception e) {
    for (Throwable s : e.getSuppressed())   // → close() 抛的旁账
        log.warn("suppressed: {}", s);
}
fillInStackTrace
Throwable 构造器里同步执行: 遍历当前调用栈逐帧记录。栈越深越贵(μs 级), 且 JIT 无法完全消除 — 这是异常性能账单的大头, throw/catch 本身反而便宜。
RuntimeException e = new RuntimeException();  // 构造即同步抓整条栈
e.getStackTrace().length;                          // → 当前栈深, 开销 ∝ 它
// 关键: 账单在构造不在 throw; 深栈 ~μs 级, JIT 消不掉
无栈异常模式
子类 override fillInStackTrace() { return this; }, 构造时不再抓栈, 成本降到 ~ns 级。适合"只看类型不看现场"的高频控制信号, 不适合需要排障的路径。
class FastSignal extends RuntimeException {
    @Override public synchronized Throwable fillInStackTrace() { return this; }
}
new FastSignal();   // → ~ns 级, 不抓栈; 只当类型信号用
哨兵异常
预分配的 static final 无栈异常单例: 抛出零构造开销。约束是全进程共享 — 不能改 message, 调用方只能按类型分流; Netty/解析器内部大量使用。
static final DirtyError DIRTY = new DirtyError();  // 预分配单例
if (!check(line)) throw DIRTY;    // 零构造开销, 按类型分流
// 约束: 全进程共享 — 不能塞 message/现场
message 上下文
"订单查询失败"是废纸, "order 10086 未找到, userId=42, trace=abc"才是排障入口。message 只读, 上下文放自定义异常的字段里(错误码/订单号), 别靠解析字符串取值。
throw new NotFound("order " + oid + " not found, userId=" + uid);
// → "order 10086 not found, userId=42"  这才是排障入口
// message 只读; 结构化上下文放异常的字段, 别解析字符串
全局兜底三件套
Web 层 @ControllerAdvice + @ExceptionHandler 统一转 HTTP 响应; 线程层 Thread.setDefaultUncaughtExceptionHandler 兜住漏网; 池化任务用 submit+get 或装饰 Runnable, 否则异常静默蒸发。
Thread.setDefaultUncaughtExceptionHandler((t, e) ->
    log.error("uncaught in {}", t.getName(), e));   // 兜住漏网
// Web 层: @RestControllerAdvice + @ExceptionHandler 统一转 HTTP
日志打印姿势
log.error("下单失败 oid={}", oid, e) — 异常作为最后一个参数, 框架才打印整条栈; e.printStackTrace() 进 stdout 不进日志聚合, 等于没打。
log.error("下单失败 oid={}", oid, e);  // 对: e 是最后一个参数 → 打全栈
// e.printStackTrace();               错: 进 stdout, ELK 收不到
业务异常基类
extends RuntimeException + 错误码(稳定枚举) + 用户可读消息 + 上下文字段(Map/强类型)。让"翻译层/重试层/HTTP 层"都能基于错误码编程, 而不是解析 message。
public class BizException extends RuntimeException {
    private final ErrorCode code;             // 稳定码, 机器判分支
    private final Map<String, Object> ctx;     // 排障上下文: skuId/want
}
多 catch 语法
catch (IOException | SQLException e) 合并捕获, 隐式 final 不能给 e 重新赋值; 类型是两者的最近公共父类, e 只能调父类方法。
try { ... }
catch (IOException | SQLException e) {   // 合并捕获, e 隐式 final
    log.warn("io/sql fail", e);      // e 只能调公共父类方法
}

🏭 生产实战 real world

场景 1 · @ControllerAdvice 统一异常出口, 响应里带 request_id

几十个 Controller 各自 try/catch 返回五花八门的错误体, 前端没法写统一拦截; 收口到一个出口, 异常翻译成 HTTP 码 + 错误码 + 排障号:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BizException.class)          // 业务错: 4xx, warn 级
    public ResponseEntity<ErrorResp> biz(BizException e, HttpServletRequest req) {
        log.warn("rid={} code={}", req.getAttribute("request_id"), e.getCode());
        return ResponseEntity.status(e.getHttpStatus())
            .body(new ErrorResp(e.getCode(), e.getUserMessage(), rid(req)));
    }

    @ExceptionHandler(Exception.class)             // 兜底: 500, 不打栈=丢现场
    public ResponseEntity<ErrorResp> unknown(Exception e, HttpServletRequest req) {
        log.error("rid={} unhandled", rid(req), e);    // e 必须是最后一个参数
        return ResponseEntity.internalServerError()
            .body(new ErrorResp("INTERNAL_ERROR", "系统繁忙, 请稍后再试", rid(req)));
    }
}

改造后前端只认一种错误体; 线上告警按 rid 一键捞出完整调用链。

场景 2 · 业务异常基类: 错误码 + 用户消息 + 上下文字段

裸 RuntimeException 只有一个字符串, 排障全靠猜; 基类把"机器可判的码"和"人可读的话"分开存:

public class BizException extends RuntimeException {
    private final String code;        // 稳定错误码: ORDER_STOCK_NOT_ENOUGH, 程序判分支用
    private final String userMsg;     // 可直接透出的用户文案
    private final Map<String, Object> ctx;  // 排障上下文: skuId/want/left

    public BizException(ErrorCode ec, Map<String, Object> ctx, Throwable cause) {
        super(ec.getCode() + ": " + ec.getUserMsg() + " ctx=" + ctx, cause); // message 自带现场
        this.code = ec.getCode(); this.userMsg = ec.getUserMsg();
        this.ctx = ctx == null ? Map.of() : Map.copyOf(ctx);
    }
}
// 抛出: throw new BizException(ErrorCode.STOCK_SHORT,
//         Map.of("skuId", skuId, "want", n, "left", stock), null);

场景 3 · 高频脏数据探测: 无栈哨兵异常, 百万次/秒不崩

风控前置的行级校验, 脏数据占比 30%, 每秒百万行; 普通 Exception 每次构造抓 80 帧栈, CPU 全烧在 fillInStackTrace:

// 预分配一次的哨兵: 无栈, 单例, 类型即信号
static final DirtyLineError DIRTY = new DirtyLineError();
static class DirtyLineError extends RuntimeException {
    @Override public synchronized Throwable fillInStackTrace() { return this; }
}
for (byte[] line : partition) {
    if (!quickCheck(line)) throw DIRTY;   // 调用方 catch 类型后走拒绝分支
}
// 压测量级: 抓栈版 ~1.2μs/次 × 100万/s ≈ 独占 1 个核; 无栈版 ~15ns, 百万次仅 15ms
// 注意: 哨兵不带现场 — 脏行号在调用方自己记录, 别指望异常告诉你

改造后该阶段 CPU 占比从 38% 降到 1% 以内, 与返回布尔值方案几乎打平。

场景 4 · CompletableFuture 的异常传播: exceptionally / handle

异步链里任何一步抛异常都会短路后续 thenApply, 但不接住就静默蒸发:

CompletableFuture<Price> f = fetchAsync(skuId)
    .thenApply(this::normalize)
    .exceptionally(e -> {                          // 只管异常分支, e 是 CompletionException 包装
        if (e.getCause() instanceof TimeoutException) return Price.FALLBACK;
        throw new CompletionException(e);           // 不认识的继续外抛, 别在这吞
    });
// 正常/异常都要处理用 handle((v, e) -> ...); exceptionally 只是它的单臂版
// 多个 future 汇聚: anyOf/allOf 抛出的是首个失败, 同样要 getCause 拆包

场景 5 · 线程池任务异常默认被吞: 三种收尸方式

execute 提交的任务抛了异常, 控制台一闪而过(甚至什么都不留), 告警全无 — 这是定时任务"静默停摆"的头号原因:

pool.execute(() -> { throw new IllegalStateException("boom"); }); // 无声消失!

// 方式1: submit + get — 异常在 get() 处重新抛出
pool.submit(task).get(5, TimeUnit.SECONDS);

// 方式2: 装饰 Runnable, 把 execute 路径的异常送进处理器
static Runnable wrap(Runnable r) {
    return () -> { try { r.run(); }
             catch (Throwable t) { log.error("task failed", t); metrics.tick(t); } };
}
// 方式3: 覆写 afterExecute 收集 Throwable 参数 (定时任务强依赖这层兜底)

补充: scheduleAtFixedRate 的任务抛出未捕获异常后整条调度静默死亡 — 任务体内必须自 catch。

场景 6 · 分布式锁做成 AutoCloseable, 跟 try-with-resources 走

手写 lock/unlock 配 finally, 早 return/异常路径漏 unlock 的 review 根本盯不住; 把锁变成资源:

class RedisLock implements AutoCloseable {
    private final String key, token;
    RedisLock(RedisClient cli, String bizKey, Duration lease) {
        this.key = "lock:" + bizKey;
        this.token = cli.setNxPx(key, UUID.randomUUID().toString(), lease);  // 抢锁
    }
    @Override public void close() {                       // 编译器保证调用
        client.releaseIfOwner(key, token);    // Lua 比对 token 才删, 防误删别人的锁
    }
}
// 用法: pay 抛任何异常, 锁都自动释放, 不用手写 try/finally
try (RedisLock lk = new RedisLock(cli, "order:" + oid, Duration.ofSeconds(5))) {
    pay(oid);
}

场景 7 · 异常翻译层: SQLException → 语义异常

底层厂商错误码不该漏进业务代码; 在 DAO 边界统一翻译, 上层只面对语义 (Spring 的 DataAccessException 就是这套思路):

public <T> T query(String sql, RowMapper<T> mapper) {
    try { return doQuery(sql, mapper); }
    catch (SQLException e) {
        switch (e.getErrorCode()) {                  // 厂商码 → 统一语义
            case 1062: throw new DuplicateKeyException(sql, e);    // 唯一键冲突, 可转用户提示
            case 1205: throw new LockWaitTimeoutException(sql, e); // 锁超时, 可重试
            default:   throw new DataAccessResourceException(sql, e);
        }   // 注意每个翻译分支都把 e 传成 cause — 保住原始现场
    }
}

换 MySQL → PostgreSQL 只改翻译层, 业务代码零改动; 重试策略也能按语义类型声明。

场景 8 · 重试框架只重试"可重试异常"

无脑重试把 4xx 参数错误也放大三倍流量, 还把故障雪崩拉长; 分类放在异常基类上, 调用方零 if-else:

abstract class RpcException extends RuntimeException {
    abstract boolean retryable();             // 由子类声明语义
}
public <T> T retry(String op, Supplier<T> call) {
    for (int i = 0; ; i++) {
        try { return call.get(); }
        catch (RpcException e) {
            if (!e.retryable() || i == 3) throw new GiveupAfterRetry(op, i, e); // 带次数+操作名
            sleep(backoffWithJitter(i));         // 指数退避+抖动, 防同步重试风暴
        }
    }
}
// TimeoutException→retryable=true; InvalidParamException→false, 重试只会放大故障

场景 9 · 全局 UncaughtExceptionHandler 兜底 worker 线程死亡

队列消费线程被一个 NPE 干掉后, 进程还活着、健康检查还绿 — 数据却不再流动; 兜底 + 上报让"死线程"变成告警:

Thread.setDefaultUncaughtExceptionHandler((t, e) ->
    reporter.fatal("uncaught thread=" + t.getName(), e));  // 任何漏网异常都到这

Thread worker = new Thread(() -> {
    while (running) {
        try { consume(queue.take()); }
        catch (InterruptedException ie) { Thread.currentThread().interrupt(); return; }
        catch (Throwable t) { log.error("msg dropped, loop continues", t); } // 单条失败不退循环
    }
}, "order-consumer");
worker.start();   // 若不 catch: 一条毒消息就让整条消费线静默死亡

场景 10 · 断言式参数校验, 干掉五层 if 嵌套

手写 if-return-null 的卫语句金字塔, 三个月后没人敢动; 平铺断言让"入口即校验失败即抛":

public Order create(CreateOrderCmd c) {
    require(c.getUserId() != null, "userId required");   // 一行一断言, 顶部平铺
    require(c.getSkuId()   != null, "skuId required");
    require(c.getQty()     > 0,    "qty must be positive");
    return doCreate(c);       // 到这里参数一定是干净的, 主逻辑零防御分支
}
static void require(boolean ok, String msg) {
    if (!ok) throw new IllegalArgumentException(msg + " at OrderService.create"); // 标注出错位置
}
// 同族: Objects.requireNonNull / Spring Assert / Guava Preconditions — 只选一种, 全队统一

⚠️ 编码注意与常见坑 pitfalls

坑 1 · catch (Exception e) 一把抓还 return null — Exception 把 Error 也罩住: OOM/栈溢出被吞掉, 服务带着坏状态继续跑, 数据悄悄写坏. 原因: Exception 是 Throwable 的子类, catch Exception 不会拦 Error 但拦掉一切业务异常. 正解: catch 具体类型; 想在边界处连 Error 一起观察用 catch (Throwable) + 只记日志 + 快速失败, 绝不 return null。
// 错: catch (Exception e) { return null; }   故障被吞, 上游拿 null 继续算
// 对: catch 具体类型做恢复; 边界观察用 catch (Throwable) 只记日志+快速失败
坑 2 · e.printStackTrace() 当日志用 — 输出进 stdout/catalina.out, 不带级别不带 rid, 日志聚合(ELK/Loki)收不到, 线上排障时"异常消失". 正解: log.error("上下文 {}", id, e), 异常永远作为最后一个参数。
// 错: e.printStackTrace();                          // → stdout, ELK 收不到
// 对: log.error("pay failed oid={}", oid, e);  // e 最后一个参数
坑 3 · 日志只打 e.getMessage() — 栈轨迹全丢, 只剩一句 "Connection refused", 无法定位调用路径. 正解: 整个异常对象交给日志框架; 想自定义文案用 log.error("msg: {}", e.toString(), e)。
// 错: log.error(e.getMessage());              // → 只剩 "Connection refused"
// 对: log.error("msg: {}", e.toString(), e); // 栈全保留
坑 4 · finally 里 return — try 里抛的异常被直接吞掉, 返回值也被 finally 的 return 顶掉, 调用方永远看不到故障. 正解: finally 只做清理不返回; 需要"兜底值"用 catch 分支显式写。
try { return compute(); }
finally { return -1; }        // 错: compute 的异常/返回值全被顶掉
// 对: finally 只做清理; 兜底值写在 catch 分支里显式返回
坑 5 · finally 里抛新异常覆盖主异常 — close() 在 finally 里抛 IOException, 顶掉 try 里的真正首因, 现场只剩"关连接失败". 正解: try-with-resources — close 的异常自动进 suppressed, 主异常保留。
// 错: finally { conn.close(); }   close 再抛 → 顶掉 try 里的首因
try (Connection c = open()) { query(c); }
// 对: close 抛的进 suppressed, 主异常保留
坑 6 · 空 catch 块 — catch (X e) {} 把故障静默吞掉, 上游拿到默认值继续算, 三天后是一单对不上的账. 正解: 至少 log.warn + 注释为什么可以忽略; 无法给出理由就是不允许忽略。
// 错: catch (InterruptedException e) {}   // 静默吞, 留下对不上的账
// 对: catch (InterruptedException e) { Thread.currentThread().interrupt(); log.warn("interrupted", e); }
坑 7 · lambda/Stream 里抛受检异常编译不过 — Function.apply 签名不声明 checked, 强行写编译报错. 正解: 在 lambda 内 try/catch 包装成非受检(保留 cause); 或手写 @FunctionalInterface 声明 throws 的变体接口, 别用反射 SneakyThrows 藏在生产热路径。
// 错: list.forEach(f -> Files.readString(f));  → checked 未处理, 编译不过
list.forEach(f -> { try { use(Files.readString(f)); }
    catch (IOException e) { throw new UncheckedIOException(e); } }); // 对: 保 cause
坑 8 · 异常当流程控制 — 解析循环里用 throw 表示"字段缺失", 每次构造同步抓 100+ 帧栈, 热路径吞吐腰斩. 原因: fillInStackTrace 是构造器里的大头开销. 正解: 正常分支用返回值/Optional; 真要抛就无栈哨兵异常。
// 错: if (missing) throw new ParseException(...);  百万次/s → CPU 烧在抓栈
if (missing) { skipped++; continue; }    // 对: 正常分支用返回值计数
坑 9 · new RuntimeException(e.getMessage()) 丢链 — 只复制了一句文案, 原异常的栈与 cause 全丢, 日志里没有 "Caused by". 正解: new RuntimeException("做什么失败了", e), cause 一路带到底。
// 错: throw new RuntimeException(e.getMessage());  // 无 Caused by
// 对: throw new RuntimeException("下单失败", e);     // 链带到底
坑 10 · 自定义异常只有字符串 — 排障只剩一句人话, 程序无法按码分支, HTTP 层还得解析中文 message. 正解: 基类带错误码枚举 + 用户文案 + 上下文字段(订单号/skuId), 见场景 2 模板。
// 错: throw new RuntimeException("库存不足");  // 只有人话, 机器没法分支
// 对: throw new BizException(ErrorCode.STOCK_SHORT, Map.of("sku", sku), null);
坑 11 · catch (A | B e) 后给 e 赋值 — 多 catch 的参数隐式 final, e = wrap(e) 编译报错. 正解: 换新变量名抛出; 且 e 的静态类型是公共父类, 调不到子类特有方法, 需要就分开 catch。
try { ... }
catch (IOException | SQLException e) {
    e = wrap(e);                    // 错: e 隐式 final, 编译不过
    throw translate(e);            // 对: 换新变量抛
}
坑 12 · 线程池 execute 的异常静默蒸发 — pool.execute(r) 抛出的异常只走线程的 uncaught 处理(默认打印到 stderr 或无声), 任务"消失"无告警. 正解: submit+get / 装饰 Runnable / afterExecute 收集, 三选一必须有一个。
pool.execute(() -> risky());              // 错: 只进 stderr, 任务无声消失
pool.submit(task).get(5, TimeUnit.SECONDS);  // 对: get() 处重新抛出
坑 13 · 异常 message 拼敏感数据 — "login failed pwd=123456, idCard=..." 进了日志与告警群, 合规事故. 正解: message 只放业务键(脱敏后的 id); 敏感字段放带访问控制的上下文存储, 日志里用掩码。
// 错: throw new LoginFail("pwd=" + pwd + " idCard=" + id);  → 进日志/告警群
// 对: throw new LoginFail("uid=" + mask(uid));  敏感字段进受控存储+掩码
坑 14 · 手写 finally close 抛 checked 污染签名 — 方法被迫 throws IOException, 一路传染到与 IO 无关的调用方. 正解: try-with-resources 由编译器在生成代码里处理 close 异常(suppressed), 方法签名保持干净。
// 错: try {...} finally { conn.close(); }  → 签名被迫 throws IOException 传染
try (var c = open()) { query(c); }       // 对: 签名干净, close 异常进 suppressed
坑 15 · OOME 被捕获继续服务 — catch (OutOfMemoryError) 后清个缓存继续跑, 但堆已破, 后续每次分配都可能再炸, 最终半死不活. 正解: 记 fatal 日志后快速失败, 由编排层(k8s/systemd)重启进程; OOME 的正确姿势是"死得干脆"。
// 错: catch (OutOfMemoryError oom) { cache.clear(); }  堆已破, 半死不活
// 对: log.fatal("oom", oom); System.exit(70);  交给 k8s/systemd 重启
坑 16 · StackOverflowError catch 后继续算 — 栈已爆, catch 块本身也可能再溢出, 状态高度可疑, 结果不可信. 正解: 捕获仅用于记日志/兜底退出; 根因是深递归 — 改迭代或限制深度, 与锁页的 AQS 源码同款教训。
// 错: catch (StackOverflowError e) { return defaultResult; }  结果不可信
// 对: 仅记日志/兜底退出; 根因深递归 → 改迭代或限制递归深度
坑 17 · initCause 调两次 — 第二次抛 IllegalStateException: cause 已经初始化过了. 正解: cause 要么走构造器一次性传入; 要么 initCause 只在"构造器没有 cause 参数的类"上调用一次, 之前判 getCause() == null。
RuntimeException e = new RuntimeException("msg");
e.initCause(cause);   e.initCause(other);   // 错: 第二次 → IllegalStateException
// 对: new RuntimeException("msg", cause)  构造器一次传入
坑 18 · 业务异常全 extends Exception — 每个调用点被迫 throws BizException, 签名污染扩散到整个调用链, lambda 里直接没法用. 正解: 业务异常 extends RuntimeException + 错误码字段, 在架构边界(ControllerAdvice/翻译层)统一收口。
// 错: class BizException extends Exception   → 调用链全被迫 throws
// 对: class BizException extends RuntimeException  边界统一收口
坑 19 · assertThrows 断言太宽 — 断言 Exception.class, 结果子类 NPE 也"通过", 测试给了假安全感. 正解: 断言具体子类 + 校验 message/错误码; 异常消息变了就该红。
// 错: assertThrows(Exception.class, () -> svc.create(cmd));  NPE 也"通过"
// 对: assertThrows(StockShortException.class, ...) 且校验 code/message
坑 20 · 重试不分类, 4xx 也重试 — 参数错误/签名失败被重试 3 次, 放大故障流量还拉长雪崩窗口. 正解: 异常体系声明 retryable 语义(超时/连接重置=可重试, 参数/权限=不可重试), 重试器只按语义放行, 见场景 8。
// 错: catch (Exception e) { retry(); }    4xx 参数错也重试 3 次, 放大故障
// 对: catch (RpcException e) { if (e.retryable()) backoff(); else throw e; }