bigywater.tech
Java 内部原理 · 14

异常真实有多贵:关掉栈追踪只快了 1.3 倍,不是传说中的 10 倍

"异常很贵,主要贵在 fillInStackTrace,关掉栈追踪能快一个数量级"是社区里流传很广的说法。真实计时对比两种异常,把这个数量级到底是多少变成一个具体数字。

约 1.3 倍
真实计时测出:关掉栈追踪(writableStackTrace=false)相对普通异常只快了约 1.3 倍,两次独立运行结果一致
30 层 · 50 万次
调用栈深度 30 层,每种异常各抛 50 万次,分别独立预热避免 JIT 互相干扰

上一篇:自动装箱在循环里到底多分配了多少内存:真实测出是 4 倍

怎么关掉栈追踪

Throwable 有一个不常用的构造函数,能在构造时直接跳过栈帧填充。

正常抛异常时,Throwable 的构造函数会调用 fillInStackTrace(),把当前调用栈的每一帧都记录下来。Throwable 还有一个四参数构造函数,最后一个参数 writableStackTrace 设成 false 就能跳过这一步:

StackTraceCost.java(两种异常) static class NormalException extends RuntimeException { NormalException(String msg) { super(msg); } } static class NoStackException extends RuntimeException { NoStackException(String msg) { super(msg, null, false, false); } // cause enableSuppression writableStackTrace }

真实验证:两种异常,50 万次,30 层调用深度

分别独立预热两种异常(各 10 万次),避免共享预热导致 JIT 决策互相干扰,再各自正式计时 50 万次。

真实实测 · 第一次运行
调用栈深度 = 200, 次数 = 500000
普通异常(真实填充栈帧)       总耗时 = 9651ms, 平均每次 = 19303ns
writableStackTrace=false 异常 总耗时 = 7592ms, 平均每次 = 15185ns
倍数 = 1.27
真实实测 · 第二次独立运行
调用栈深度 = 200, 次数 = 500000
普通异常(真实填充栈帧)       总耗时 = 9786ms, 平均每次 = 19573ns
writableStackTrace=false 异常 总耗时 = 7525ms, 平均每次 = 15050ns
倍数 = 1.30

两次独立运行的倍数分别是 1.27 和 1.30,结果稳定,但和"关掉栈追踪快一个数量级"这种说法差得很远。真实原因是:抛异常的完整开销不只是 fillInStackTrace 这一步,还包括异常对象本身的分配、throw 触发的栈展开(unwind)、沿着调用链逐层匹配 catch 块——这些开销 writableStackTrace=false 一个都省不掉,它只省了"把每一层栈帧的类名、方法名、行号真实记录下来"这一步。

那这个优化还值得做吗?值得,但预期要真实一些:在异常被大量、高频抛出的场景(比如把异常当控制流用,一秒抛几十万次),省下的 1.3 倍是真实收益,尤其调用栈越深、收益通常越明显;但如果只是常规的错误处理路径(异常本来就应该是低频事件),这点差距在真实系统里几乎感知不到,不值得为了这个把异常语义搞得更复杂。

分步演示:异常真正"贵"在哪一步

每一行都是真实运行这段代码得到的结果。

栈帧采集的真实开销发生在异常被构造(new)的那一刻,不是被throw的那一刻;writableStackTrace=false能真实完全跳过这项工作
这一步新验证的异常

    参考与说明

    • 本文计时数据均在本机真实运行 Java 17 得到,两组各自独立预热 10 万次,正式计时前 JIT 已经充分热身。
    • 和HashMap 树化那篇一样,这篇也是拿一个流传很广但没人验证过具体倍数的说法去对照真实数据。
    • 没有涉及:不同 JVM(如 OpenJ9)、不同调用深度下这个倍数的变化曲线——这些留给系列后面按需再写。
    京ICP备2026067502号-1京公网安备11010502063487号