Java解惑之try catch finally
写给自己:
技术关注过于分散往往导致不能专注,长时间的浮躁、纠结最终的结果只是太多珍贵东西浪费,程序员拥有好奇心、求知欲本是件好事,但学会驾驭这些东西才是真正的成熟,坚持并抵住诱惑、潜心而无视喧闹,这是现在自己要做的。
转入正文:
此文起因是由于论坛中出现的这两个讨论贴:
至于这个问题是否值得深究我们不做讨论,人跟人观点不一样,我就觉得很有意思,所以可以试着分析一下。
不过要提前说明一下,可能有的地方我的理解并不正确或者措辞并不恰当,还希望高手指正。
首先,还是先看下问题,代码如下:
- private static void foo() {
- try {
- System.out.println("try");
- foo();
- } catch (Throwable e) {
- System.out.println("catch");
- foo();
- } finally {
- System.out.println("finally");
- foo();
- }
- }
- public static void main(String[] args) {
- foo();
- }
这个会输出什么呢?
要理解这个问题,我们先讲一些其他的东西
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执行退出指令。
语言总是缺乏表现力,看代码吧。
- public class TCF {
- static int f1() {
- try {
- return 1;
- } finally {
- System.out.print("f1");
- }
- }
- static int f2() {
- try {
- throw new Exception("try error");
- } catch (Exception e) {
- return 2;
- } finally {
- System.out.print("f2");
- }
- }
- static int f3() {
- try {
- throw new RuntimeException("try error");
- } catch (ArithmeticException e) {
- return 3;
- } finally {
- System.out.print("f3");
- }
- }
- static int f4() {
- try {
- throw new Exception("try error");
- } catch (Exception e) {
- throw new RuntimeException("catch error");
- } finally {
- System.out.print("f4");
- }
- }
- static int f5() {
- try {
- throw new Exception("try error");
- } catch (Exception e) {
- throw new RuntimeException("catch error");
- } finally {
- System.out.print("f5");
- return 5;
- }
- }
- static int f6() {
- try {
- throw new Exception("try error");
- } catch (Exception e) {
- throw new RuntimeException("catch error");
- } finally {
- System.out.print("f6");
- throw new RuntimeException("finally error");
- }
- }
- public static void main(String[] args) {
- System.out.println(" : " + f1());
- try {
- System.out.println(" : " + f2());
- } catch (Exception e) {
- System.out.println(" : " + e.getMessage());
- }
- try {
- System.out.println(" : " + f3());
- } catch (Exception e) {
- System.out.println(" : " + e.getMessage());
- }
- try {
- System.out.println(" : " + f4());
- } catch (Exception e) {
- System.out.println(" : " + e.getMessage());
- }
- try {
- System.out.println(" : " + f5());
- } catch (Exception e) {
- System.out.println(" : " + e.getMessage());
- }
- try {
- System.out.println(" : " + f6());
- } catch (Exception e) {
- System.out.println(" : " + e.getMessage());
- }
- }
- }
输出如下:
解释如下:
声明:我们把每一个可以导致方法退出的点称为结束点。
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。
还记得当初那个引出这篇文章的小程序吗?估计都忘了,再回忆一下吧,有一段代码如下:
- private static void foo() {
- try {
- System.out.println("try");
- foo();
- } catch (Throwable e) {
- System.out.println("catch");
- foo();
- } finally {
- System.out.println("finally");
- foo();
- }
- }
- public static void main(String[] args) {
- foo();
- }
它会输出什么?
在说明这个问题之前,我首先不得不说一个现象,那就是在不同的机子上运行上面的代码会有不同的输出结果,看看我遇到的三种输出:
1:在公司电脑中,直接执行上面的代码,代码及输出如下:
代码:
- public class JvmMain {
- private static void foo() {
- try {
- System.out.println("try");
- foo();
- } catch (Throwable e) {
- System.out.println("catch");
- foo();
- } finally {
- System.out.println("finally");
- foo();
- }
- }
- public static void main(String[] args) {
- foo();
- }
- }
输出:
2:在家里的电脑中,直接执行上面的代码,代码及输出如下:
代码和上面的一样,略。
输出:
3:在家里的电脑中,在原来的基础上添加一个方法,代码及输出如下:
代码:
- class JvmMain {
- public static void foo() {
- try {
- System.out.println("try");
- foo();
- } catch (Throwable e) {
- System.out.println("catch");
- foo();
- } finally {
- System.out.println("finally");
- foo();
- }
- }
- public static void fooAgain() throws Exception {
- throw new Exception("fooAgain");
- }
- public static void main(String[] args) {
- foo();
- }
- }
输出:
纠结了很久,但依旧不知道是怎么回事,可能是因为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:为什么上面的输出有些不换行?
郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。