卷向字節碼-Java異常到底是怎么被處理的?


你好呀,我是why,你也可以叫我歪歪。

比如下面這位讀者:

他是看了我《神了!異常信息突然就沒了?》這篇文章后產生的疑問。

既然是看了我的文章帶來的進一步思考,恰巧呢,我又剛好知道。

雖然這類文章看的人少,但是我還是來填個坑。

害,真是暖男石錘了。

異常怎么被拋出的。

先上一個簡單代碼片段:

運行結果大家都是非常的熟悉。

光看這僅有的幾行代碼,我們是探索不出來什么有價值的東西。

我們都知道運行結果是這樣的,沒有任何毛病。

這是知其然。

那么所以然呢?

所以然,就藏在代碼背后的字節碼里面。

通過 javap 編譯之后,上面的代碼的字節碼是這樣:

我們主要關注下面部分,字節碼指令對應的含義我也在后面注釋一下:

  public static void main(java.lang.String[]);
    Code:
       0: iconst_1 //將int型的1推送至棧頂
       1: iconst_0 //將int型的0推送至棧頂
       2: idiv     //將棧頂兩int型數值相除並將結果壓入棧頂
       3: istore_1 //將棧頂int型數值存入第二個本地變量
       4: return   //從當前方法返回 void

別問我怎么知道字節碼的含義的,翻表就行了,這玩意誰背得住啊。

通過字節碼,好像也沒看出什么玄機來。

但是,你先記着這個樣子,馬上我給你表演一個變形:

public class MainTest {

    public static void main(String[] args) {
        try {
            int a = 1 / 0;
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

用 try-catch 把代碼包裹起來,捕獲一下異常。

再次用 javap 編譯之后,字節碼變成了這個樣子:

可以明顯的看到,字節碼發生了變化,至少它變長了。

主要還是關注我框起來的部分。

把兩種情況的字節碼拿來做個對比:

對比一下就很清楚了,加入 try-catch 之后,原有的字節碼指令一行不少。

沒有被框起來的,就是多出來的字節碼指令。

而多出來的這部分,其中有個叫做 Exception table 尤為明顯:

異常表,這個玩意,就是 JVM 拿來處理異常的。

至於這里每個參數的含義是什么,我們直接繞過網上的“二手”資料,到官網上找文檔:

https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.7.3

看起來英文很多,很有壓力,但是不要怕,有我呢,我挑關鍵的給你 say:

首先 start_pc、end_pc 是一對參數,對應的是 Exception table 里面的 from 和 to,表示異常的覆蓋范圍。

比如前面的 from 是 0 ,to 是 4,代表的異常覆蓋的字節碼索引就是這個范圍:

0: iconst_1 //將int型的1推送至棧頂
1: iconst_0 //將int型的0推送至棧頂
2: idiv     //將棧頂兩int型數值相除並將結果壓入棧頂
3: istore_1 //將棧頂int型數值存入第二個本地變量

有個細節,不知道你注意到了沒有。

范圍不包含 4,范圍區間是這樣的 [start_pc, end_pc)。

而至於為什么沒有包含 end_pc,這個就有點意思了。

拿出來講講。

The fact that end_pc is exclusive is a historical mistake in the design of the Java Virtual Machine: if the Java Virtual Machine code for a method is exactly 65535 bytes long and ends with an instruction that is 1 byte long, then that instruction cannot be protected by an exception handler. A compiler writer can work around this bug by limiting the maximum size of the generated Java Virtual Machine code for any method, instance initialization method, or static initializer (the size of any code array) to 65534 bytes.

不包含 end_pc 是 JVM 設計過程中的一個歷史性的錯誤。

因為如果 JVM 中一個方法編譯后的代碼正好是 65535 字節長,並且以一條 1 字節長的指令結束,那么該指令就不能被異常處理機制所保護。

編譯器作者可以通過限制任何方法、實例初始化方法或靜態初始化器生成的代碼的最大長度來解決這個錯誤。

上面就是官網的解釋,反正就是看的似懂非懂的。

沒關系,跑個例子就知道了:

當我代碼里面只有一個方法,且長度為 16391 行時,編譯出來的字節碼長度為 65532。

而通過前面的分析我們知道,一行 a=1/0 的代碼,會被編譯成 4 行字節碼。

那么只要我再加一行代碼,就會超出限制,這個時候再對代碼進行編譯,會出現什么問題呢?

看圖:

直接編譯失敗,告訴你代碼過長。

所以你現在知道了一個知識點:一個方法的長度,從字節碼層面來說是有限制的。但是這個限制算是比較的大,正常人是寫不出這樣長度的代碼的。

雖然這個知識點沒啥卵用,但是要是你在工作中真的碰到了一個方法長度成千上萬行,即使沒有觸發字節碼長度限制,我也送你一個字:快跑。

接着說下一個參數 handler_pc,對應的是 Exception table 里面的 target。

其實它非常好理解,就是指異常處理程序開始的那條指令對應的索引。

比如這里的 target 是 7 ,對應的就是 astore_1 指令:

也就是告訴 JVM,如果出異常了,請從這里開始處理。

最后,看 catch_type 參數,對應的是 Exception table 里面的 type。

這里就是程序捕獲的異常。

比如我把程序修改為這樣,捕獲三種類型的異常:

那么編譯后的字節碼對應的異常表所能處理的 type 就變成了這三個:

至於我這里為什么不能寫個 String 呢?

別問,問就是語法規定。

具體是啥語法規定呢?

就在異常表的這個地方:

編譯器會檢查該類是否是 Throwable 或 Throwable 的子類。

關於 Throwable、Exception、Error、RuntimeException 就不細說了,生成一個繼承關系圖給大家看就行了:

所以,上面的消息匯總一下:

  • from:可能發生異常的起始點指令索引下標(包含)
  • to:可能發生異常的結束點指令索引下標(不包含)
  • target:在from和to的范圍內,發生異常后,開始處理異常的指令索引下標
  • type:當前范圍可以處理的異常類信息

知道了異常表之后,可以回答這個問題了:異常怎么被拋出的?

JVM 通過異常表,幫我們拋出來的。

異常表里面有啥?

前面我說了,不再贅述。

異常表怎么用呢?

簡單描述一下:

1.如果出現異常了,JVM 會在當前的方法中去尋找異常表,查看是否該異常被捕獲了。
2.如果在異常表里面匹配到了異常,則調用 target 對應的索引下標的指令,繼續執行。

好,那么問題又來了。如果匹配不到異常怎么辦呢?

我在官網文檔的這里找到了答案:

https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-3.html#jvms-3.12

它的示例代碼是這樣的:

然后下面有這樣的一句描述:

意思就是如果拋出的值與 catchTwo 的任何一個 catch 子句的參數不匹配,Java虛擬機就會重新拋出該值,而不調用 catchTwo 的任何一個 catch 子句中的代碼。

什么意思?

說白了就是反正我處理不了,我會把異常扔給調用方。

這是編程常識,大家當然都知道。

但是當常識性的東西,以這樣的規范的描述展示在你面前的時候,感覺還是挺奇妙的。

當別人問你,為什么是這樣的調用流程的時候,你說這是規定。

當別人問你,規定在哪的時候,你能把官網文檔拿出來扔他臉上,指着說:就是這里。

雖然,好像沒啥卵用。

稍微特殊的情況

這一趴再簡單的介紹一下有 finally 的情況:

public class MainTest {
   public static void main(String[] args) {
       try {
           int a = 1 / 0;
       } catch (Exception e) {
           e.printStackTrace();
       } finally {
           System.out.println("final");
       }
   }
}

經過 javap 編譯后,異常表部分出現了三條記錄:

第一條認識,是我們主動捕獲的異常。

第二三條都是 any,這是啥玩意?

答案在這:

https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-3.html#jvms-3.13

主要看我畫線的地方:

一個帶有 finally 子句的 try 語句被編譯為有一個特殊的異常處理程序,這個異常處理程序可以處理在 try 語句中拋出的(any)任何異常。

所有,翻譯一下上面的異常表就是:

  • 如果 0 到 4 的指令之間發生了 Exception 類型的異常,調用索引為 15 的指令,開始處理異常。
  • 如果 0 到 4 的指令之間,不論發生了什么異常,都調用索引為 31 的指令(finally 代碼塊開始的地方)
  • 如果 15 到 20 的指令之間(也就是 catch 的部分),不論發生了什么異常,都調用索引為 31 的指令。

接着,我們把目光放到這一部分:

怎么樣,發現了沒?就問你神不神奇?

在源碼中,只在 finally 代碼塊出現過一次的輸出語句,在字節碼中出現了三次。

finally 代碼塊中的代碼被復制了兩份,分別放到了 try 和 catch 語句的后面。再配合異常表使用,就能達到 finally 語句一定會被執行的效果。

以后再也不怕面試官問你為什么 finally 一定會執行了。

雖然應該也沒有面試官會問這樣無聊的問題。

問起來了,就從字節碼的角度給他分析一波。

當然了,如果你非要給我抬個杠,聊聊 System.exit 的情況,就沒多大意義了。

最后,關於 finally,再討論一下這個場景:

public class MainTest {
    public static void main(String[] args) {
        try {
            int a = 1 / 0;
        } finally {
            System.out.println("final");
        }
    }
}

這個場景下,沒啥說的, try 里面拋出異常,觸發 finally 的輸出語句,然后接着被拋出去,打印在控制台:

如果我在 finally 里面加一個 return 呢?

可以看到,運行結果里面異常都沒有被拋出來:

為什么呢?

答案就藏在字節碼里面:

其實已經一目了然了。

右邊的 finally 里面有 return,並沒有 athrow 指令,所以異常根本就沒有拋出去。

這也是為什么建議大家不要在 finally 語句里面寫 return 的原因之一。

冷知識

再給大家補充一個關於異常的冷知識吧。

還是上面這個截圖。你有沒有覺得有一絲絲的奇怪?

夜深人靜的時候,你有沒有想過這樣的一個問題:

程序里面並沒有打印日志的地方,那么控制台的日子是誰通過什么地方打印出來的呢?

是誰干的?

這個問題很好回答,猜也能猜到,是 JVM 幫我們干的。

什么地方?

這個問題的答案,藏在源碼的這個地方,我給你打個斷點跑一下,當然我建議你也打個斷點跑一下:

java.lang.ThreadGroup#uncaughtException

而在這個地方打上斷點,根據調用堆棧順藤摸瓜可以找到這個地方:

java.lang.Thread#dispatchUncaughtException

看方法上的注釋:

This method is intended to be called only by the JVM.

翻譯過來就是:這個方法只能由 JVM 來調用。

既然源碼里面都這樣說了,我們可以去找找對應的源碼嘛。

https://hg.openjdk.java.net/jdk7u/jdk7u/hotspot/file/5b9a416a5632/src/share/vm/runtime/thread.cpp

在 openJdk 的 thread.cpp 源碼里面確實是找到了該方法被調用的地方:

而且這個方法還有個有意思的用法。

看下面的程序和輸出結果:

我們可以自定義當前線程的 UncaughtExceptionHandler,在里面做一些兜底的操作。

有沒有品出來一絲絲全局異常處理機制的味道?

好了,再來最后一個問題:

我都這樣問了,那么答案肯定是不一定的。

你就想想,發揮你的小腦袋使勁的想,啥情況下 try 里面的代碼拋出了異常,外面的 catch 不會捕捉到?

來,看圖:

沒想到吧?

這樣處理一下,外面的 catch 就捕捉不到異常了。

是不是很想打我。

別慌,上面這樣套娃多沒意思啊。

你再看看我這份代碼:

public class MainTest {
    public static void main(String[] args) {
        try {
            ExecutorService threadPool = Executors.newFixedThreadPool(1);
            threadPool.submit(()->{
               int a=1/0;
            });
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

你直接拿去執行,控制台不會有任何的輸出。

來看動圖:

是不是很神奇?

不要慌,還有更絕的。

把上面的代碼從 threadPool.submit 修改為 threadPool.execute 就會有異常信息打印出來了:

但是你仔細看,你會發現,異常信息雖然打印出來了,但是也不是因為有 catch 代碼塊的存在。

具體是為啥呢?

參見這篇文章,我之前詳細講過的:《關於多線程中拋異常的這個面試題我再說最后一次!》

最后說一句

好了,看到了這里安排個關注吧。

感謝您的閱讀,我堅持原創,十分歡迎並感謝您的關注。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM