第2章 JVM 异常处理机制源码级剖析

第2章 JVM 异常处理机制源码级剖析

2.1 字节码层面:athrow 指令

throw 语句在编译后对应一条 JVM 字节码指令 athrow。它的执行语义(依据 JVM 规范):

  1. 从操作数栈弹出一个对象引用(必须是 Throwable 或其子类,否则会在类校验阶段报 VerifyError)。
  2. 如果该引用为 null,JVM 在此处自动创建一个 NullPointerException 并抛出(也就是说 throw null; 会得到的是 NPE,而不是"抛出 null"这种未定义行为)。
  3. 清空当前帧的操作数栈,只保留这个异常引用
  4. 在当前方法的异常表 (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~1219~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 链。


相关推荐
Alan_751 小时前
文件上传接口设计-从单文件到分片上传
后端
newerp1 小时前
Go语言程序结构 —— 变量、声明与零值机制
后端
SamDeepThinking1 小时前
现场重构一段代码,展示什么叫「好代码,都在表达自己的意图」
java·后端·程序员
Conan在掘金1 小时前
ArkTS 进阶之道(16):@Styles 样式复用边界——为啥样式不能当对象返
后端
Conan在掘金2 小时前
ArkTS 进阶之道(15):@BuilderParam 组件参数化边界——为啥 @Builder 能当参数传
后端
IT_陈寒2 小时前
Redis踩了个大坑,原来DEL命令也会卡住整个实例
前端·人工智能·后端
zhiSiBuYu05173 小时前
Flask 请求与响应新手实战指南
后端·python·flask
卷无止境3 小时前
Python装饰器:一层糖衣包裹的函数魔法
后端·python
卷无止境3 小时前
Python的Lambda表达式——不起名字的函数也能干大事
后端·python