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. 总结

通过层层拆解,我们得出以下结论:

操作 相对开销 根源
正常对象创建 ✅ 极低
创建/抛出异常 ❌ 极高 构造时填充栈追踪
禁用栈追踪 ✅ 可接受 避免栈遍历
获取栈追踪 ❌❌ 灾难级 栈帧解析与对象生成

核心建议

  1. 异常仅用于异常场景:不要用异常控制正常流程(如用 catch 做空值判断)
  2. 避免在高频路径抛异常:尤其在循环、核心服务接口中
  3. ✅ **日志中慎用 printStackTrace()**:生产环境建议用日志框架,避免不必要的栈解析
  4. 考虑无栈异常(No-Stack Exception):对性能极度敏感的场景,可自定义异常并重写 fillInStackTrace() 返回 this(但要权衡调试成本)

异常的设计初衷是处理“意外”,而不是替代 if-else。理解其性能代价,才能写出既健壮又高效的代码。

完整代码示例见 GitHub 仓库


原始标题:Performance Effects of Exceptions in Java