"异常很贵,主要贵在 fillInStackTrace,关掉栈追踪能快一个数量级"是社区里流传很广的说法。真实计时对比两种异常,把这个数量级到底是多少变成一个具体数字。
上一篇:自动装箱在循环里到底多分配了多少内存:真实测出是 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 }
分别独立预热两种异常(各 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 一个都省不掉,它只省了"把每一层栈帧的类名、方法名、行号真实记录下来"这一步。
每一行都是真实运行这段代码得到的结果。