卷向位元組碼-Java異常到底是怎麼被處理的?

why技術發表於2021-08-10

你好呀,我是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 程式碼塊的存在。

具體是為啥呢?

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

最後說一句

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

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

相關文章