一个"参数校验失败"的接口在压测里 CPU 打满,而业务逻辑很简单。查下来是校验失败时抛了异常——每次构造异常都要 fillInStackTrace,而它要遍历整个调用栈并逐帧记录。QPS 5000、每请求失败一次,就是每秒 5000 次栈遍历。
异常在 Java 里不是"轻量的控制流",而是"带栈快照的对象"。这个认知决定了三件事:什么时候不该用异常、什么时候该关掉栈、以及为什么"异常当控制流"会拖垮系统。
1. 一次 new Exception 做了什么
| 步骤 | 成本 |
|---|---|
| 分配对象 | 小 |
fillInStackTrace |
大(遍历栈帧) |
| 记录 message/detail | 小 |
| 构造 cause 链 | 小 |
第二项是主成本:它要遍历当前线程的整个调用栈(可能几十层)并记录每帧的类名、方法、行号第二项的"几十层"在框架应用里很普遍:Spring 的代理链、过滤链、AOP 都会加深栈,于是"同一个异常"在新版框架里可能更贵——而这类变化没人会归因到"框架"。
。在深调用栈 + 高频抛出的场景里,它就是主要开销。
而这个成本只发生在"构造异常"时,与"打印/记录"无关——这个错位的机制值得记住:成本在"产生"处,不在"消费"处。于是"我没打日志啊"不能作为"没开销"的理由,而它是最常见的辩解。
所以"抛了但不打日志"仍然很贵。
2. 三种"关掉栈"的方式
| 方式 | 效果 |
|---|---|
super(message, cause, false, false) |
不填栈、不抑制 |
覆写 fillInStackTrace() 返回 this |
不填栈 |
| 用单例异常(预构造) | 零构造成本 |
第一项的"陷阱"是参数顺序:第三个是 enableSuppression,第四个才是 writableStackTrace,写反了会"以为关了栈,实际没关",而它不报错。
第一项是 JDK 7+ 的标准做法:Exception(String, Throwable, boolean enableSuppression, boolean writableStackTrace),最后一项传 false 即"不记录栈"。
第三项在"极高频、无需区分实例"的场景里最快,但它失去了栈信息,所以只适合"用类型就能判断"的错误。第三项的另一个代价是"无法携带实例数据":单例异常的 message 是固定的,于是"哪个字段错了"只能靠"多个单例"分别表达——而它会膨胀成几十个常量。
// 高频校验异常:关栈(保留 message 与 cause)
public class ValidationException extends RuntimeException {
public ValidationException(String msg) {
super(msg, null, false, false); // 不填栈、不做抑制
}
}
// 更省:预构造单例(无实例开销)
public static final ValidationException EMPTY_QTY = new ValidationException("qty must be > 0");
// 代价:栈信息丢失,只能靠"类型 + message"定位;适合"错误种类有限"的场景
3. 什么时候"异常当控制流"更贵
| 场景 | 相对成本 |
|---|---|
| 每请求抛 0.001 次 | 可忽略 |
| 每请求抛 1 次 | 显著(可达总 CPU 的 10%+) |
| 循环里抛出 | 极大(O(n) 次栈遍历) |
| 深栈(> 50 层) | 更大(遍历更长) |
第三项还有个"隐性"版本:在 Optional.orElseThrow 里用异常做"缺失"——看起来现代,成本与 try/catch 一样,只是写在链式调用里。
第三项是"最坏形态":在循环里用异常做"找不到就抛",于是"1 万个元素不匹配"就是 1 万次栈遍历。
第四项容易被忽略:框架越复杂,栈越深,于是"同一个异常"在 Spring 应用里比在裸 Java 里更贵。
4. 该用返回值的三个信号
| 信号 | 说明 |
|---|---|
| 失败是"常见结果" | 如"没找到" |
| 需要分支处理 | 调用方要区分几种失败 |
| 在循环里 | 批量处理逐项失败 |
第一项是判据的核心:"没找到"不是异常,是正常结果。用异常表达它,等于"把常见路径做成昂贵路径"。
第二项要求"可枚举的失败":用 enum/结果对象能表达"哪种失败",而异常只能靠类型层级第二项在"对外 API"上更重要:"参数非法"与"资源不足"要返回不同的错误码,而异常类型与错误码的映射会在框架层做一次转换——多一层就多一次漏掉的机会。
。
// 反例:用异常表达"常见结果"
try { return repo.find(id); }
catch (NotFound e) { return null; } // 每次未命中都付栈成本
// 正例:Optional 或结果对象
Optional<Item> find(String id); // 未命中是常态,不抛
Result<Item> findStrict(String id); // 需要区分失败原因时
"异常当控制流"最坏的一种写法是"用异常做递归出口":递归到叶子就抛一个 sentinel 异常,于是"每个叶子一次栈遍历"——而栈在递归里最深,所以每一次都最贵。
// 反例:用异常做递归出口(每叶子一次 fillInStackTrace)
int depth(Node n) {
try { return 1 + depth(n.child); }
catch (LeafReached e) { return 0; } // 每个叶子抛一次
}
// 正解:显式判空返回
int depth(Node n) { return n == null ? 0 : 1 + depth(n.child); }
5. 异常的"另一半"成本:日志与栈折叠
| 环节 | 成本 |
|---|---|
| 构造(填栈) | 高 |
| 打印(格式化栈) | 中 |
| 日志采集(传输/索引) | 中高 |
| 栈折叠(同源合并) | 低但必要 |
第四项的判据是"同一栈指纹出现频率":如果一条错误每分钟出现上千次,它已经不是"错误",而是"常态"——此时该修的是触发条件,而不是日志格式。
第四项在"同一异常高频出现"时必备:把"同一位置的同一异常"合并成一条带计数的日志,否则日志系统会被同一份栈淹没。
"异常开销"要用分配剖析而不是 CPU 剖析来定位——因为它表现为"分配 + 栈遍历",而不是"某个方法很久"。
// 看异常分配热点(JFR)
jcmd <pid> JFR.start duration=60s filename=exc.jfr settings=profile
// 或 async-profiler 的 alloc 模式
./profiler.sh -e alloc -d 60 -f alloc.html <pid>
// 关注:java.lang.Exception 子类的分配栈 → 直接指向"谁在抛"
6. 排错:五种"异常拖慢系统"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| CPU 高但业务简单 | 高频抛异常 | 改返回值 |
| GC 频繁 | 异常对象大量分配 | 同上 + 关栈 |
| 日志量暴涨 | 每次异常都记完整栈 | 栈折叠 |
| 深调用栈时更慢 | 栈遍历长度 | 关栈或减层 |
| 压测比生产慢 | 压测触发更多校验失败 | 区分"错误路径"压测 |
第五项很实际:压测数据往往"脏",于是失败率远高于生产第五项还有一个"反向"盲区:生产上"偶发的异常风暴"(如下游抖动导致 10% 失败)会瞬间放大异常开销,而它在"平时监控"里完全看不到。
,而它暴露的"异常开销"在生产里看不到——但也不能因此忽略(因为真实故障时失败率会上升)。
最后一个"度量"建议:统计"每秒异常构造次数"。这个数比"异常率"更直接——因为"1% 失败率 × 10 万 QPS"与"10% 失败率 × 1 千 QPS"对系统的压力完全不同,而前者更容易被"1%"这个小数麻痹。
// 两个数一起看
exception_created_per_sec ← 直接对应开销
exception_ratio ← 对应"看起来多不多"
// 判据:> 1000/s 就该考虑"关栈或改返回值",无论比率多低
7. 边界:什么情况不必优化异常
| 场景 | 为什么 | 做法 |
|---|---|---|
| 异常率极低(< 0.1%) | 成本可忽略 | 不优化 |
| 启动/配置阶段 | 一次性 | 不优化 |
| 需要完整栈排查 | 栈是核心信息 | 保留栈 |
| 已用框架统一处理 | 平台已优化 | 跟随 |
最后一条与"可读性"有关的边界:关栈会让排查变难,所以"关栈"只应对"用类型就能定位"的异常。对"意料之外的失败",栈是唯一线索——省这点 CPU 不值得。
判断口径一句话:这个异常在一秒内会被构造多少次。几千次以上,就该考虑"返回值/关栈";几十次,栈信息的价值高于成本。
8. 自测
new Exception的四个步骤里,哪一步是主成本?为什么"抛了但不打日志"仍然很贵?- 三种关栈方式分别怎么做?预构造单例的代价是什么?
- 为什么"循环里抛异常"是最坏形态?"框架越复杂越贵"的原因是什么?
- 哪三个信号说明"该用返回值"?为什么"没找到"不是异常?
- 为什么"压测比生产慢"可能是异常开销?它揭示了什么盲区?
- 判断"是否需要优化异常"的那个问题是什么?