1. 程式人生 > >Java解惑之try catch finally

Java解惑之try catch finally

此文起因是由於論壇中出現的這兩個討論貼:

至於這個問題是否值得深究我們不做討論,人跟人觀點不一樣,我就覺得很有意思,所以可以試著分析一下。

不過要提前說明一下,可能有的地方我的理解並不正確或者措辭並不恰當,還希望高手指正。

首先,還是先看下問題,程式碼如下:

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

這個會輸出什麼呢?

要理解這個問題,我們先講一些其他的東西

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

輸出如下:


解釋如下:

宣告:我們把每一個可以導致方法退出的點稱為結束點。

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.     try {  
  3.         System.out.println("try");  
  4.         foo();  
  5.     } catch (Throwable e) {  
  6.         System.out.println("catch");  
  7.         foo();  
  8.     } finally {  
  9.         System.out.println("finally");  
  10.         foo();  
  11.     }  
  12. }  
  13. public static void main(String[] args) {  
  14.     foo();  
  15. }  

它會輸出什麼?

在說明這個問題之前,我首先不得不說一個現象,那就是在不同的機子上執行上面的程式碼會有不同的輸出結果,看看我遇到的三種輸出:

1:在公司電腦中,直接執行上面的程式碼,程式碼及輸出如下:

程式碼:

Java程式碼  收藏程式碼
  1. public class JvmMain {  
  2.     private static void foo() {  
  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.     public static void main(String[] args) {  
  15.         foo();  
  16.     }  
  17. }  

輸出:


2:在家裡的電腦中,直接執行上面的程式碼,程式碼及輸出如下:

程式碼和上面的一樣,略。

輸出:

3:在家裡的電腦中,在原來的基礎上新增一個方法,程式碼及輸出如下:

程式碼:

Java程式碼  收藏程式碼
  1. class JvmMain {  
  2.     public static void foo() {  
  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.     public static void fooAgain() throws Exception {  
  15.         throw new Exception("fooAgain");  
  16.     }  
  17.     public static void main(String[] args) {  
  18.         foo();  
  19.     }  
  20. }  

輸出:

糾結了很久,但依舊不知道是怎麼回事,可能是因為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:為什麼上面的輸出有些不換行?