字节码层面理解--java中的finally是如何执行的

finally一直是java笔试中出现概率较高的知识点,我之前在面试的时候碰到也是很迷糊,今天就通过字节码来看finally最后究竟是怎样执行的,只要明白了原理,面试自然迎刃而解。阅读本文需要耐心,就算不懂字节码,多看看也就明白了,等哪天有空写一篇字节码入门的教程。废话不多说,进入正题。


1.try...catch的机制--异常表

java中的finally是不可以单独使用的,只能结合try使用,所以有必要先弄明白try...catch的原理。

源代码如下

    public static void tryCatch(){
        try {
            test();
        } catch (Exception e){
            e.printStackTrace();
        }
    }

使用命令javap -v -p Test.class反编译得到方法的字节码

public static void tryCatch();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=1, args_size=0
         0: invokestatic  #5                  // Method test:()V
         3: goto          11
         6: astore_0
         7: aload_0
         8: invokevirtual #7                  // Method java/lang/Exception.printStackTrace:()V
        11: return
      Exception table:
         from    to  target type
             0     3     6   Class java/lang/Exception

从字节码可以看出,方法tryCatch有一个异常表(Exception table),异常表解释为,从位置from的指令开始,此处from=0,表示Code中的偏移量为0的指令,也就是invokestatic #5这个指令,到位置to(不包括to)的指令为止,这段指令执行期间如果发生异常且异常的类型type为Class java.lang.Exception,程序就跳转到target处指令,这里target=6处指令是astore_0。异常表的一行数据,表示一个catch块,这里只有一个行,也就是只有一个catch块。

下面让我逐行解释一下该方法的指令

偏移量0 invokestatic #5,该指令则是调用静态方法test()。

偏移量3 goto 11,如果上一条指令发生异常,就执行该条指令,直接跳转到偏移量11的指令。

偏移量6 astore_0,如果第一条指令发生异常,根据异常表,程序就会跳转到这条指令,6~8三条指令就是catch块代码对应的指令了,astore_0指令是将第一条指令的异常对象存储到局部变量表slot=1的位置。

偏移量7 aload_0,将局部变量表slot=1的位置异常对象压入操作数栈。

偏移量8 invokevirtual #7,调用java.lang.Excetion.printStackTrace()方法,调用对象就是上一个指令压入操作数栈的异常对象。

偏移量9 return,不管是从goto 11这条指令跳转到这的,还是发生了异常,执行了catch块代码后到这的,方法最后需要执行的都是return指令,方法结束。


2.finally机制--复制finally代码块到每个分支

做java的都知道或者听说过finally块一定会执行,那么java是如何保证finally块一定会执行的呢?直觉上是不是觉得通过跳转来实现的呢?

看源代码如下

    public static void func1(){
        try{
            test();
        }catch (Exception e){
            test1();
        }finally {
            test2();
        }
    }

还是使用命令javap -v -p Test.class反编译得到方法的字节码如下

  public static void func1();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=2, args_size=0
         0: invokestatic  #5                  // Method test:()V
         3: invokestatic  #8                  // Method test2:()V
         6: goto          25
         9: astore_0
        10: invokestatic  #9                  // Method test1:()V
        13: invokestatic  #8                  // Method test2:()V
        16: goto          25
        19: astore_1
        20: invokestatic  #8                  // Method test2:()V
        23: aload_1
        24: athrow
        25: return
      Exception table:
         from    to  target type
             0     3     9   Class java/lang/Exception
             0     3    19   any
             9    13    19   any

先看最后异常表,结合刚刚讲解的异常表知识,我们可以知道func1方法有三个catch块,但是我们的代码里明明只有一个catch块啊?解释就是编译器添加了两个catch块。第一行表示指令0发生Exception类型异常跳转到指令9。第二行表示指令0发生任何类型异常(也就是Throwable类型)跳转到指令19,前提是前面没有捕获该异常。第三行表示指令9~10发生任何类型异常则跳转到指令19。

现在来看方法字节码指令

0: invokestatic #5 调用静态方法test(),结合异常表可知,test若发生Exception类型异常,程序跳转到指令9,若发生Throwable类型异常,程序跳转到指令19,若没有发生异常,则顺序往下执行。

3:invokestatic #8 调用静态方法test2()。这是在try块中调用test2()。

6:goto 25 跳转到25指令return,将会结束方法。

9:astore_0 将异常对象存储到局部变量表slot=0的位置

10:invokestatic #9 调用静态方法test1(),这是第一个catch块的代码

13:invokestatic #8 调用静态方法test2()。这是在第一个catch块中调用test2()。

16:goto 25 同6。

19:astore_1将异常对象存储到局部变量表slot=1的位置,这是第二、三个catch块。

20: invokestatic  #8 调用test2()。
23~24:将异常重新抛出。

25: return 方法结束。

通过解析字节码,可以知道编译后的func1相当于这样

    public static void func1() {
        try {
            test();
            test2();//编译器添加的finally块代码
        } catch (Exception e){
            try {
                test1();
                test2();//编译器添加的finally块代码
            }catch (Throwable t){//编译器添加的Throwable类型的catch分支
                test2();//添加这个分支的目的就是为了确保test1()方法发生异常后finally块还会被执行
                throw t;//执行完test2后,将异常对象原样抛出
            }
        } catch (Throwable t){//这个也是编译器添加的Throwable类型的catch分支,目的还是为了确保finally块一定执行
            test2();//编译器复制的finally块代码
            throw t;//抛出异常
        }
    }

至此,我们可以回答这节开始的两个问题了,编译器将finally块代码复制到每个分支,并且编译器还会为每个分支添加一个Throwable类型catch分支,然后在这个catch分支里,先添加finally块的代码,再将异常对象原样抛出。finally是通过复制代码块到每个分支最后,而不是在每个分支最后跳转到finally块


3.当finally遇到return,return的表现会有什么不一样呢?

我们先单独看return的字节码是怎样的,下面是源代码

    public static int returnFunc(){
        return 1;
    }

javap反编译后的字节码指令如下

public static int returnFunc();
    descriptor: ()I
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=0, args_size=0
         0: iconst_1
         1: ireturn

总共就两个指令,iconst_1,将int型常量1压入操作数栈,ireturn,将操作数栈顶的int型变量返回并结束方法。所以,通常来说代码return i 被编译为两个指令,第一个指令,将需要返回的值压入栈顶,第二个指令,将栈顶变量作为方法返回值并结束方法

然后让我们来看finally和return一起使用时,return会有什么不一样呢?源代码如下,这也是从一个经典的面试题。朋友可以先猜一下返回值是什么再往下看。

    public static int func2() {
        int i = 0;
        try {
            return i;
        } catch (Exception e){
            return i;
        } finally {
            i++;
        }
    }

javap反编译后的字节码如下

  public static int func2();
    descriptor: ()I
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=4, args_size=0
         0: iconst_0
         1: istore_0
         2: iload_0
         3: istore_1
         4: iinc          0, 1
         7: iload_1
         8: ireturn
         9: astore_1
        10: iload_0
        11: istore_2
        12: iinc          0, 1
        15: iload_2
        16: ireturn
        17: astore_3
        18: iinc          0, 1
        21: aload_3
        22: athrow
      Exception table:
         from    to  target type
             2     4     9   Class java/lang/Exception
             2     4    17   any
             9    12    17   any

0~1: 两个指令为局部变量i赋值0,也就是int i=0。

2~3: iload_0将局部变量i的值压入栈顶,istore_1将栈顶变量存储到局部变量表slot=1的位置。这是代码return i对应的指令。哎,,,这里好像跟上一个例子中的return 1表现不一样,上一个例子中,return 1是将常量压入栈顶,然后返回返回1并结束方法,而这里return i将i的值压入栈顶后,并没有返回1然后结束方法,而是将栈顶变量又存到了局部变量表中,而且和原来局部变量i的位置不一样,这里的不一样就是因为finally,从这里可以看到finally将原来return的两个指令分成了多个指令。

4:innc 0,1 就是对局部变量表中slot=0的局部变量,也就是变量i,自增1,这对应着finally的代码i++。

7~8:iload_1 将局部变量表中slot=1的局部变量压入栈顶,这里并不是局部变量i,变量i在局部变量表中slot=0的位置,slot=1的位置是指令3存储的值,也就是try中return准备返回的值,ireturn则是以栈顶变量作为返回值并结束方法。

如果不发生异常,方法就到此结束了。剩下的代码就和上面finally的例子一样,就不另行分析了。

由此可知,当return后还有finally代码需要执行,return拆分成四个指令,前两个指令,将原本需要返回的变量存储为一个局部变量,当执行完finally代码后,再由后两个指令,将刚刚存储的局部变量加载到栈顶并返回。所以方法返回值0。

伪代码如下

int tmp = returnValue;//return i在执行finally代码前会先将i的存为一个临时局部变量
doFinally...;//执行finally块代码
return tmp;//执行完finally后,return i会取出刚刚存储的局部变量并返回

4.finally遇到return的另一种情况

再来看finally和return相遇的另一种情况,finally中有return语句。这同样是一个经典面试题,还是请朋友你先猜一下返回值吧。

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

javap反编译字节码如下

  public static int func3();
    descriptor: ()I
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=3, args_size=0
         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
      Exception table:
         from    to  target type
             0     2     4   Class java/lang/Exception
             0     2     9   any
             4     7     9   any

0~1: iconst_1 将常量1压入操作数栈顶,istore_0 将栈顶变量存储到局部变量表slot=0的位置。这就是代码return 1在finally块执行之前将需要返回的值0存为一个局部变量,同时这也印证了上一个例子对return的分析。

2~3: iconst_3 将常量3压入操作数栈顶,ireturn 返回栈顶的值并结束方法。这就是finally块中的代码return 3,因为这个return 3之后没有finally代码需要再执行了,所以此处的return 3就只有两个指令,同样,这也印证了上上个例子对return的分析。

如果没有发生异常,方法就到这里结束,所以返回值3。由此可知,当return后的finally中有return语句,则第一个return只是将返回值存为一个局部变量,而第二个的return会结束方法,而且第一个return存储的返回值也不会被使用

这段逻辑用伪代码表示就是这样

int tmp = returnValue1; //第一个return 1在执行finally代码之前将返回值存为一个局部变量
finally...//finally里的其他代码
return 3;//finally里最后有一个return语句,这个return会返回3并结束方法,所以前一个return的返回值也没有用了

5.当finally遇到Throwable异常和return

源代码是这样子的,没有catch,try块中发生了异常,finally里有return语句。我们知道java中throw异常会结束方法,return也会结束方法,所以这个方法有返回值吗,又会抛出异常吗?

    public static int func4() {
        try {
            throw new Exception();
        } finally {
            return 2;
        }
    }

同样通过字节码来分析

  public static int func4();
    descriptor: ()I
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=0
         0: new           #6                  // class java/lang/Exception
         3: dup
         4: invokespecial #10                 // Method java/lang/Exception."":()V
         7: athrow
         8: astore_0
         9: iconst_2
        10: ireturn
      Exception table:
         from    to  target type
             0     9     8   any

0~4: new、dup、invokespecial #10这三个指令,熟悉字节码指令的人就会知道这是创建Exception对象,并将这个异常对象的引用压入操作数栈顶。

7: athrow 抛出操作数栈顶的异常对象。本来程序到这里就应该结束了,结合第二节,有finally的代码,编译器会为代码添加catch块,并将finally的代码复制到catch块中,以此确保finally一定会被执行。再仔细看方法的字节码,编译器为func4生成了一个异常表,所以该方法就相当于下面这样。

    public static int func4() {
        try {
            throw new Exception();
        } catch(Throwable t) {
            return 2;
        }
    }

8: 指令7抛出了异常,被编译器生成的异常表捕获,并将异常对象放在操作数栈顶,astore_0 会将这个异常对象存到局部变量表slot=0的位置。

9~10: iconst_2、ireturn返回int 2,并结束方法。

由此可知,即使没有catch块,因为finally的存在,编译器也会为try添加一个catch块,捕获所有类型的异常(Throwable),然后将finally代码复制到catch块中,以此确保finally一定被执行。所以这个方法并不会抛出异常,会返回int型2。


本来以为finally很快就写完了,结果在写作过程中,发现各种好玩的case,所以花了我三四个小时,把各种情况都写出来了,希望对各位朋友有所帮助。

总结一下:

1.try...catch是通过异常表实现的。

2.java确保finally一定会被执行,是通过复制finally代码块到每个分支实现的。

3.通常return会被编译成2个指令,当return后还有finally,return就会被编译成4个指令。

4.return后还有finally,return会将返回值存储起来,finally中并不能改变返回值(当返回值是引用类型是另外一种情况,自行研究下)。

5.return后的finally里有return,则finally里的return会结束方法。

6.try中抛出异常后,finally里有return,则方法并不会抛出异常,会正常返回。


最后的最后,这一篇文章有点长,希望你耐心看完。留一道面试题,func2返回值A的属性aInt会是多少?这和上面的func2有什区别呢?

    public static A func2() {
        A a = new A();
        a.aInt = 0;
        try {
            return a;
        } catch (Exception e){
            return a;
        } finally {
            a.aInt = a.aInt + 1;
        }
    }

    public static class A{
        public int aInt ;
    }

 

 

 

 

你可能感兴趣的:(字节码,java)