抛出问题

Joshua Bloch 在《Effective Java》的 Item 57 里明确地提到过,不要试图用 Exception 的跳转来代替正常的程序控制流。他列举了很多原因,但特别提到了抛出异常会使得整个程序运行变慢。抛出异常远比普通的 returnbreak 等操作对控制流、数据流的性能影响要大,它就只适合拿来作异常分支的控制语句,而不能拿来编写正常的逻辑。

Throwing exception is expensive.

这句话在 Java 的程序员世界里面已经成为老生常谈,却很少有人谈及到底抛出异常比正常的程序跳转返回慢在哪里,有多慢。"不要滥用异常"好像一个猴子定律,人们知道不能这么做,却不明白为什么不能这么做。

此前读了一位同事写的好文《Java虚拟机是如何处理异常的》,深入地分析了 JVM 对异常跳转的处理过程:JVM 会通过异常表的机制,优化异常抛出和正常返回之间的性能差异。仅从程序计数器的移动上来讲,抛出一个异常对栈帧的弹栈并不比直接返回更昂贵。写在前头的结论是:"try-catch 语句块几乎不会影响程序运行性能!在开启 JIT 的情况下,throw 也不会增加多少系统开销。"实际上这篇文章也做了一些对比,在不同的场景下,try-catch 会不会让系统变慢。

文中还提到一个有趣的实验:


代码 A

1
2
3
4
5
6
for (int i = 0; i < 1000000; i++) {
try {
// throw exception;
} catch (Exception e) {
}
}

代码 B

1
2
3
4
5
try {
for (int i = 0; i < 1000000; i++) {
}
} catch (Exception e) {
}

实验结果是:

异常抛出 关闭JIT 开启JIT(默认开启)
无异常抛出 两者耗时几乎相同 两者耗时几乎相同
A每次都抛异常 A耗时约是B的30倍 两者耗时几乎相同

  这几乎推翻了既有的刻板印象,从此抛出异常不再是一个需要考虑性能的设计决定了。在仔细研究了这个问题以后,却有了一个不同的结论:try-catch 语句在 JIT 的帮助下,也许可以达到和正常 return 一样的性能,然而 throw 却会产生远比文中描述的更严重的性能影响,因为 throw 不是孤立的语句,它必须伴随着异常对象的创建,而异常对象的创建的昂贵代价,是不可能被 JIT 优化掉的。也就是说,《Java虚拟机是如何处理异常的》中结论的前半部分是正确的,后半部分是不准确的。

异常的机制

JVM 的异常处理机制,大致可以分为三个部分 :

  1. new Exception
  2. throw Exception
  3. catch and deal with Exception

通过《Java虚拟机是如何处理异常的》已经可以明确理解,JVM 对于 try-throw-catch 的程序控制流处理,与普通的 return 如出一辙,都是基于程序计数器的改变,直接使得控制流发生跳转,并无特别之处。而 catch 异常如果为空(即生吞异常),则开销上看起来和平凡 return 一样。然而,new Exception 实际上是一个非常昂贵的操作。因为异常对象在生成的时候,其父类构造函数 Throwable 中的一部分会调用 fillInStackTrace() 操作。这个 fillInStackTrace() 函数,会试图把当前抛出异常的栈帧全都囊括在内,在实际的运行之中,有可能导致复杂的 CPU 寄存器读写操作。这种读写操作的复杂度与是否使用 JIT 无关,也就不可能被 JIT 所优化,是一种很昂贵的固定成本。

fillInStackTrace() 到底做了什么

要理解异常为什么昂贵,必须深入 fillInStackTrace() 的实现细节。这个方法是一个 native 方法,它的底层实现(在 HotSpot JVM 中)大致执行以下操作:

  1. 栈帧遍历(Stack Walking):从当前栈帧开始,沿着调用链逐帧向上遍历。对于每一帧,需要读取帧指针(frame pointer)或通过栈展开表(unwind table)来定位上一帧。在没有帧指针的优化代码中(JIT 编译后的代码通常会省略帧指针以节省寄存器),这个过程需要查阅 JIT 编译器生成的元数据来确定每一帧的大小和布局。

  2. 方法元数据解析:对于每一帧,需要将程序计数器(PC)映射回对应的 Java 方法名、类名和行号。这需要查阅方法的调试信息表(LineNumberTable)和常量池。对于 JIT 编译后的代码,还需要通过 PC 映射表(PcDesc)将本地代码地址映射回字节码偏移量。

  3. 内存分配:遍历完成后,需要分配一个 StackTraceElement[] 数组来存储所有栈帧信息。每个 StackTraceElement 对象包含类名、方法名、文件名和行号四个字符串字段,这意味着还需要分配大量的 String 对象。

在一个典型的 Spring Boot 应用中,一次 HTTP 请求的调用栈深度可能达到 50-100 层(Servlet 容器 + Spring MVC + AOP 代理 + 业务代码 + 数据库驱动),这意味着一次 fillInStackTrace() 需要遍历并记录 50-100 个栈帧的完整信息。

writableStackTrace:跳过栈帧收集

值得一提的是,Java 7 开始 Throwable 的构造器提供了 writableStackTrace 参数(protected Throwable(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace)),将其设为 false 可以跳过 fillInStackTrace() 的调用,从而大幅降低异常创建的开销。

1
2
3
4
5
public class LightweightException extends RuntimeException {
public LightweightException(String message) {
super(message, null, true, false); // writableStackTrace = false
}
}

这种轻量级异常的创建开销与普通对象创建几乎相同,但代价是丢失了栈帧信息——在日志中你只能看到异常消息,无法看到异常是从哪里抛出的。这在某些场景下是可以接受的,比如用异常来表示业务校验失败(“用户名已存在”),此时栈帧信息对排查问题没有帮助。

JVM 的隐式优化:Fast Throw

HotSpot JVM 还有一个鲜为人知的优化:当同一个位置反复抛出同一类型的异常时(如 NullPointerException),JVM 会在 C2 编译器中启用 Fast Throw 优化——直接抛出一个预先创建好的、不包含栈帧信息的异常单例。这个优化可以通过 -XX:-OmitStackTraceInFastThrow 来关闭。

这个优化在生产环境中是一把双刃剑:它确实提升了性能,但当你在日志中看到一个没有栈帧的 NullPointerException 时,排查问题会变得极其困难。这也是为什么很多运维团队会建议在生产环境中关闭这个优化。

《Java虚拟机是如何处理异常的》中提到的实验并不代表 Java 在真实生产环境中的工作状况,因为现实中几乎没有栈深只为 1 的方法调用,一个框架或者容器,本身就会带来几十层的调用栈深度。

一个实验

StackOverflow 上已经有很多人做了相关的实验,以下代码用于印证上述结论:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Param({ "1", "100", "200", "1000", "2000"})
private int recursiveDepth;

private Object returnMethod(int recursiveDepth) {
if ( recursiveDepth > 0 ) {
return returnMethod(recursiveDepth - 1);
} else {
return new Object();
}
}


private Object throwMethod(int recursiveDepth) throws Exception {
if ( recursiveDepth > 0 ) {
return throwMethod(recursiveDepth - 1);
} else {
throw new RuntimeException();
}
}

// 一定要是公开方法
@Benchmark
public void benchMarkReturn() {
// 一定要明确使用返回结果,不然 JIT会优化掉很多东西。
System.out.println(returnMethod((recursiveDepth)));
}

@Benchmark
public void benchMarkThrow() {
try {
throwMethod((recursiveDepth));
} catch (Exception e) {
// 一定要明确使用返回结果,不然 JIT会优化掉很多东西。
System.out.println(e);
}
}

这是一个基于 JMH 的测试方案,先预热一万轮,再跑一万轮 benchmark 方法,使 JIT 完全发挥作用。实验环境是 CentOS 7,使用 Java 8 的 JVM,默认打开了分层编译。栈深度分别为 1、100、200、1000、2000。

实验结果如下:

测试方法 栈深度 操作平均耗时(微秒)
benchMarkReturn 1 0.002
benchMarkThrow 1 1.462
benchMarkReturn 100 0.178
benchMarkThrow 100 15.200
benchMarkReturn 200 0.369
benchMarkThrow 200 28.595
benchMarkReturn 1000 1.864
benchMarkThrow 1000 152.968
benchMarkReturn 2000 7.563
benchMarkThrow 2000 238.049

我们可以清晰地看到:

  1. 在相同的栈深度下,抛出异常的耗时是返回普通对象的 30 倍到 700 倍。

  2. 在本实验中,可能因为存在边际效应,栈深为 1 的时候反而是性能差距最大的。

这也基本符合在网上看到的其他人的测试的结论(例子1例子2)。

如果再试图在 catch 块里调用 printStackTrace(),性能差距只会更大。

数据背后的规律

从实验数据中可以提炼出一个重要的规律:异常的开销与调用栈深度近似线性相关。这完全符合 fillInStackTrace() 的实现逻辑——它需要逐帧遍历调用栈,栈越深,遍历的帧越多,开销越大。

具体来看:

  • 栈深从 1 到 100,throw 耗时从 1.462μs 增长到 15.200μs,约 10 倍增长对应 100 倍栈深增长
  • 栈深从 100 到 1000,throw 耗时从 15.200μs 增长到 152.968μs,几乎完美的 10 倍线性关系

而 return 的耗时增长则平缓得多(0.002μs → 1.864μs),这部分增长主要来自递归调用本身的栈帧分配和回收开销,与异常机制无关。

这个线性关系给出了一个实用的估算公式:在典型的 Java 应用中,每增加一层调用栈深度,异常创建大约增加 0.15μs 的开销。对于一个栈深 50 层的 Spring Boot 应用,一次异常创建大约需要 7-8μs。如果每秒处理 10000 个请求,且每个请求都抛出一个异常,那么仅异常创建就会消耗约 70-80ms 的 CPU 时间——这在高并发场景下是不可忽视的。

生产环境中的异常优化实践

理解了异常昂贵的根源之后,可以总结出几种在生产环境中优化异常性能的实践模式:

模式一:用返回值代替异常

对于可预见的业务错误(如参数校验失败、资源不存在),使用返回值而非异常来传递错误信息。这是 Go 语言的核心设计哲学,在 Java 中可以通过 OptionalEither 模式或自定义 Result 类型来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 反模式:用异常传递业务错误
public User findUser(long userId) {
User user = userDao.findById(userId);
if (user == null) {
throw new UserNotFoundException("User not found: " + userId);
}
return user;
}

// 推荐:用 Optional 表达"可能不存在"的语义
public Optional<User> findUser(long userId) {
return Optional.ofNullable(userDao.findById(userId));
}

模式二:轻量级异常

对于确实需要使用异常机制但不需要栈帧信息的场景(如业务校验),使用 writableStackTrace = false 的轻量级异常:

1
2
3
4
5
6
7
8
9
10
11
12
public class BusinessException extends RuntimeException {
private final String errorCode;

public BusinessException(String errorCode, String message) {
super(message, null, true, false); // 跳过 fillInStackTrace()
this.errorCode = errorCode;
}

public String getErrorCode() {
return errorCode;
}
}

模式三:异常缓存与复用

对于某些固定的异常(如流控异常、熔断异常),可以预先创建异常实例并复用,避免反复调用 fillInStackTrace()

1
2
3
4
5
6
7
8
public class FlowControlException extends RuntimeException {
// 预先创建的单例,栈帧信息固定为类加载时的位置
public static final FlowControlException INSTANCE = new FlowControlException("Flow control triggered");

private FlowControlException(String message) {
super(message, null, true, false);
}
}

这种模式在 Netty、Sentinel 等高性能框架中被广泛使用。例如 Netty 的 StacklessClosedChannelException 就是一个典型的轻量级异常。

模式四:避免异常驱动的控制流

某些 API 设计迫使开发者用异常来处理正常的控制流,这是应该避免的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 反模式:用异常来检测迭代结束(早期 Java 集合的做法)
try {
while (true) {
Object item = iterator.next();
process(item);
}
} catch (NoSuchElementException e) {
// 迭代结束
}

// 推荐:用 hasNext() 检查
while (iterator.hasNext()) {
process(iterator.next());
}

类似地,Integer.parseInt() 在解析失败时抛出 NumberFormatException,如果你需要频繁地尝试解析可能不是数字的字符串,应该先用正则或其他方式预检查,而不是依赖异常来判断。

结论

在开启JIT的情况下,throw也不会增加多少系统开销。

固然是实话。

然而现实之中,throw 却不能离开任何 Throwable 的子类,我们在使用异常机制的时候,必须背负上生成栈帧这样一个沉重的负担,空谈 throw 的性能优化是无意义的。所以

所以当你遇到有人说try-catch一定要少用会影响性能时,或许你就不会再去盲从这种"建议"了。

却是一种过于乐观的结论。我们当然不能无节制地使用 try-catch,因它不仅使程序变得支离破碎,而且除非不会发生异常抛出,否则 JVM 对它进行的优化,只是杯水车薪。

应当永远记住,抛出异常是昂贵的,不是因为 try-catch 是昂贵的,而是因为无论怎么使用异常,异常对象的创建(fillInStackTrace())都是昂贵的。

更精确地说,异常的代价可以分解为三个层次:

操作 相对开销 能否被 JIT 优化
try-catch 语句块(无异常抛出) 几乎为零
throw + catch(控制流跳转) 很小
new Exception(fillInStackTrace() 极大,与栈深线性相关
printStackTrace() 极大,涉及 IO

理解这张表,就理解了异常性能问题的全部。优化的方向永远是:减少 fillInStackTrace() 的调用次数和每次调用时的栈深度

附原文:

编码时我们常常被要求尽量减少try-catch语句块,理由就是就算不抛异常它们也会影响性能。然而影响究竟有多大呢?语句块应该放在循环体内部还是外部呢?下面译文将详细阐释Java虚拟机处理异常的机制。
虽然文中没有进行性能分析,但文末提供了一些基准测试的文章,先把结论写在前头:try-catch语句块几乎不会影响程序运行性能!在开启JIT的情况下,throw也不会增加多少系统开销。

异常机制

异常机制可以让你顺利的处理程序运行过程中所遇到的许多意想不到的情况。为了说明Java虚拟机处理异常的方式,我们来看一个名为NitPickyMath的类,它提供了针对整型的求模运算。和直接进行运算操作不同的是,该方法除零情况下将抛出受检查的异常(checked exceptions)。在Java虚拟机中除零时同样也会抛出ArithmeticException异常。NitPickyMath类抛出的异常定义如下:

class DivideByZeroException extends Exception {
}

NitPickyMath类的remainder方法简单地捕获并抛出了异常:

static int remainder(int dividend, int divisor)
throws DivideByZeroException {
try {
return dividend % divisor;
}
catch (ArithmeticException e) {
throw new DivideByZeroException();
}
}

remainder方法仅仅只是将两个int入参进行了求模运算(也使用了除法)。当除数为0时,求模运算将抛出ArithmeticException异常,该方法将捕获这个异常并抛出一个自定义DivideByZeroException异常。

DivideByZeroException 和ArithmeticException 的不同之处在于前者是受检查异常,而后者是非受检查异常。因此后者抛出时不需要在方法头添加throws语句。Error或RuntimeException类的所有子类都是非受检查异常(例如ArithmeticException就是RuntimeException的子类)。

使用javac对remainder方法进行编译,将得到如下字节码:

remainder方法主体的字节码序列:
0 iload_0 // 压入局部变量0 (传入的除数)
1 iload_1 // 压入局部变量0 (传入的被除数)
2 irem // 弹出除数, 弹出被除数, 压入余数
3 ireturn // 返回栈顶的int值 (余数)

catch语句的的字节码序列 (ArithmeticException):
4 pop // 弹出ArithmeticException引用(因为没被用到)
5 new #5
// 创建并压入新对象DivideByZeroException的引用

DivideByZeroException
8 dup // 复制栈顶的DivideByZeroException引用,因为它既要被初始化又要被抛出,初始化将消耗掉栈顶的一个引用
9 invokenonvirtual #9 <Method DivideByZeroException.()V>
// 调用DivideByZeroException的构造器来初始化,栈顶引用出栈
12 athrow // 弹出Throwable对象的引用并抛出异常

可以看到remainder的字节码序列主要分成了两部分,第一部分是方法正常执行的路径,这部分对应的pc程序计数器偏移为0到3。第二部分是catch语句,pc偏移为4到12。

运行时,字节码序列中的irem指令将抛出ArithmeticException异常,虚拟机将会根据异常查表来找到可以跳转到的catch语句位置。每个含有catch语句的方法的字节码中都附带了一个异常表,它包含每个异常try语句块的条目(entry)。每个条目都有四项信息:起点、终点、跳转的pc偏移位置以及该异常类所在常量池中的索引。remainder方法的异常表如下所示:

Exception table:
from to target type
0 4 4

上面的异常表显示了try语句块的起始位置为0,结束位置为4(不包含4),如果ArithmeticException异常在0-3的语句块中抛出,那么pc计数器将直接跳转到偏移为4的位置。

如果在运行时抛出了一个异常,那么java虚拟机会按顺序搜索整个异常表找到匹配的条目,并且仅会匹配到在其指定范围内的异常。当找到第一个匹配的条目后,虚拟机便将程序计数器设置为新的偏移位置,然后继续执行指令。如果没有条目被匹配到,java虚拟机会弹出当前的栈帧(停止执行当前方法),并继续向上(调用remainder方法的方法)抛出同样的异常。当然上级方法也不会继续正常执行的,它同样需要查表来处理该异常,如此反复。

开发者可以使用throw申明来抛出一个异常,就像remainder方法的catch块中那样。相应的字节码描述如下:
操作码 操作数 描述
athrow 无 弹出Throwable对象引用,并抛出该异常

athrow指令弹出操作数栈栈顶的引用,该引用应当为Throwable的子类 (或者就是 Throwable自身)。
思考

回到开头讨论的话题,你觉得下面两段代码性能差异有多大
A:

for (int i = 0; i < 1000000; i++) {
try {
// throw exception;
} catch (Exception e) {
}
}

B:

try {
for (int i = 0; i < 1000000; i++) {
}
} catch (Exception e) {
}

这篇博客给出了结果以及基准测试方法:try catch 对性能影响 。

我也使用JMH进行了测试,环境和细节就不列出了。其中使用了-Xint参数控制JIT热点编译,结果如下:
异常抛出 关闭JIT 开启JIT(默认开启)
A无异常抛出 两者耗时几乎相同 两者耗时几乎相同
A每次都抛异常 A耗时约是B的30倍 两者耗时几乎相同

了解了译文中的异常的机制后,我们知道try-catch其实不过是在class文件中加了一个异常表用于异常查表,如果没有异常抛出,程序的执行方式和不包含try-catch块完全相同。如果有异常抛出,那么性能的确会下降,而这是有throw导致的,与try-catch无关。此时需要根据实际的业务来预估该方法抛出异常的频率有多高,就算你不去管,当方法被执行次数过多时,java虚拟机也会通过JIT来编译这段方法,编译过后两者的执行效率也是几乎相同的。注意,关闭JIT后循环方法整体性能下降了几十倍。

所以当你遇到有人说try-catch一定要少用会影响性能时,或许你就不会再去盲从这种“建议”了。当然在知晓这个信息的同时,我们反倒更应该去思考如何从业务和代码逻辑的角度来适当地使用try-catch写出更漂亮的代码。

本文参考:
http://www.javaworld.com/article/2076868/learn-java/how-the-java-virtual-machine-handles-exceptions.html