编码时我们常常被要求尽量减少try-catch语句块,理由就是就算不抛异常它们也会影响性能。然而影响究竟有多大呢?语句块应该放在循环体内部还是外部呢?下面译文将详细阐释Java虚拟机处理异常的机制。
虽然文中没有进行性能分析,但文末提供了一些基准测试的文章,先把结论写在前头:try-catch语句块几乎不会影响程序运行性能!
异常机制
异常机制可以让你顺利的处理程序运行过程中所遇到的许多意想不到的情况。为了说明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 <Class DivideByZeroException>
// 创建并压入新对象DivideByZeroException的引用
DivideByZeroException
8 dup // 复制栈顶的DivideByZeroException引用,因为它既要被初始化又要被抛出,初始化将消耗掉栈顶的一个引用
9 invokenonvirtual #9 <Method DivideByZeroException.<init>()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 <Class java.lang.ArithmeticException>
上面的异常表显示了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 {
Math.sin(j);
//throw exception
} catch (Exception e) {
}
}
B:
try {
for (int i = 0; i < 1000000; i++) {
Math.sin(j);
}
} catch (Exception e) {
}
这篇博客给出了结果以及基准测试方法:try catch 对性能影响 。
我也使用JMH进行了测试,环境和细节就不列出了。其中使用了-Xint参数控制JIT热点编译,结果如下:
异常抛出 | 关闭JIT | 开启JIT(默认开启) |
---|---|---|
A无异常抛出 | 两者耗时几乎相同 | 两者耗时几乎相同 |
A每次都抛异常 | A耗时约是B的两倍 | 两者耗时几乎相同 |
了解了译文中的异常的机制后,我们知道try-catch
其实只不过是在class文件中加了一个异常表用于异常查表,如果没有异常抛出,程序的执行方式和不包含try-catch
块完全相同。如果有异常抛出,那么性能的确会下降(还达不到数量级的差距),此时需要根据实际的业务来预估该方法抛出异常的频率有多高,就算你不去管,当方法被执行次数过多时,java虚拟机也会通过JIT来编译这段方法,编译过后两者的执行效率也是几乎相同的。顺带说个题外话,关闭JIT后循环方法整体性能下降了几十倍。
所以当你遇到有人说try-catch
一定要少用会影响性能时,或许你就不会再去盲从这种“建议”了。当然在知晓这个信息的同时,我们反倒更应该去思考如何从业务和代码逻辑的角度来适当地使用try-catch
写出更漂亮的代码。