第2章 JVM 异常处理机制源码级剖析
2.1 字节码层面:athrow 指令
throw 语句在编译后对应一条 JVM 字节码指令 athrow。它的执行语义(依据 JVM 规范):
- 从操作数栈弹出一个对象引用(必须是
Throwable或其子类,否则会在类校验阶段报VerifyError)。 - 如果该引用为
null,JVM 在此处自动创建一个NullPointerException并抛出(也就是说throw null;会得到的是 NPE,而不是"抛出 null"这种未定义行为)。 - 清空当前帧的操作数栈,只保留这个异常引用。
- 在当前方法的异常表 (Exception Table)中查找是否有能够处理该异常类型、且 PC(程序计数器)落在 try 范围内的条目;找到则跳转到 handler 的字节码偏移量继续执行;找不到则弹出当前栈帧 (方法直接结束,不会执行 return),把异常继续向调用者传播,这个过程叫栈展开(Stack Unwinding)。
2.2 异常表(Exception Table)结构
每个方法的字节码除了指令序列本身,还附带一张异常表,try-catch 结构在编译期会被翻译成这张表里的若干条目,而不是真的有"分支跳转判断异常类型"这样的运行时指令。异常表的每一行包含四个字段:
| 字段 | 含义 |
|---|---|
start_pc |
try 块的起始字节码偏移量 |
end_pc |
try 块的结束字节码偏移量(不含) |
handler_pc |
匹配时跳转到的 catch 块起始偏移量 |
catch_type |
该 catch 处理的异常类型(常量池索引,0 表示 finally 块,匹配任何异常) |
以下面这段代码为例:
java
public int demo(int a, int b) {
int result;
try {
result = a / b;
} catch (ArithmeticException e) {
result = -1;
} finally {
System.out.println("finally");
}
return result;
}
用 JDK 21 实际编译并执行 javap -c -v 反汇编(以下为真实输出,未做任何简化改写):
yaml
public int demo(int, int);
Code:
0: iload_1
1: iload_2
2: idiv
3: istore_3
4: getstatic #7 // System.out
7: ldc #13 // "finally"
9: invokevirtual #15 // println
12: goto 43
15: astore 4
17: iconst_m1
18: istore_3
19: getstatic #7
22: ldc #13
24: invokevirtual #15
27: goto 43
30: astore 5
32: getstatic #7
35: ldc #13
37: invokevirtual #15
40: aload 5
42: athrow
43: iload_3
44: ireturn
Exception table:
from to target type
0 4 15 Class java/lang/ArithmeticException
0 4 30 any
15 19 30 any
30 32 30 any
逐条解读:
[0,4)对应a / b这条idiv指令。如果抛出ArithmeticException,跳转到偏移量 15 (catch 块起点,astore 4把异常对象存入局部变量槽 4,随后result = -1)。- 三条
type = any(即catch_type = 0)的条目,分别覆盖了[0,4)(try 块本身)、[15,19)(catch 块的赋值逻辑部分)、[30,32)(紧跟在 finally 打印语句之后、自动生成的一段"异常兜底重抛"代码)。可以清楚看到,offset 4~12 和 19~27 分别是 try 块和 catch 块各自"正常结束路径"上的 finally 代码副本(两处都重复出现了getstatic/ldc/invokevirtual三条打印指令),而 offset 32~42 是任意其他异常穿透时 再执行一次 finally 打印后用athrow把异常重新抛出。这印证了 2.2 节的结论:finally 块的字节码被原样复制了三份,分别嵌入到 try 正常结束、catch 正常结束、以及未被捕获异常穿透这三条退出路径上,而不是运行时动态判断"是否该执行 finally"。
2.3 try-with-resources 的字节码本质
JDK 7 引入的 try-with-resources 语法糖,编译后本质上是嵌套的 try-finally,并且引入了一个特殊机制处理"关闭资源本身也抛异常"的情况:
java
try (AutoCloseable r = open()) {
use(r);
}
编译后近似等价于:
java
AutoCloseable r = open();
Throwable primaryException = null;
try {
use(r);
} catch (Throwable t) {
primaryException = t;
throw t;
} finally {
if (r != null) {
if (primaryException != null) {
try {
r.close();
} catch (Throwable suppressed) {
primaryException.addSuppressed(suppressed); // 关键:不会覆盖原始异常,而是作为"被抑制异常"附加
}
} else {
r.close();
}
}
}
addSuppressed() / getSuppressed() 是 JDK 7 同期引入的机制,专门解决"业务逻辑抛了异常 A,close() 又抛了异常 B,到底该让调用者看到哪个"的问题------答案是:A 作为主异常抛出,B 作为 suppressed 异常附加在 A 上 ,打印堆栈时会看到额外的 Suppressed: ... 段落,两者都不会丢失。这比早期手写 finally { conn.close(); } (close 抛出的异常会直接覆盖原始异常,导致真正的业务异常信息丢失)要优越得多。
2.4 finally 中 return 吞掉异常的经典陷阱
java
public int demo() {
try {
throw new RuntimeException("原始异常");
} finally {
return 1; // 危险:finally 块的 return 会导致 try 块中抛出的异常被直接丢弃
}
}
// demo() 正常返回 1,RuntimeException 消失得无影无踪,且没有任何提示
原理 :字节码层面,finally 块中的 return/throw/break/continue 会导致该分支"提前退出"当前方法帧,此时正在传播中的异常引用会被直接丢弃------因为异常传播的本质是"弹出栈帧继续向上找异常表",而 return 指令的语义是"正常返回",两者互斥,return 会覆盖异常传播这条路径。这是唯一一种"异常被无声吞掉且没有任何编译警告"的语言层面陷阱 ,需要在 code review 中特别关注 finally 块内是否有 return/throw。
现代 IDE(IntelliJ IDEA)和静态检查工具(SpotBugs 的 RCN_REDUNDANT_NULLCHECK/Finally block 相关规则)都会对此发出警告。
2.5 栈展开(Stack Unwinding)与堆栈跟踪的关系
要注意区分两个容易混淆的概念:
- 栈展开(Stack Unwinding):JVM 执行层面,异常向上传播时依次弹出调用栈帧的过程,是异常处理机制的运行时行为。
- 堆栈跟踪(Stack Trace) :
Throwable对象内部记录的StackTraceElement[]数组,是异常对象被创建那一刻 (构造函数中调用fillInStackTrace())对当前调用栈的一次"快照记录"。
关键点:堆栈跟踪在异常对象 new 出来的那一刻就已经固定 ,之后无论异常被传播、被重新 throw(如 catch (Exception e) { throw e; }),堆栈信息都不会更新 ,看到的永远是异常最初创建位置的调用链。这也是为什么"在 catch 块里 throw e; 重新抛出同一个异常对象"和"throw new XxxException(e) 包装新异常"在堆栈信息上表现完全不同------前者堆栈不变,后者会有一条新的堆栈起点加上 Caused by 链。