Java解惑之try catch finally

写给自己:

 

技术关注过于分散往往导致不能专注,长时间的浮躁、纠结最终的结果只是太多珍贵东西浪费,程序员拥有好奇心、求知欲本是件好事,但学会驾驭这些东西才是真正的成熟,坚持并抵住诱惑、潜心而无视喧闹,这是现在自己要做的。

 

转入正文:

 

此文起因是由于论坛中出现的这两个讨论贴:

 

 

至于这个问题是否值得深究我们不做讨论,人跟人观点不一样,我就觉得很有意思,所以可以试着分析一下。

不过要提前说明一下,可能有的地方我的理解并不正确或者措辞并不恰当,还希望高手指正。

 

首先,还是先看下问题,代码如下:

 

Java代码  
  1. private static void foo() {  
  2.   
  3.     try {  
  4.         System.out.println("try");  
  5.         foo();  
  6.     } catch (Throwable e) {  
  7.         System.out.println("catch");  
  8.         foo();  
  9.     } finally {  
  10.         System.out.println("finally");  
  11.         foo();  
  12.     }  
  13. }  
  14.   
  15. public static void main(String[] args) {  
  16.     foo();  
  17. }  

 

 

这个会输出什么呢?

 

要理解这个问题,我们先讲一些其他的东西

 

1) Java Stacks:

 

所谓Java栈,描述的是一种Java中方法执行的内存模型,Java栈为线程私有,线程中每一次的方法调用(或执行),JVM都会为该方法分配栈内存,即:栈帧(Stack Frame),分配的栈帧用于存放该方法的局部变量表、操作栈(JVM执行的所有指令都是围绕它来完成的)、方法编译后的字节码指令信息和异常处理信息等,JVM指定了一个线程可以请求的栈深度的最大值,如果线程请求的栈深度超过这个最大值,JVM将会抛出StackOverflowError,注意,此处抛出的时Error而不是Exception。

下面我们来看一张图(引自Inside Java Virtual Machine)

 

由上图可知:

在一个JVM实例中(即我们运行的一个Java程序)可以同时运行多个线程,而每个线程都拥有自己的Java栈,此栈为线程私有,随着线程内方法的不断调用,线程内的栈深度不断增加,直到溢出。而当一个方法执行完毕(return或throw),该方法所对应的线程内的栈帧被JVM回收,线程内的栈深度会相应的变小,直到线程的终结。

 

2) Java的异常体系:

 

在Java的异常体系中,java.lang.Throwable是所有异常的超类,继承于Object,直接子类为Error和Exception,其中Error和RuntimeException(Exception的子类)为unchecked,即:无需用户捕获,除RuntimeException以外的其他Exception都为checked,即:用户必须捕获,否则编译无法通过。

因为Throwable处于Java异常体系的最顶层,所以Java抛出的任何Error和Exception都会被其捕获,包括StackOverflowError。

 

3) Finally到底是怎么回事?

 

Finally通常会配合try、catch使用,在每一处的try或catch将要退出该方法之前,JVM都会保证先去调用finally的代码,这里所说的退出不单单是指return语句,try或catch中异常的抛出也会导致相应方法的退出(当然,前提是不被catch捕获以及不被finally跳转)。在执行finally代码时,如果finally代码本身没有退出的语句(return或抛出异常),finally执行完毕后还会返回try或catch,由try或catch执行退出指令。

 

语言总是缺乏表现力,看代码吧。

 

Java代码  
  1. public class TCF {  
  2.   
  3.     static int f1() {  
  4.         try {  
  5.             return 1;  
  6.         } finally {  
  7.             System.out.print("f1");  
  8.         }  
  9.     }  
  10.   
  11.     static int f2() {  
  12.         try {  
  13.             throw new Exception("try error");  
  14.         } catch (Exception e) {  
  15.             return 2;  
  16.         } finally {  
  17.             System.out.print("f2");  
  18.         }  
  19.     }  
  20.   
  21.     static int f3() {  
  22.         try {  
  23.             throw new RuntimeException("try error");  
  24.         } catch (ArithmeticException e) {  
  25.             return 3;  
  26.         } finally {  
  27.             System.out.print("f3");  
  28.         }  
  29.     }  
  30.   
  31.     static int f4() {  
  32.         try {  
  33.             throw new Exception("try error");  
  34.         } catch (Exception e) {  
  35.             throw new RuntimeException("catch error");  
  36.         } finally {  
  37.             System.out.print("f4");  
  38.         }  
  39.     }  
  40.   
  41.     static int f5() {  
  42.         try {  
  43.             throw new Exception("try error");  
  44.         } catch (Exception e) {  
  45.             throw new RuntimeException("catch error");  
  46.         } finally {  
  47.             System.out.print("f5");  
  48.             return 5;  
  49.         }  
  50.     }  
  51.       
  52.     static int f6() {  
  53.         try {  
  54.             throw new Exception("try error");  
  55.         } catch (Exception e) {  
  56.             throw new RuntimeException("catch error");  
  57.         } finally {  
  58.             System.out.print("f6");  
  59.             throw new RuntimeException("finally error");  
  60.         }  
  61.     }  
  62.   
  63.     public static void main(String[] args) {  
  64.         System.out.println(" : " + f1());  
  65.           
  66.         try {  
  67.             System.out.println(" : " + f2());  
  68.         } catch (Exception e) {  
  69.             System.out.println(" : " + e.getMessage());  
  70.         }  
  71.   
  72.         try {  
  73.             System.out.println(" : " + f3());  
  74.         } catch (Exception e) {  
  75.             System.out.println(" : " + e.getMessage());  
  76.         }  
  77.           
  78.         try {  
  79.             System.out.println(" : " + f4());  
  80.         } catch (Exception e) {  
  81.             System.out.println(" : " + e.getMessage());  
  82.         }  
  83.           
  84.         try {  
  85.             System.out.println(" : " + f5());  
  86.         } catch (Exception e) {  
  87.             System.out.println(" : " + e.getMessage());  
  88.         }  
  89.   
  90.         try {  
  91.             System.out.println(" : " + f6());  
  92.         } catch (Exception e) {  
  93.             System.out.println(" : " + e.getMessage());  
  94.         }  
  95.     }  
  96. }  

 

 

输出如下:

 

 

解释如下:

声明:我们把每一个可以导致方法退出的点称为结束点。

 

f1方法: try中return 1代表着try方法块的结束点,jvm会在该结束点执行之前,执行finally,finally代码块本身没有结束点,所以执行完finally后会返回try方法块,然后执行讨try中的return 1,所以结果输出如上。

 

f2方法:try中throw代表着try方法块的结束点,但是由于有catch的存在,并且catch可以捕获try中抛出的异常,所以catch在某种意义上延续了try的生命周期,try catch此时组成了一个新的整体,try中的throw不再代表一个结束点,而catch中return 2此时代表try catch整体的结束点,这时没有任何语句可以延续try catch的生命周期,JVM知道try catch产生了一个结束点,将要结束方法的执行,所以JVM在这个结束点执行之前立即执行finally,因为finally没有结束点,所以finally执行完毕返回catch,然后执行该catch中的return 2,所以输出结果如上。

 

f3方法:f3和f2的区别在于f3的catch是捕获ArithmeticException,而我们在try中抛出的是RuntimeException,所以catch没能捕获该异常,也就无法延续try的生命周期,所以try的throw形成一个结束点,JVM获知try将要结束该方法的执行,所以马上调用finally,因为finally内部没有结束点,所以会返回try,然后try抛出自己的异常,输出结果如上。

 

f4方法:f4和f2本质相同,只不过f2中catch是以return 2作为自己的结束点,而f4中catch是以抛出异常作为自己的结束点,输出如上。

 

f5方法:f5和f4大部分相同,catch延续try的生命周期,try catch组成一个整体,而这个整体的结束点由catch抛出异常产生,区别就在于下面的部分,JVM知道try catch整体将要结束该方法的执行,所以马上调用finally,而在f5的finally内部有自己的结束点,即:return 5,这样finally自己就结束了整个方法的执行,而不会返回catch,由catch抛出异常,结束该方法的执行,所以会有如上的输出。

 

f6方法:f6和f5大致相同,只不过在f6的finally中是以抛出异常作为自己的结束点,进而结束方法的执行,输出结果如上。

 

至此,对于try catch finally的使用都应该大致明白了,但是JVM为什么会这么做呢?它内部究竟是怎么实现的呢?

 

让我们从字节码的角度来分析一下JVM的try catch finally运行机制。

声明:下面描述的并不是真正的Java字节码,我们只是为了表述方便而模拟出来的。

 

任何Java类中的方法最终都会编译成字节码,由JVM解释执行,一个Java方法最终形成的就是一串字节码流,下面模拟我们的第一个字节码流:

 

 

如果把这段字节码给JVM,JVM就会顺序执行1、2、3、4指令,很简单吧,下面看另一个:

 

 

JVM遇到这段指令,会先执行1、2、3,到第4条指令时发现是一个goto语句,所以就跳过5,继而执行6、7,依旧是很简单,然后下一个:

 

JVM执行1、2、3,然后跳到第7行,执行4、5、6,然后又跳回第5行,执行return 1,看看是否似曾相识,对,它就是我们f1方法的原型!

当我们用javac把f1编译后,生成class文件内部的字节码原理上就和上面的一样,好,既然我们可以模拟f1了,那让我们再来模拟一下f2:

 

 

JVM执行1、2、3后看到throw语句,就抛出了一个异常,名字为exception,然后,JVM想应该先去执行finally了,执行完finally后,再把那个异常向上抛,但它又一看,原来还有catch部分,它又看看catch内部,居然有它抛出的那个异常(exception),所以JVM就放弃执行finally部分,转而执行catch的相应部分,即4、5、6,然后它遇到goto(goto是由编译生成,因为编译时它看到一个return 2,它知道这是一个结束点,而Java代码中又有finally语句,所以编译器就会在这个return语句之前生成一条goto语句),所以跳到13,执行7,8,9,最后再跳到11行,执行return 2,这样,我们的f2方法就结束了。

 

我们再模拟一个f4方法的字节码:

 

 

在分析上图之前,我先提出一个问题,当try语句块抛出异常,而我们没有写catch语句块或写的catch语句块中不能捕获try中抛出的异常时,JVM还是能帮我们保证finally的执行,它的内部究竟是怎样实现的呢?好,带着这个疑问,我们来看下面的分析:

在try catch finally语法结构中,try是必须的,而catch和finally中我们至少要选一个,由于这样的语法规则,所以我们可以不写catch,而又由于异常有unchecked的类型(或其他原因),所以很有可能即使我们写了catch,try中抛出的异常在我们的catch中还是不能捕获,综上两种情况我们可以得知,不管怎样,只要有try代码块的地方,就有可能存在我们不能捕获或者说无需捕获的异常。而finally的定义又要求,不管try或catch中发生了什么,finally部分必须要执行。可如果JVM不能捕获上面我们描述的那类异常,它就无法得知一个结束点的产生,也就无法在这种结束点产生的情况下调用finally。Java为了使这两种情况可以同时成立,在遇到有try的代码块地方,Java编译器不管我们有没有声明catch,都会为我们生成一个system catch(命名也许并不恰当),而这个catch可以捕获任何异常,这样一来,即使是上面我们讨论的那种异常产生,JVM也能捕获并得知这可能是一个结束点,进而决定finally是否去执行。分析f4,JVM先执行1、2、3,然后抛出一个异常,我们自定义的catch捕获该异常后,执行4、5、6然后又抛出RuntimeException,由于自定义catch中无法再捕获这个异常,所以由system catch来捕获,system catch只做一件事,调用finally,然后rethrow捕获的异常。

 

最后我们看下f5:

 

 

 

由于finally内部有自己return了(而不是f4中的goto 19),所以finally中的return 5就代表了整个该方法的退出。

 

最后,我们再上最后一张截图吧:

 

 

这个截图和上面那个截图没有什么不一样,只是去掉了[try] [catch] [finally]等标识符,之所以这样做是因为我想展示的是一个更加贴近真实字节码的模拟。

为什么这样就更加贴近真实了呢?

 

因为JVM是呆板的,它只知道执行,而没有智能。

 

对于JVM来说,它并不知道哪处是try,哪处是catch,哪处是finally,甚至对于它来说,根本就没有try catch finally的概念,它知道的只有你给我什么指令,我就执行什么指令,没有语法,没有辨别,它内部没有这样的规定说,啊,12到15行是finally语句块,我得注意点,一旦我遇到一个结束点,我先要跳到finally,执行完这个finally后再跳回这个结束点,然后执行这个结束点,JVM内部并没有这样智能的处理,其实它也不需要有这样智能得处理。Java规范中是要求,只要遇到有finally得地方,不管发生什么情况,finally都要执行,但Java中的这个要求并不是直接对JVM提出的,JVM只是执行指令的机器,而把含有Java语法规范的Java源码翻译成字节码指令的是Javac,对,就是Javac,是Javac把这样的Java语法规范翻译成字节码指令流,而在这些字节码指令流中,通过添加一些判断、跳转、返回等指令,使得当JVM在执行这些指令的时候,它的外部表现就是符合Java语法规范的。

 

你明白我在说什么吗?

我是在说,任何方法编译后的结果只是一串字节码指令流,各个指令间都是等价的,虽然我们在我们的方法中添加上了try catch finally,但这只是Java语法,编译后的字节码是没有这些东西的,编译的过程是按照Java语法规范生成一系列的包含判断、跳转、返回等指令的指令流,以使JVM在执行这些指令流时并不总是顺序执行,你自己想想,Java语法规范要求的finally特性本质上不就是跳转吗?,finally语法规范用通俗的语句来说就是,在一个含有finally的方法的各个结束点执行之前先跳转到finally,执行完finally后再跳回来,执行剩下的部分,就这么简单。所以,Javac在遇到有finally的方法时,就找出各个方法的结束点,并在各个结束点指令之前添加一条跳转指令,跳转到finally,执行完finally之后,再跳转回来,哇,原来就是些如此简单的东西啊。

 

此处有一点需要注意的就是当跳转到finally后,如果finally内部有结束点,finally就不会再跳转回去,JVM直接执行了finally内部的结束点(执行其它地方的结束点会先跳转到finally,但执行finally内部的结束点并不会跳转到其它地方,因为这个结束点已经是在finally内部了,无需跳转,所以JVM直接执行了这个结束点,整个方法执行结束),这样finally自己就结束了方法的执行。

 

最后再说明一点:在一个含有try catch finally的方法中,try语句块内部,catch语句块内部和finally语句块内部的所有语句都有与之对应的字节码指令,所以Javac在编译这些部分的时候,直接编译。而至于try catch finally这三个关键字,它们并没有与之对应的字节码指令,它们只是语法上的定义,Javac在遇到这三个关键字时,会通过其它指令(例如:跳转、返回指令)的组合来实现这种语法要求。

 

总结一下,try catch finally有两个作用:

1: 把一个方法的字节码指令流分成三个部分,并标识出,哪个部分是try,哪个部分是catch,哪个部分是finally。(各个部分内部也可以存在的跳转,但这种跳转是语句层面的跳转(例如:if),并且这种跳转只能在自己内部发生,即:只能跳到自己内部的其它语句,而不能跳到其它部分的其它语句)

2:指明了这三个部分的执行顺序,例如,先执行try,再执行catch,再执行finally,再执行catch。(这种执行顺序也可以认为是一种跳转,而这种跳转是语法层面的跳转,只能在try catch finally这三个部分之间发生,即:一旦发生跳转就会跳转到其它部分的其它语句,而不是跳转到自己内部的其它语句)

 

说了这么多,其实我们要记住的只有一点,那就是:要想掌握finally,只需要知道在一个方法中,哪些地方是结束点,即:哪些地方会结束该方法的执行,JVM在这个结束点执行之前,会先去执行finally。

 

 

还记得当初那个引出这篇文章的小程序吗?估计都忘了,再回忆一下吧,有一段代码如下:

 

Java代码  
  1. private static void foo() {  
  2.   
  3.     try {  
  4.         System.out.println("try");  
  5.         foo();  
  6.     } catch (Throwable e) {  
  7.         System.out.println("catch");  
  8.         foo();  
  9.     } finally {  
  10.         System.out.println("finally");  
  11.         foo();  
  12.     }  
  13. }  
  14.   
  15. public static void main(String[] args) {  
  16.     foo();  
  17. }  

 

 

它会输出什么?

 

在说明这个问题之前,我首先不得不说一个现象,那就是在不同的机子上运行上面的代码会有不同的输出结果,看看我遇到的三种输出:

 

1:在公司电脑中,直接执行上面的代码,代码及输出如下:

 

代码:

Java代码  
  1. public class JvmMain {  
  2.   
  3.     private static void foo() {  
  4.   
  5.         try {  
  6.             System.out.println("try");  
  7.             foo();  
  8.         } catch (Throwable e) {  
  9.             System.out.println("catch");  
  10.             foo();  
  11.         } finally {  
  12.             System.out.println("finally");  
  13.             foo();  
  14.         }  
  15.     }  
  16.   
  17.     public static void main(String[] args) {  
  18.         foo();  
  19.     }  
  20. }  

  

输出:

 

 

2:在家里的电脑中,直接执行上面的代码,代码及输出如下:

 

代码和上面的一样,略。

输出:

 

 

 

3:在家里的电脑中,在原来的基础上添加一个方法,代码及输出如下:

 

代码:

Java代码  
  1. class JvmMain {  
  2.   
  3.     public static void foo() {  
  4.         try {  
  5.             System.out.println("try");  
  6.             foo();  
  7.         } catch (Throwable e) {  
  8.             System.out.println("catch");  
  9.             foo();  
  10.         } finally {  
  11.             System.out.println("finally");  
  12.             foo();  
  13.         }  
  14.     }  
  15.   
  16.     public static void fooAgain() throws Exception {  
  17.         throw new Exception("fooAgain");  
  18.     }  
  19.   
  20.     public static void main(String[] args) {  
  21.         foo();  
  22.     }  
  23. }  

 

 

输出:

 

 

 

纠结了很久,但依旧不知道是怎么回事,可能是因为JDK的版本或发行商不同吧,不知道,期盼高人分析啊。

 

按照我的理解,输出结果应该是第一种情况,下面我们就基于第一种情况进行分析:

 

由于程序是层层递归调用,所以栈的深度会不断增加,直到栈溢出。现在假设我们的栈深度最多能有10层(就是说最多可以存放10个栈帧)

 

 

当main中调用foo,foo再调foo,层层递归直到填满第10层。此时,栈及方法执行状态为:由于递归调用,10层栈帧全部填满,此时第10层栈帧对应我们最后调用的那个方法,即:foo。而此时,第10层栈帧对应的foo方法的执行状态为:即将在try中再次调用foo方法,并且希望jvm为此方法分配栈帧,即第11层栈帧,用来存放方法的各种信息,但是,此时的问题就出现了,由于栈内存最多只能分配10层栈帧,所以try中的再次调用foo方法将导致StackOverflowError抛出,而根据我们上面所述,因为第10层栈帧对应的foo方法中存在catch,捕获的是Throwable,所以第10层栈帧对应的foo方法的try中抛出的异常并不代表一个结束点,catch为其延续生命周期,jvm进而执行第10层栈帧对应的foo方法的catch,所以会输出“catch”,然后catch再调用foo,并希望jvm为foo分配栈内存,即第11层栈帧,还是因为栈内存够,catch方法也抛出StackOverflowError,这个Error又被System Catch捕获,System Catch调用第10层栈帧对应的foo方法的finally方法,输出finally,然后第10层栈帧对应的foo方法的finally中再调用foo方法,并希望jvm为其分配内存,内存不够,还是抛出StackOverflowError,此时,finally再次抛出异常,由于该异常成为finally的结束点,所以finally不会再返回system catch,抛出system catch 捕获的catch语句块抛出的异常,jvm执行finally的结束点,退出第10层栈帧对应的foo方法,并且把第10层栈帧内存收回,返回到第9层栈帧对应的foo方法的try语句块中(因为是在此调用的第10层栈帧对应的foo方法),此时第9层栈帧对应的foo方法中的try语句块接到第10层栈帧对应的foo方法返回的异常,try语句块无法处理所以继续抛出异常,由于第9层栈帧对应的foo方法中的catch可以捕获该异常,所以进而执行第9层栈帧对应的foo方法中的catch,输出catch字符串,然后第9层栈帧对应的foo方法中的catch代码块再次调用foo,希望jvm为其分配栈内存,jvm检查栈内存,发现第10层栈帧可以用,所以jvm就为其分配第10层栈帧,分配完成之后,jvm开始执行第10层栈帧对应foo方法的第一条语句,即:输出try字符串,然后jvm开始执行第10层栈帧对应foo方法的第二条语句,即再次调用foo方法,并希望jvm为其分配栈内存,jvm检查之后发现,现在10层栈帧都已经用完,无法再分配了,所以抛出StackOverflowError,之后的jvm行为就和刚刚描述的第10层栈帧对应foo方法是一样的了,最终结果是finally中由于调用foo而jvm无法为其分配第11层栈帧,所以finally抛出异常,返回到第9层栈帧对应的foo方法中的catch中,第9层栈帧对应的foo方法中的catch代码块继续抛出该异常,让其他部分处理,第9层栈帧对应的foo方法的system catch捕获该异常,然后调用第9层栈帧对应的foo方法中的finally语句块,finally中的第一条语句输出finally字符串,第二条语句又调用foo方法,jvm又为该foo方法分配第10层栈帧,后续的执行和第9层栈帧对应的foo方法中的catch中调用foo过程是一样的,结果也是返回StackOverflowError到第9层栈帧对应的foo方法中的finally代码块中,然后,第9层栈帧对应的foo方法中的finally代码块继续向上抛出该异常,并退出第9层栈帧对应的foo方法,回收第9层栈帧占用的内存,第8层栈帧对应的foo方法的try代码块接到该异常并继续抛出,然后。。。

 

后续的部分不再分析,因为我想如果你还没有被绕晕,你肯定是已经理解了,那后续的部分自己已经可以推导出来。

 

直接看我的推导结果吧,我只分析了栈最上面的三层:

 

 

 

怎样看这个图呢?等号划分三个部分,从上到下依次读取三个部分的字符串输出,如果一个部分中有多行,则把上面的行压倒最下面的行的空白处,例如第二部分,将10压入到9的空白处,形成输出为:catch try catch finally finally try catch finally,把三个部分形成的一个大的字符串和程序的输出结果进行比较,结果完全一样(当然,要从开始抛出异常的地方进行比较)。

 

按照这种分析,这段递归程序最终的最终会抛出异常,因为最底层的main方法无法处理上一层foo的finally抛出的StackOverflowError,但我在公司跑了一下午都没有出现这种结果,哎,很受打击,但后来我想了想,一下午的时间真的够吗?

 

假设我们的栈的最大深度为2001,那让我们粗略的算算有多少次的栈帧分配和释放的过程?至少是3的2000次方以上吧,这个数量需要多久??而你再看看你自己栈最大深度,远远不止2000吧。

 

到此为止,所以的分析完毕,但还是有些疑问不能解释:

 

疑问1:java栈深度是否会根据栈内存使用情况动态变化?

 

因为在一长串的try输出中,我无意间发现了一个catch,这是我公司电脑的输出,而家里的电脑就没有这种输出。

 

 

疑问2:是否会因为jdk版本、发行商或是参数设置的问题导致这段程序的输出结果不同?(上面说的三种输出结果中的1和2)

疑问3:为什么我加了一个没有用到的方法(加的方法必须要抛异常才可以)会改变原来的输出?(上面说的三种输出结果中的2和3)

疑问4:为什么上面的输出有些不换行?

郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。