2387 字
约 7 分钟
14
S04-A19 · 异常的代价:栈追踪是怎么被生成的

一个"参数校验失败"的接口在压测里 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. 自测

  1. new Exception 的四个步骤里,哪一步是主成本?为什么"抛了但不打日志"仍然很贵?
  2. 三种关栈方式分别怎么做?预构造单例的代价是什么?
  3. 为什么"循环里抛异常"是最坏形态?"框架越复杂越贵"的原因是什么?
  4. 哪三个信号说明"该用返回值"?为什么"没找到"不是异常?
  5. 为什么"压测比生产慢"可能是异常开销?它揭示了什么盲区?
  6. 判断"是否需要优化异常"的那个问题是什么?
S04-A19 · 异常的代价:栈追踪是怎么被生成的
https://www.clxhxhhr.top/posts/6556/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。