昂贵的异常
抛出问题
Joshua Bloch 在《Effective Java》的 Item 57 里明确地提到过,不要试图用 Exception 的跳转来代替正常的程序控制流。他列举了很多原因,但特别提到了抛出异常会使得整个程序运行变慢。抛出异常远比普通的 return , break 等操作对控制流、数据流的性能影响要大,它就只适合拿来作异常分支的控制语句,而不能拿来编写正常的逻辑。
Throwing exception is expensive.
这句话在 Java 的程序员世界里面已经成为老生常谈。却很少有人谈及,但到底抛出异常比正常的程序跳转返回慢在哪里,有多慢。“不要滥用异常”好像一个猴子定律,人们忘记了为什么不能这么做,却不明白为什么不能这么做。
这几天读了一位同事写的好文[《Java虚拟机是如何处理异常的》][2],深入地分析了 JVM 对异常跳转的处理过程: JVM 会通过异常表的机制,优化异常抛出和正常返回之间的性能差异。仅从程序计数器的移动上来讲,抛出一个异常对栈帧的弹栈并不比直接返回更昂贵。写在前头的结论是:“try-catch语句块几乎不会影响程序运行性能!在开启JIT的情况下,throw也不会增加多少系统开销。”实际上这篇文章也做了一些对比,在不同的场景下,try-catch 会不会让系统变慢。
文中还提到一个有趣的实验:
代码 A1
2
3
4
5
6for (int i = 0; i < 1000000; i++) {
try {
// throw exception;
} catch (Exception e) {
}
}
代码 B
1 |
|
实验结果是:
异常抛出 | 关闭JIT | 开启JIT(默认开启) |
---|---|---|
无异常抛出 | 两者耗时几乎相同 | 两者耗时几乎相同 |
A每次都抛异常 | A耗时约是B的30倍 | 两者耗时几乎相同 |
这几乎推翻了我们既有的刻板印象,从此抛出异常不再是一个需要考虑性能的设计决定了。在仔细研究了这个问题以后,我却有了一个不同的结论:try-catch 语句在 jit 的帮助下 ,也许可以达到和正常 return 一样的性能 ,然而 throw 却会产生远比文中描述的更严重的性能影响 ,因为 throw 不是孤立的语句,它必须伴随着异常对象的创建,而异常对象的创建的昂贵代价,是不可能被 jit 优化掉的。也就是说,我认为[《Java虚拟机是如何处理异常的》][3]中结论的前半部分是正确的,后半部分是不准确的。
异常的机制
JVM 的异常处理机制,大致可以分为三个部分 :
- new Exception
- throw Exception
- catch and deal with Exception
通过[《Java虚拟机是如何处理异常的》][4]我们已经可以明确理解,JVM 对于 try-throw-catch 的程序控制流处理,与普通的 return 如出一辙,都是基于程序计数器的改变,直接使得控制流发生跳转,并无特别之处。而 catch 异常如果为空(即如果我们生吞异常),则开销上看起来和平凡 return 一样。然而,new Exception 实际上是一个非常昂贵的操作。因为异常对象在生成的时候,其父类构造函数 Throwable 中的一部分会调用 fillInStackTrace() 操作。这个 fillInStackTrace() 函数,会试图把当前抛出异常的栈帧全都囊括在内,在实际的运行之中,有可能导致复杂的 CPU 寄存器读写操作。这种读写操作的复杂度与是否使用 jit 无关,也就不可能为 jit 锁优化,是一种很昂贵的固定成本。
[《Java虚拟机是如何处理异常的》][6]中提到的实验并不代表 Java in real world 的工作状况,因为现实中几乎没有栈深只为1的方法调用,一个框架或者容器,本身就会带来几十层的调用栈深度。
一个实验
StackOverflow 上已经有很多人做了相关的实验,我也决定试试用以下代码来印证自己的结论:
1 |
|
这是一个基于 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 |
我们可以清晰地看到:
在相同的栈深度下,抛出异常的时间有可能有是返回普通的对象
的时间的30倍到700倍。在我们的实验里,可能因为存在边际效应,栈深为1的时候反而是性能差距最大的。
这也基本符合在网上看到的其他人的测试的结论(例子1,例子2)。
如果我们再试图在 catch 块里 printStackTrace(),性能差距只会更大。
结论
在开启JIT的情况下,throw也不会增加多少系统开销。
固然是实话。
然而现实之中,throw 却不能离开任何 Throwable 的子类,我们在使用异常机制的时候,必须背负上生成栈帧这样一个沉重的负担,空谈 throw 的性能优化是无意义的。所以
所以当你遇到有人说try-catch一定要少用会影响性能时,或许你就不会再去盲从这种“建议”了。
却是一种过于乐观的结论。我们当然不能无节制地使用 try-catch,因它不仅使程序变得支离破碎,而且除非不会发生异常抛出,否则 JVM 对它进行的优化,只是杯水车薪。
我们应当永远记住,抛出异常是昂贵的,不是因为 try-catch 是昂贵的,因为无论怎么使用异常,异常都是昂贵的。
附原文:
编码时我们常常被要求尽量减少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
// 调用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写出更漂亮的代码。