Java虚拟机是如何处理异常的?

Posted 小杨Vita

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java虚拟机是如何处理异常的?相关的知识,希望对你有一定的参考价值。

编码时我们常常被要求尽量减少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异常。

DivideByZeroExceptionArithmeticException 的不同之处在于前者是受检查异常,而后者是非受检查异常。因此后者抛出时不需要在方法头添加throws语句。ErrorRuntimeException类的所有子类都是非受检查异常(例如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:

fromtotargettype
044<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 
        //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来编译这段方法,编译过后两者的执行效率也是几乎相同的。注意,这儿不考虑异常对象的创建时间,而实际项目中每次都需要创建Exception对象,此时需要收集堆栈信息,系统消耗很大,花费的时间也是远超上述的时间。

所以当你遇到有人说try-catch一定要少用会影响性能时,你可能应该把重点放在异常是否有必要被创建以及被抛出的频率。同时,我们更应该去思考如何从业务和代码逻辑的角度来适当地使用try-catch写出更漂亮的代码。

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

以上是关于Java虚拟机是如何处理异常的?的主要内容,如果未能解决你的问题,请参考以下文章

c语言是如何处理除法运算的呀?

JVM 是如何处理异常的?Java 虚拟机异常处理机制原理

JVM-JVM 是如何处理异常的

JVM虚拟机-- JVM是如何处理异常的

java是如何处理异常的?

Java大神简历