从JVM异常表和字节码角度分析try-catch-finally为什么效率低

共 8426字,需浏览 17分钟

 ·

2021-08-23 12:26

你知道的越多,不知道的就越多,业余的像一棵小草!

你来,我们一起精进!你不来,我和你的竞争对手一起精进!

编辑:业余草

推荐:https://www.xttblog.com/?p=5261

有经验的 Java 老司机可能告诉过你,Java 中的 try-catch-finally代码块范围不要包的太大,因为它可能会影响 Java 程序的运行效率

你可能会百思不得其解其中是什么缘由?甚至是不少“老程序员”也搞不懂其中的机制!刚好最近我在 codereview 时,要求同事缩小 try-catch-finally代码块的范围,并从字节码的角度给他们讲了异常表以及 JVM 的处理机制。

本文将我在公司内部的讲解整理成文稿,分享给大家!

希望在这之后,不会有人再将下面这张表情包发给你……

try-catch-finally

环境介绍

  • 本文内容基于 jdk 1.8.0_127
  • IntelliJ IDEA 2018 以及 jclasslib 字节码插件

字节码中的 try-catch

Talk is cheap, show you my code!

反编译后的字节码

为了方便演示,我将整个业务代码移除,简化成下面的 demo 代码。

下面是第一段测试代码,这段代码里有一个 try-catch 代码块,每个代码块中都有一行输出,然后会在 catch 代码块中捕获 Exception 异常。

 public static void main(String[] args) {
        try {
            System.out.println("enter try block");
        } catch (Exception e) {
            System.out.println("enter catch block");
        }
    }

然后在命令行中先定位到这个类的字节码文件目录中,执行主方法后敲下javap -c 类名(注意⚠️,这里不要带上文件的后缀名)进行反编译,或者直接在编译器中选择Build Project,然后打开 jclasslib 工具就可以看到这个类的字节码。查看字节码的方式比较多,不习惯使用 jclasslib 的网友可以安装 idea 的其他插件。

通过上面的操作后,main 方法对应的字节码如下图所示:

主方法的字节码

从上图可以看出 0 ~ 3 行是 try 代码块中的输出语句,12 ~ 17 行是 catch 代码块中的输出语句。最后的第 20 行的 return 是所有方法都会存在的指令。

业余草敲黑板,重点来了

上图中的第 8 行对应的字节码 goto,语义是8 goto 20,即这个字节码指令就是从 8 跳转到第 20 行的意思,前提是第 5 行的输出代码有异常。说白了,就是 try 代码块中如果没有出现异常,那么就跳转到第 20 行,也就是整个方法行完成后执行 return 了。

这是一段再正常不过的代码了,假设运行过程中出现异常了,那么虚拟机是如何知道应该处理 try 代码块的呢?JVM 又是如何知道该捕获何种异常的呢?

答案就是:异常表 Exception table。

异常表

当一个类被编译成字节码之后,它的每个方法中只要有 try-catch 都会有一张异常表。异常表中包含了“监控”的范围,“监控”各种异常以及抛出异常后去哪里处理。比如上述的示例代码,在 jclasslib 中它的异常表如下图。

jclasslib查看字节码异常表

或者在javap -c命令下异常表是这样的:

Exception table:
   from    to  target type
       0     8    11   Class java/lang/Exception

无论是哪种形式的异常表,可以确定的是,异常表中每一行就代表一个异常处理器。

下面解释一下 jclasslib 异常表中,各个列所代表的含义。

  • Nr. 列:代表异常处理器的序号
  • Start PC (也就是 from):代表异常处理器所监控范围的起始位置
  • End PC (对应 to):代表异常处理器所监控范围的结束位置(注意:该行不被包括在监控范围内,一般是 goto 指令。就是一个前包区间"[)")
  • Handler PC (对应 target):指向异常处理器的起始位置,在这里就是 catch 代码块的起始位置。
  • Catch Type (对应 type):代表异常处理器所捕获的异常类型。如 Exception,any 等。

如果程序触发了异常,Java 虚拟机会按照序号遍历异常表,当触发的异常在这条异常处理器的监控范围内(from 和 to),且异常类型(type)与该异常处理器一致时,Java 虚拟机就会跳转到该异常处理器的起始位置(target)开始执行字节码。

如果程序没有触发异常,那么虚拟机会使用 goto 指令跳过 catch 代码块,执行 finally 语句或者方法返回。

字节码中的 finally

接下来在上述的代码中再加入一个 finally 代码块,然后再次执行反编译的命令看看有什么不一样。

// 源代码
public static void main(String[] args) {
        try {
            // dosomething
            System.out.println("enter try block");
        } catch (Exception e) {
            System.out.println("enter catch block");
        } finally {
            System.out.println("enter finally block");
        }
    }

上面这段 Java 源码对应的字节码如下:

// 字节码
 0 getstatic #2     <java/lang/System.out>
 3 ldc #3           <enter try block>
 5 invokevirtual #4 <java/io/PrintStream.println>
 8 getstatic #2     <java/lang/System.out>
11 ldc #5           <enter finally block>
13 invokevirtual #4 <java/io/PrintStream.println>
16 goto 50 (+34)
19 astore_1
20 getstatic #2     <java/lang/System.out>
23 ldc #7           <enter catch block>
25 invokevirtual #4 <java/io/PrintStream.println>
28 getstatic #2     <java/lang/System.out>
31 ldc #5           <enter finally block>
33 invokevirtual #4 <java/io/PrintStream.println>
36 goto 50 (+14)
39 astore_2
40 getstatic #2     <java/lang/System.out>
43 ldc #5           <enter finally block>
45 invokevirtual #4 <java/io/PrintStream.println>
48 aload_2
49 athrow
50 return

finally 代码块在当前版本(jdk 1.8)的 JVM 中的处理机制是比较特殊的。从上面的字节码中也可以明显看到,只是加了一个 finally 代码块而已,字节码指令增加了很多行,goto 和 ldc 指令分别出现了多次。指令的增多,意味着栈轨迹复杂化了,效率或多或少有些影响。

如果再仔细观察一下,我们可以发现。在字节码指令中,有三块重复的字节码指令,分别是 8 ~ 13 行、28 ~ 33 行和 40 ~ 45 行,如果对字节码有些了解的同学或许已经知道了,这三块重复的字节码就是 finally 代码块对应的代码。

出现三块重复字节码指令的原因是在 JVM 虚拟机中,所有异常路径(如 try、catch)以及所有正常执行路径的出口都会被附加一份 finally 代码块。也就是说,在上述的示例代码中,try 代码块后面会跟着一份 finally 的代码,catch 代码块后面也是如此,再加上原本正常流程会执行的 finally 代码块,共有 3 个地方会出现 finally,因此在字节码中一共也有三份 finally 代码块代码块。

而针对每一条可能出现的异常的路径,JVM 都会在异常表中多生成一条异常处理器,用来监控整个 try-catch 代码块,同时它会捕获所有种类的异常,并且在执行完 finally 代码块之后会重新抛出刚刚捕获的异常。

上述示例代码的异常表如下:

Exception table:
   from    to  target type
       0     8    19   Class java/lang/Exception
       0     8    39   any
      19    28    39   any

可以看到与原来相比异常表增加了两条,第 2 条异常处理器异常监控 try 代码块,第 3 条异常处理器监控 catch 代码块,如果出现异常则会跳转到第39行的 finally 代码块执行。

这就是 finally 一定会在 try-catch 代码块之后执行的原因了(某些能中断程序运行的操作除外:95% 的人都答错的一道阿里面试题:finally 中的代码一定会被执行吗?)。

如果 finally 也抛出异常

上文说到虚拟机会对整个 try-catch 代码块生成一个或多个异常处理器,如果在 catch 代码块中抛出了异常,这个异常会被捕获,并且在执行完 finally 代码块之后被重新抛出。

那么在这里有一个额外的问题需要提及,假设在 catch 代码块中抛出了异常 A,当执行 finally 代码块时又抛出了异常 B,那么最后抛出的是什么异常呢?

如果有同学自己尝试过这个操作,就会知道最后抛出的异常 B。也就是说,在捕获了 catch 代码块中的异常后,如果 finally 代码块中也抛出了异常,那么最终将会抛出 finally 中抛出的异常,而原来 catch 代码块中的异常将会被忽略。

如果代码块中有 return

讲完了异常在各个代码块中的情况,接下来再来考虑一下 return 关键字吧。如果 try 或者 catch 中有 return,那么 finally 还会执行吗?如果 finally 中也有 return,那么最终返回的值是什么?这是很多初级程序员面试必考的问题,想象大家初入职场的时候,都被拷问过。

下面为了说明这个问题,我们在通过一段测试代码,然后查看它的字节码指令,通过指令来揭开它的面纱。

public static int get() {
    try {
        return 1;
    } catch (Exception e) {
        return 2;
    } finally {
        return 3;
    }
}

// 字节码指令
 0 iconst_1
 1 istore_0
 2 iconst_3
 3 ireturn
 4 astore_0
 5 iconst_2
 6 istore_1
 7 iconst_3
 8 ireturn
 9 astore_2
10 iconst_3
11 ireturn

正如上文所述,finally 代码块会在所有正常及异常的路径上都复制一份,在这段字节码中,iconst_3就是对应着 finally 代码块,共三份,所以即便在 try 或者 catch 代码块中有 return 语句,最终还是会会执行 finally 代码块中的内容。除非有例外情况发生,参考:95% 的人都答错的一道阿里面试题:finally 中的代码一定会被执行吗?

也就是说,这个方法最终的返回结果是 3。

下面整理了一个流程图,分享给大家。通过查看流程图加深理解。

最后总结了几个面试题,大家可以试着不看答案先回答一下,方便确认是否真的理解了本文!

为什么使用异常捕获的代码比较耗费性能

单从 Java 语法上看不出来,但是从 JVM 实现的细节上来看就明白了。构造异常实例,需要生成该异常的栈轨迹。该操作会逐一访问当前线程的栈帧,记录各种调试信息,包括类名,方法名,触发异常的代码行数等等。

finally 是怎么实现无论异常与否都能执行

编译器在编译代码时会复制 finally 代码块放在 try-catch 代码块所有正常执行路径以及异常执行路径的出口处。

finally 中有 ruturn 语句,catch 中抛出的异常会被忽略,为什么

catch 抛出的异常会被 finally 捕获,执行完 finally 后会重新抛出该异常。由于 finally 中有 return 语句,在重新抛出异常之前,代码就已经返回了。

方法的异常表都包含哪些异常

方法的异常表只声明这段代码会被捕获的异常,而且是非检查异常。如果 catch 中有自定义异常,那么异常表中也会包含自定义异常的条目。

检查异常和非检查异常也就是其他书籍中说的编译期异常和运行时异常?

检查异常也会在运行过程中抛出。但是它会要求编译器检查代码有没有显式地处理该异常。非检查异常包括 Error 和 RuntimeException,这两个则不要求编译器显式处理。

以上内容,希望能够帮助到大家。如果感觉本文内容还可以,欢迎点赞👍!

浏览 69
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报