1. 概述
在 Java 中,异常通常被认为是“昂贵”的操作,不应该用于流程控制。本文将通过实证方式验证这一观点的正确性,并深入剖析性能开销的根源。
我们不会停留在“异常慢”这种模糊认知上,而是通过 JMH 精确测量,一步步定位到真正拖慢程序的关键环节。对于有经验的开发者来说,理解底层机制比记住结论更重要。
2. 环境准备
要准确评估异常的性能开销,必须在受控环境下进行基准测试。直接用 for
循环计时的方式并不可靠,因为 JIT 编译器的优化可能会让结果失真——看似很快,但生产环境未必如此。
2.1. 使用 JMH(Java Microbenchmark Harness)
我们选择 JMH 作为基准测试工具。它能有效规避 JVM 预热不足、死代码消除等问题,提供更接近真实场景的性能数据。
✅ 推荐:对性能敏感的项目,基准测试必须用 JMH,别再手写
System.currentTimeMillis()
了。
2.2. 引入 JMH 依赖
在 pom.xml
中添加以下依赖:
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.37</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.37</version>
</dependency>
建议始终使用 Maven Central 上的最新稳定版本。
2.3. 创建基准测试类
@Fork(1)
@Warmup(iterations = 2)
@Measurement(iterations = 10)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class ExceptionBenchmark {
private static final int LIMIT = 10_000;
// 各种 benchmark 方法将放在这里
}
关键注解说明:
@Fork(1)
:启动 1 个独立 JVM 进程运行测试,避免多进程干扰@Warmup(iterations = 2)
:预热 2 轮,不计入最终结果@Measurement(iterations = 10)
:正式测量执行 10 次@BenchmarkMode(AverageTime)
:以平均执行时间作为指标@OutputTimeUnit
:输出单位为毫秒LIMIT
:循环次数,用于放大差异
2.4. 执行测试
添加主类以运行测试:
public class ExceptionBenchmarkRunner {
public static void main(String[] args) throws Exception {
org.openjdk.jmh.Main.main(args);
}
}
为方便在 IDE 中直接运行,可在 pom.xml
中配置 maven-jar-plugin
:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.2.0</version>
<configuration>
<archive>
<manifest>
<mainClass>com.example.ExceptionBenchmarkRunner</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
打包后通过 java -jar
运行即可。
3. 性能实测
接下来通过多个对比实验,逐步拆解异常的性能成本。
3.1. 正常返回(基准线)
首先建立性能基线,一个不抛异常的普通方法:
@Benchmark
public void doNotThrowException(Blackhole blackhole) {
for (int i = 0; i < LIMIT; i++) {
blackhole.consume(new Object());
}
}
Blackhole
是 JMH 提供的防优化工具,防止 JIT 将无用对象创建优化掉- 该方法仅用于对比,代表“无异常”场景的开销
测试结果:
Benchmark Mode Cnt Score Error Units
ExceptionBenchmark.doNotThrowException avgt 10 0.049 ± 0.006 ms/op
这是我们的性能基准,约 0.05ms。后续所有测试都以此为参照。
3.2. 抛出并捕获异常
模拟典型的异常使用场景:
@Benchmark
public void throwAndCatchException(Blackhole blackhole) {
for (int i = 0; i < LIMIT; i++) {
try {
throw new Exception();
} catch (Exception e) {
blackhole.consume(e);
}
}
}
测试结果:
Benchmark Mode Cnt Score Error Units
ExceptionBenchmark.doNotThrowException avgt 10 0.048 ± 0.003 ms/op
ExceptionBenchmark.throwAndCatchException avgt 10 17.942 ± 0.846 ms/op
⚠️ 关键发现:仅仅是抛出并捕获异常,性能就下降了 350 倍以上!这说明异常的开销远超普通方法调用。
3.3. 仅创建异常(不抛出)
问题来了:是“抛出”动作慢,还是“创建”异常对象慢?我们测试仅创建异常的情况:
@Benchmark
public void createExceptionWithoutThrowingIt(Blackhole blackhole) {
for (int i = 0; i < LIMIT; i++) {
blackhole.consume(new Exception());
}
}
测试结果:
Benchmark Mode Cnt Score Error Units
ExceptionBenchmark.createExceptionWithoutThrowingIt avgt 10 17.601 ± 3.152 ms/op
ExceptionBenchmark.doNotThrowException avgt 10 0.054 ± 0.014 ms/op
ExceptionBenchmark.throwAndCatchException avgt 10 17.174 ± 0.474 ms/op
✅ 结论:创建异常对象的开销 ≈ 抛出异常的开销。说明 异常对象的构造过程本身就很重,而 throw
/catch
语法本身的开销可以忽略。
3.4. 禁用栈追踪(JVM 参数优化)
为什么创建异常这么慢?因为 Exception
构造时会自动填充当前调用栈(fillInStackTrace
)。我们通过 JVM 参数禁用它:
@Benchmark
@Fork(value = 1, jvmArgs = "-XX:-StackTraceInThrowable")
public void throwExceptionWithoutAddingStackTrace(Blackhole blackhole) {
for (int i = 0; i < LIMIT; i++) {
try {
throw new Exception();
} catch (Exception e) {
blackhole.consume(e);
}
}
}
-XX:-StackTraceInThrowable
表示创建异常时不收集栈信息。
测试结果:
Benchmark Mode Cnt Score Error Units
ExceptionBenchmark.throwExceptionWithoutAddingStackTrace avgt 10 1.174 ± 0.014 ms/op
✅ 惊人发现:禁用栈追踪后,性能提升了 15 倍以上,从 17ms 降到 1.17ms。说明 栈追踪的收集是性能瓶颈的根源。
3.5. 主动获取栈追踪(最差情况)
如果不仅抛出异常,还主动调用 getStackTrace()
,会发生什么?
@Benchmark
public void throwExceptionAndUnwindStackTrace(Blackhole blackhole) {
for (int i = 0; i < LIMIT; i++) {
try {
throw new Exception();
} catch (Exception e) {
blackhole.consume(e.getStackTrace());
}
}
}
测试结果:
Benchmark Mode Cnt Score Error Units
ExceptionBenchmark.throwExceptionAndUnwindStackTrace avgt 10 326.560 ± 4.991 ms/op
⚠️ 踩坑警告:主动获取栈追踪会让性能再下降 20 倍以上!总耗时达到 326ms,是正常情况的 6500 倍。
原因:getStackTrace()
会重新解析和格式化栈帧,产生大量临时对象和 CPU 开销。
4. 总结
通过层层拆解,我们得出以下结论:
操作 | 相对开销 | 根源 |
---|---|---|
正常对象创建 | ✅ 极低 | 无 |
创建/抛出异常 | ❌ 极高 | 构造时填充栈追踪 |
禁用栈追踪 | ✅ 可接受 | 避免栈遍历 |
获取栈追踪 | ❌❌ 灾难级 | 栈帧解析与对象生成 |
核心建议
- ✅ 异常仅用于异常场景:不要用异常控制正常流程(如用
catch
做空值判断) - ✅ 避免在高频路径抛异常:尤其在循环、核心服务接口中
- ✅ **日志中慎用
printStackTrace()
**:生产环境建议用日志框架,避免不必要的栈解析 - ✅ 考虑无栈异常(No-Stack Exception):对性能极度敏感的场景,可自定义异常并重写
fillInStackTrace()
返回this
(但要权衡调试成本)
异常的设计初衷是处理“意外”,而不是替代
if-else
。理解其性能代价,才能写出既健壮又高效的代码。
完整代码示例见 GitHub 仓库。