天天看點

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

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

比如下面這位讀者:

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

他是看了我《神了!異常資訊突然就沒了?》這篇文章後産生的疑問。

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

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

害,真是暖男石錘了。

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

異常怎麼被抛出的。

先上一個簡單代碼片段:

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

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

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

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

這是知其然。

那麼是以然呢?

是以然,就藏在代碼背後的位元組碼裡面。

通過 javap 編譯之後,上面的代碼的位元組碼是這樣:

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

我們主要關注下面部分,位元組碼指令對應的含義我也在後面注釋一下:

  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 編譯之後,位元組碼變成了這個樣子:

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

可以明顯的看到,位元組碼發生了變化,至少它變長了。

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

把兩種情況的位元組碼拿來做個對比:

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

對比一下就很清楚了,加入 try-catch 之後,原有的位元組碼指令一行不少。

沒有被框起來的,就是多出來的位元組碼指令。

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

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

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

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

https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.7.3
卷向位元組碼-Java異常到底是怎麼被處理的?

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

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

首先 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 位元組長的指令結束,那麼該指令就不能被異常處理機制所保護。

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

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

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

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

當我代碼裡面隻有一個方法,且長度為 16391 行時,編譯出來的位元組碼長度為 65532。

而通過前面的分析我們知道,一行

a=1/0

的代碼,會被編譯成 4 行位元組碼。

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

看圖:

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

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

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

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

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

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

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

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

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

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

最後,看 catch_type 參數,對應的是 Exception table 裡面的 type。

這裡就是程式捕獲的異常。

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

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

那麼編譯後的位元組碼對應的異常表所能處理的 type 就變成了這三個:

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

至于我這裡為什麼不能寫個 String 呢?

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

别問,問就是文法規定。

具體是啥文法規定呢?

就在異常表的這個地方:

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

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

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

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

是以,上面的消息彙總一下:

  • 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

它的示例代碼是這樣的:

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

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

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

意思就是如果抛出的值與 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 編譯後,異常表部分出現了三條記錄:

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

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

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

答案在這:

https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-3.html#jvms-3.13
卷向位元組碼-Java異常到底是怎麼被處理的?

主要看我畫線的地方:

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

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

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

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

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

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

在源碼中,隻在 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 的輸出語句,然後接着被抛出去,列印在控制台:

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

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

可以看到,運作結果裡面異常都沒有被抛出來:

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

為什麼呢?

答案就藏在位元組碼裡面:

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

其實已經一目了然了。

右邊的 finally 裡面有 return,并沒有 athrow 指令,是以異常根本就沒有抛出去。

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

冷知識

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

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

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

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

程式裡面并沒有列印日志的地方,那麼控制台的日子是誰通過什麼地方列印出來的呢?

是誰幹的?

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

什麼地方?

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

java.lang.ThreadGroup#uncaughtException
卷向位元組碼-Java異常到底是怎麼被處理的?

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

java.lang.Thread#dispatchUncaughtException
卷向位元組碼-Java異常到底是怎麼被處理的?

看方法上的注釋:

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 源碼裡面确實是找到了該方法被調用的地方:

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

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

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

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

我們可以自定義目前線程的

UncaughtExceptionHandler

,在裡面做一些兜底的操作。

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

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

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

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

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

來,看圖:

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

沒想到吧?

這樣處理一下,外面的 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();
        }
    }
}
           

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

來看動圖:

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

是不是很神奇?

不要慌,還有更絕的。

把上面的代碼從

threadPool.submit

修改為

threadPool.execute

就會有異常資訊列印出來了:

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

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

具體是為啥呢?

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

最後說一句

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

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