从字节码角度分析try catch finally这个语法糖背后的实现原理

145 阅读6分钟

1. 缘由

我们经常会被问到try中如果return了,finally中的代码是否还会执行?如果在finally中修改返回值大小,是否会影响返回值等问题,这些问题我们可以根据try catch finally的执行逻辑死记硬背,但是时间一长记忆就会发生形变,让问题变得既熟悉又陌生,不能准确给出答案,因此本文尝试通过字节码的方式,来解释try catch finally的本质执行逻辑,透过现象看本质,记忆会更为深刻

2. 站在字节码角度看待问题

下面我会给出一个方法,以及该方法编译后得到的字节码

private static int get() {
    int i = 1;
    try {
        i += 1;
        return i;
    } catch (IllegalArgumentException e) {
        System.out.println("IllegalArgumentException");
    } catch (Exception e) {
        System.out.println("Exception");
    } finally {
        i += 2;
    }
    System.out.println(i);
    return i;
}
private static int get();
    descriptor: ()I
    flags: (0x000a) ACC_PRIVATE, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=0
         0: iconst_1
         1: istore_0
         2: iinc          0, 1
         5: iload_0
         6: istore_1
         7: iinc          0, 2
        10: iload_1
        11: ireturn
        12: astore_1
        13: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
        16: ldc           #27                 // String IllegalArgumentException
        18: invokevirtual #29                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        21: iinc          0, 2
        24: goto          48
        27: astore_1
        28: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
        31: ldc           #34                 // String Exception
        33: invokevirtual #29                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        36: iinc          0, 2
        39: goto          48
        42: astore_2
        43: iinc          0, 2
        46: aload_2
        47: athrow
        48: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
        51: iload_0
        52: invokevirtual #19                 // Method java/io/PrintStream.println:(I)V
        55: iload_0
        56: ireturn
      Exception table:
         from    to  target type
             2     7    12   Class java/lang/IllegalArgumentException
             2     7    27   Class java/lang/Exception
             2     7    42   any
            12    21    42   any
            27    36    42   any

可以看到,上面字节码总共分为了下面几块:

2.1 正常执行路径(无异常)

2.1.1. 初始化局部变量 i
0: iconst_1         // 将 int 1 压入操作数栈
1: istore_0         // 将栈顶的 1 存储到局部变量槽位 0(假设为变量 `a`)
2.1.2. try 块逻辑
2: iinc 0, 1        // 对槽位 0 的值加 1(i = 1 + 12)
5: iload_0          // 将槽位 0 的值(i=2)加载到栈顶
6: istore_1         // 将栈顶的 2 存储到槽位 1(假设为临时变量 `temp`)
7: iinc 0, 2        // 对槽位 0 的值加 2(i = 2 + 24)finally中的逻辑
10: iload_1         // 加载槽位 1 的值(temp=2)到栈顶
11: ireturn         // 返回栈顶的 2(方法提前返回)

解释下上面几个字节码的作用:

iload指令:将局部变量表中指定槽位的int类型变量加载到操作数栈顶

istore指令:将操作数栈顶的int值弹出,存储到局部变量表的指定槽位

iinc指令:直接对局部变量表中指定槽位的int值进行自增/自减操作,无需操作数栈参与

好了,现在我们来看上面字节码让人摸不着头脑的地方,字节码第6行及后续部分,2和5我们都还明白,就是对应的i+=1;,第6行将栈顶2存储到局部变量表槽位1,后续所有操作都跟这个槽位没有关系,第7行将槽位0的值+2,第10行加载槽位0的值到栈顶,第11行返回栈顶元素

这就是我们常说的,try中如果有return,会将结果暂存,等执行完finally代码后,再将暂存的结果返回,因此上面第6行及后续部分的含义我们就清楚了,现将try中计算的结果2暂存,然后执行finally逻辑,finally中如果有对返回值的改动,并不影响暂存值,因为暂存值此时已经快照了,原变量的值确实会变化,但是返回的不是原变量i的值,而是try中执行完return时快照的暂存的值,即上述方法在正常执行情况下,返回结果为2,不是4.

2.2 异常处理路径

2.2.1 捕获 IllegalArgumentException
12: astore_1        // 将异常对象存储到槽位 1(覆盖之前的 `temp`)
13: getstatic #7    // 获取 System.out 对象
16: ldc #27         // 加载字符串 "IllegalArgumentException"
18: invokevirtual #29 // 调用 println 打印字符串
21: iinc 0, 2       // 对槽位 0 的值加 2(i = 2 + 2  4)finally中的逻辑
24: goto 48          // 跳转到指令 48
2.2.2 捕获 Exception
27: astore_1        // 将异常对象存储到槽位 1
28: getstatic #7     // 获取 System.out 对象
31: ldc #34          // 加载字符串 "Exception"
33: invokevirtual #29 // 调用 println 打印字符串
36: iinc 0, 2        // 对槽位 0 的值加 2(i = 2 + 2  4)finally中的逻辑
39: goto 48           // 跳转到指令 48
2.2.3 最终处理(finally 逻辑,未捕获异常的抛出路径)
42: astore_2        // 将未捕获的异常对象存储到槽位 2
43: iinc 0, 2        // 对槽位 0 的值加 2(i = 2 + 2 → 4)finally中的逻辑
46: aload_2         // 加载槽位 2 的异常对象到栈顶
47: athrow          // 重新抛出异常

2.3 最终输出与返回

48: getstatic #7    // 获取 System.out 对象
51: iload_0         // 加载槽位 0 的值(i)到栈顶
52: invokevirtual #19 // 调用 println 打印 i 的值
55: iload_0         // 再次加载 i 的值到栈顶
56: ireturn         // 返回 i 的值

2.4 异常表分析

Exception table:
   from    to  target type
     2     7    12   Class java/lang/IllegalArgumentException
     2     7    27   Class java/lang/Exception
     2     7    42   any
    12    21    42   any
    27    36    42   any
  • 前三行try 块(指令 2-7)抛出异常时的处理逻辑:

    • 若抛出 IllegalArgumentException,跳转到 12。
    • 若抛出 Exception,跳转到 27。
    • 若抛出其他异常,跳转到 42(finally 块)。
  • 后两行:在 catch 块(12-21 和 27-36)中再次抛出异常时,跳转到 42(finally 块)。

3. 核心

try catch finally语法糖实现的核心主要是异常表和代码复制,这两个特性在上面例子中均有体现,异常表很好理解,参见2.4即可,代码复制也很好理解,finally中的代码编译后的字节码,在下述所有位置都会复制一遍来插入:

  • try 块正常结束后的代码路径
  • 每个 catch 块处理完异常后的代码路径
  • 未捕获异常的抛出路径(通过 athrow 指令)

在上面的字节码中我们也能看到try、catch以及未捕获异常的抛出路径中均有相关字节码,至此,我们从字节码层面,通过代码复制、异常表、返回值暂存等概念,就能非常清晰的理解try catch finally的执行逻辑了