Throwable 家族树与受检边界 — try-with-resources 的 suppressed 不丢账, fillInStackTrace 是异常真正的性能账单
Java 异常的本质是"可抛出的控制流信号": Throwable 家族分成 Error(JVM 的白旗, 别碰)、受检异常(编译器强迫你处理的外部故障)、非受检异常(你自己的 bug) — catch 的边界就是"我能恢复的边界"。而 try-with-resources 是编译器替你写的一段完美 finally: 反向关闭资源, 主异常优先, close 抛的异常 addSuppressed 进旁挂数组, 一个都不丢。最后记住性能账单: 异常贵在构造时的 fillInStackTrace(), 热路径上把异常当流程控制, 每一次 throw 都在同步抓整条调用栈。
new OutOfMemoryError("x") instanceof Exception; // → false: Error 不归 Exception 管 new NullPointerException() instanceof Exception; // → true: 非受检也是 Exception // 受检 IOException: 不 catch 也不 throws → 编译不过
void api() throws IOException { read(); } // 受检: 调用方被迫处理/上抛 list.forEach(x -> read(x)); // ✗ lambda 里抛 checked → 编译不过 // 新代码惯例: 业务异常 extends RuntimeException, 边界统一收口
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 ... 原始现场全保留
addSuppressed 到主异常, 手写等价 finally 需要两层嵌套 try — 没人该手写。
try (A a = openA(); B b = openB()) { // 关键: 逆序 close, 先 b 后 a work(); } // close 抛的异常 addSuppressed 到主异常, 一个不丢
getSuppressed() 取数组。典型来源是 close() 抛的异常; finally 里手动 throw 会直接顶掉主异常(无 suppressed), 这正是两者行为的分水岭。
try { ... } catch (Exception e) { for (Throwable s : e.getSuppressed()) // → close() 抛的旁账 log.warn("suppressed: {}", s); }
RuntimeException e = new RuntimeException(); // 构造即同步抓整条栈 e.getStackTrace().length; // → 当前栈深, 开销 ∝ 它 // 关键: 账单在构造不在 throw; 深栈 ~μs 级, JIT 消不掉
fillInStackTrace() { return this; }, 构造时不再抓栈, 成本降到 ~ns 级。适合"只看类型不看现场"的高频控制信号, 不适合需要排障的路径。
class FastSignal extends RuntimeException { @Override public synchronized Throwable fillInStackTrace() { return this; } } new FastSignal(); // → ~ns 级, 不抓栈; 只当类型信号用
static final DirtyError DIRTY = new DirtyError(); // 预分配单例 if (!check(line)) throw DIRTY; // 零构造开销, 按类型分流 // 约束: 全进程共享 — 不能塞 message/现场
throw new NotFound("order " + oid + " not found, userId=" + uid); // → "order 10086 not found, userId=42" 这才是排障入口 // message 只读; 结构化上下文放异常的字段, 别解析字符串
@ControllerAdvice + @ExceptionHandler 统一转 HTTP 响应; 线程层 Thread.setDefaultUncaughtExceptionHandler 兜住漏网; 池化任务用 submit+get 或装饰 Runnable, 否则异常静默蒸发。
Thread.setDefaultUncaughtExceptionHandler((t, e) ->
log.error("uncaught in {}", t.getName(), e)); // 兜住漏网
// Web 层: @RestControllerAdvice + @ExceptionHandler 统一转 HTTPlog.error("下单失败 oid={}", oid, e) — 异常作为最后一个参数, 框架才打印整条栈; e.printStackTrace() 进 stdout 不进日志聚合, 等于没打。
log.error("下单失败 oid={}", oid, e); // 对: e 是最后一个参数 → 打全栈 // e.printStackTrace(); 错: 进 stdout, ELK 收不到
public class BizException extends RuntimeException { private final ErrorCode code; // 稳定码, 机器判分支 private final Map<String, Object> ctx; // 排障上下文: skuId/want }
catch (IOException | SQLException e) 合并捕获, 隐式 final 不能给 e 重新赋值; 类型是两者的最近公共父类, e 只能调父类方法。
try { ... } catch (IOException | SQLException e) { // 合并捕获, e 隐式 final log.warn("io/sql fail", e); // e 只能调公共父类方法 }
几十个 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 一键捞出完整调用链。
裸 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);
风控前置的行级校验, 脏数据占比 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% 以内, 与返回布尔值方案几乎打平。
异步链里任何一步抛异常都会短路后续 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 拆包
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。
手写 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); }
底层厂商错误码不该漏进业务代码; 在 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 只改翻译层, 业务代码零改动; 重试策略也能按语义类型声明。
无脑重试把 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, 重试只会放大故障
队列消费线程被一个 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: 一条毒消息就让整条消费线静默死亡
手写 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 — 只选一种, 全队统一
Exception 是 Throwable 的子类, catch Exception 不会拦 Error 但拦掉一切业务异常. 正解: catch 具体类型; 想在边界处连 Error 一起观察用 catch (Throwable) + 只记日志 + 快速失败, 绝不 return null。
// 错: catch (Exception e) { return null; } 故障被吞, 上游拿 null 继续算 // 对: catch 具体类型做恢复; 边界观察用 catch (Throwable) 只记日志+快速失败
log.error("上下文 {}", id, e), 异常永远作为最后一个参数。
// 错: e.printStackTrace(); // → stdout, ELK 收不到 // 对: log.error("pay failed oid={}", oid, e); // e 最后一个参数
log.error("msg: {}", e.toString(), e)。
// 错: log.error(e.getMessage()); // → 只剩 "Connection refused" // 对: log.error("msg: {}", e.toString(), e); // 栈全保留
try { return compute(); } finally { return -1; } // 错: compute 的异常/返回值全被顶掉 // 对: finally 只做清理; 兜底值写在 catch 分支里显式返回
suppressed, 主异常保留。
// 错: finally { conn.close(); } close 再抛 → 顶掉 try 里的首因 try (Connection c = open()) { query(c); } // 对: close 抛的进 suppressed, 主异常保留
catch (X e) {} 把故障静默吞掉, 上游拿到默认值继续算, 三天后是一单对不上的账. 正解: 至少 log.warn + 注释为什么可以忽略; 无法给出理由就是不允许忽略。
// 错: catch (InterruptedException e) {} // 静默吞, 留下对不上的账 // 对: catch (InterruptedException e) { Thread.currentThread().interrupt(); log.warn("interrupted", e); }
@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
fillInStackTrace 是构造器里的大头开销. 正解: 正常分支用返回值/Optional; 真要抛就无栈哨兵异常。
// 错: if (missing) throw new ParseException(...); 百万次/s → CPU 烧在抓栈 if (missing) { skipped++; continue; } // 对: 正常分支用返回值计数
new RuntimeException("做什么失败了", e), cause 一路带到底。
// 错: throw new RuntimeException(e.getMessage()); // 无 Caused by // 对: throw new RuntimeException("下单失败", e); // 链带到底
// 错: throw new RuntimeException("库存不足"); // 只有人话, 机器没法分支 // 对: throw new BizException(ErrorCode.STOCK_SHORT, Map.of("sku", sku), null);
e = wrap(e) 编译报错. 正解: 换新变量名抛出; 且 e 的静态类型是公共父类, 调不到子类特有方法, 需要就分开 catch。
try { ... } catch (IOException | SQLException e) { e = wrap(e); // 错: e 隐式 final, 编译不过 throw translate(e); // 对: 换新变量抛 }
pool.execute(() -> risky()); // 错: 只进 stderr, 任务无声消失 pool.submit(task).get(5, TimeUnit.SECONDS); // 对: get() 处重新抛出
// 错: throw new LoginFail("pwd=" + pwd + " idCard=" + id); → 进日志/告警群 // 对: throw new LoginFail("uid=" + mask(uid)); 敏感字段进受控存储+掩码
throws IOException, 一路传染到与 IO 无关的调用方. 正解: try-with-resources 由编译器在生成代码里处理 close 异常(suppressed), 方法签名保持干净。
// 错: try {...} finally { conn.close(); } → 签名被迫 throws IOException 传染 try (var c = open()) { query(c); } // 对: 签名干净, close 异常进 suppressed
// 错: catch (OutOfMemoryError oom) { cache.clear(); } 堆已破, 半死不活 // 对: log.fatal("oom", oom); System.exit(70); 交给 k8s/systemd 重启
// 错: catch (StackOverflowError e) { return defaultResult; } 结果不可信 // 对: 仅记日志/兜底退出; 根因深递归 → 改迭代或限制递归深度
getCause() == null。
RuntimeException e = new RuntimeException("msg"); e.initCause(cause); e.initCause(other); // 错: 第二次 → IllegalStateException // 对: new RuntimeException("msg", cause) 构造器一次传入
// 错: class BizException extends Exception → 调用链全被迫 throws // 对: class BizException extends RuntimeException 边界统一收口
// 错: assertThrows(Exception.class, () -> svc.create(cmd)); NPE 也"通过" // 对: assertThrows(StockShortException.class, ...) 且校验 code/message
// 错: catch (Exception e) { retry(); } 4xx 参数错也重试 3 次, 放大故障 // 对: catch (RpcException e) { if (e.retryable()) backoff(); else throw e; }