Java 代碼中通過使用 try-catch-finally 塊來對異常進行捕獲/處理。但是對于 JVM 來說,是如何處理 try/catch 代碼塊與異常的呢?
實際上 Java代碼在進行編譯時,編譯器會在代碼后附加一個異常表,以實現try塊出現異常后能進入對應的異常處理程序執行。
- 如果在方法執行期間拋出異常,Java 虛擬機會在異常表中搜索匹配的條目。
- 如果當前PC程序計數器在條目指定的范圍內,并且拋出的異常類是條目指定的異常類(或者是指定異常類的子類),則異常表條目匹配。
- Java 虛擬機按照條目在表中出現的順序搜索異常表。當找到第一個匹配項時,Java 虛擬機將程序計數器設置為新的 pc 偏移位置并在那里繼續執行。如果未找到匹配項,Java 虛擬機將彈出當前堆棧幀并重新拋出相同的異常。
JVM對異常表的約定
在 JVM 規范中,對 Exception table 有以下幾個約定:
1.
Exception table 的結構:
在 JVM 規范中,Exception table 被定義為一張表格,由多行記錄組成。每一行記錄用于描述一個代碼塊,其中包含了代碼塊的起始地址、結束地址、異常處理程序的程序計數器(PC)值以及異常類類型。
2.
Exception table 的字節碼偏移值:
在 JVM 規范中,Exception table 中的每個記錄都包含了字節碼偏移值(Bytecode offset)和行號信息,這些信息用于告訴 JVM 在哪個字節碼偏移值處發生了異常。這一信息在調試 Java 代碼時十分有用。
3.
Exception table 列表的順序:
在 JVM 規范中,Exception table 中的代碼塊記錄必須按照字節碼地址從低到高排序。這個順序確保了在 JVM 查找代碼塊和異常處理程序時能夠正確有效。
4.
Exception table 的匹配邏輯:
在 JVM 規范中,當 JVM 發生異常時,會遍歷 Exception table 列表,按行依次匹配當前 PC 計數器和代碼塊的起始和結束字節碼偏移值,以確定當前發生異常所在的代碼塊和異常處理程序。
Exception table 是 JVM 中非常重要的數據結構之一,為 JVM 提供了一種有效、可靠的異常處理機制。在 Java 編程中,Exception table 可以幫助開發者更好更快地調試和解決問題,保證 Java 程序的可靠性和穩定性。
模擬JVM的執行過程
class Ball extends Exception {
}
class Pitcher {
private static Ball ball = new Ball();
static void playBall() {
int i = 0;
while (true) {
try {
if (i % 4 == 3) {
throw ball;
}
++i;
}
catch (Ball b) {
i = 0;
}
}
}
}
編譯后通過javap -v進行反編譯,找到playBall Java方法對應的Code指令:
0 iconst_0 // Push constant 0
1 istore_0 // Pop into local var 0: int i = 0;
// The try block starts here (see exception table, below).
2 iload_0 // Push local var 0
3 iconst_4 // Push constant 4
4 irem // Calc remainder of top two operands
5 iconst_3 // Push constant 3
6 if_icmpne 13 // Jump if remainder not equal to 3: if (i % 4 == 3) {
// Push the static field at constant pool location #5,
// which is the Ball exception itching to be thrown
9 getstatic #5 <Field Pitcher.ball LBall;>
12 athrow // Heave it home: throw ball;
13 iinc 0 1 // Increment the int at local var 0 by 1: ++i;
// The try block ends here (see exception table, below).
16 goto 2 // jump always back to 2: while (true) {}
// The following bytecodes implement the catch clause:
19 pop // Pop the exception reference because it is unused
20 iconst_0 // Push constant 0
21 istore_0 // Pop into local var 0: i = 0;
22 goto 2 // Jump always back to 2: while (true) {}
Exception table:
from to target type
2 16 19 <Class Ball>
對于每個 catch 塊捕獲的異常,異常表都有一個條目。每個條目有四個信息:
- from:可能發生異常的起始點指令索引下標(包含)
- to:可能發生異常的結束點指令索引下標(不包含)
- target:在from和to的范圍內,發生異常后,開始處理異常的指令索引下標
- type:當前范圍可以處理的異常類信息
基于異常表條目,可以判斷出
- try塊,對應PC偏移范圍的 2~15
- catch塊,對應PC偏移范圍的 19~21
異常表中,覆蓋范圍區間是左開右閉 [from, to),為什么沒有包含右邊界,這個就有點意思了
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.
start_pc、end_pc 是一對參數,對應的是 Exception table 里面的 from 和 to,表示異常的覆蓋范圍。
不包含 end_pc 是 JVM 設計過程中的一個歷史性的錯誤。
在Java中,一個方法的長度從字節碼層面來說是有限制的。具體來說,Java虛擬機規范定義了一個方法的字節碼長度不能超過65535個字節,也就是64KB。
這個限制是由于Java虛擬機規范中方法表結構的設計所決定的。方法表結構中有一個字段code_length用于表示方法的字節碼長度,這個字段是一個16位的無符號整數,因此最大值為65535。
因為如果 JVM 中一個方法編譯后的代碼正好是 65535 字節長,并且以一條 1 字節長的指令結束,那么該指令就不能被異常處理機制所保護。存在邊界問題,因此異常的覆蓋范圍定為左開右閉。
示例中,Pitcher#playball方法會一直循環;每經過四次循環,playball 就會拋出Ball并catch住。
因為 try 塊和 catch 子句都在while(true) 循環中,所以永遠不會停止。
局部變量i
從 0 開始,每次循環遞增。當if語句為 時true,即每次i
等于 3 時都會拋出異常。
Java 虛擬機檢查異常表,發現確實有匹配的條目。條目的有效范圍是從 2 到 15,包括了在 pc 偏移量 12 處拋出異常。條目對應能處理的異常類型class 是Ball,拋出異常的 class 也是Ball。鑒于這種完美匹配,Java 虛擬機將拋出的異常對象壓入堆棧,并在 pc 偏移量 19 處繼續執行。
catch 子句只是將int i
重置為 0,然后循環重新開始。