柯儿的天空头像
关注
【DIY系列:Java虚拟机】第63篇:异常处理概述——JVM 怎么“接住“异常封面图

【DIY系列:Java虚拟机】第63篇:异常处理概述——JVM 怎么“接住“异常

上一篇【第62篇】本地方法调用测试与总结——jvmgo 的能力跃迁
下一篇【第64篇】异常抛出与异常处理表——athrow 和 ExceptionHandler


摘要

从第 5 章到现在,jvmgo 处理异常的方式一直是 panic——抛一个 Go 的 panic,打印堆栈,程序退出。

这意味着:

try {
    int x = Integer.parseInt("abc");
} catch (NumberFormatException e) {
    System.out.println("格式错误");      // ← 永远执行不到
}

try-catch 在 jvmgo 里完全无效。

第 10 章来解决这个问题——这是 jvmgo 的最后一个功能模块,也是让它从"能跑"变成"能正确处理错误"的关键。

这一篇先把概念理清楚:

  1. Checked vs Unchecked——谁强制你处理,谁不强制(注意:这是 Java 语言规则,不是 JVM 规范)
  2. 异常的三种来源——JVM 抛的 Error、指令抛的 RuntimeException、代码主动 throw 的
  3. throw 语句编译成什么——new + dup + invokespecial + athrow
  4. 异常对象不普通——构造函数里藏着一个 fillInStackTrace() 本地方法

一、Java 异常的分类

1.1 继承体系

                    Throwable
                        │
        ┌───────────────┴───────────────┐
        │                               │
      Error                         Exception
        │                               │
   ┌────┴────┐                    ┌─────┴──────────────┐
   │         │                    │                    │
OutOfMemory  StackOverflow    IOException        RuntimeException
   Error       Error               │                    │
                              FileNotFound        ┌─────┼─────┐
                                Exception         │     │     │
                                            NullPointer  Index  ClassCast
                                            Exception  OutOfBounds  Exception
                                                        Exception

所有异常最终继承自 java.lang.Throwable。

1.2 Checked vs Unchecked

书里的定义:

异常可以分为两类:Checked 异常和 Unchecked 异常。

  • Unchecked 异常包括 java.lang.RuntimeException、java.lang.Error 以及它们的子类
  • 其他异常都是 Checked 异常
Throwable
  ├── Error          ──── Unchecked(不需要声明)
  │     ├── OutOfMemoryError
  │     ├── StackOverflowError
  │     └── NoClassDefFoundError
  │
  └── Exception
        ├── IOException ── Checked(必须处理或声明)
        ├── ClassNotFoundException ── Checked
        ├── SQLException ── Checked
        │
        └── RuntimeException ──── Unchecked
              ├── NullPointerException
              ├── IndexOutOfBoundsException
              ├── ClassCastException
              ├── ArithmeticException
              └── NumberFormatException

规则:

如果一个方法有可能导致 Checked 异常抛出,则该方法要么需要捕获该异常并妥善处理,要么必须把该异常列在自己的 throws 子句中,否则无法通过编译。Unchecked 异常没有这个限制。

// Checked:必须处理或声明
public void readFile() throws IOException {      // ✓ 声明
    FileReader r = new FileReader("x.txt");
}
public void readFile2() {
    try { new FileReader("x.txt"); }
    catch (IOException e) { }                    // ✓ 捕获
}
public void readFile3() {
    new FileReader("x.txt");                     // ✗ 编译错误
}

// Unchecked:随意
public void f() {
    int[] a = null;
    a[0] = 1;                                    // ✓ 编译通过,运行时 NPE
}

1.3 一个极其重要的澄清

书里特别加了一句:

请注意,Java 虚拟机规范并没有这个规定,这只是 Java 语言的语法规则。

这句话是本章的"题眼",值得展开:

层面Checked/Unchecked 的区分
Java 语言有。编译器强制检查 throws 子句
class 文件几乎没有。Code 属性里的异常表不区分,方法的 Exceptions 属性只是"我可能抛这些"的一个注释
JVM完全不区分。JVM 从来不检查一个异常是不是被声明过

证据:JVM 规范里有一句著名的话:

The Java Virtual Machine does not require that a method declare the exceptions it may throw.

也就是说,你可以用字节码工具生成一个抛出未声明 Checked 异常的方法,JVM 会正常执行它。

一个具体的例子——Java 的"偷偷抛出 Checked 异常":

// 用泛型技巧绕过编译器的 Checked 异常检查
@SuppressWarnings("unchecked")
public static <T extends Throwable> void sneakyThrow(Throwable t) throws T {
    throw (T) t;
}

public void f() {
    sneakyThrow(new IOException("surprise!"));   // 编译通过!没声明 throws
}

这在 JVM 层面完全合法,因为 throw 指令根本不检查异常类型。

所以:第 10 章实现异常处理时,我们不需要(也不能)区分 Checked 和 Unchecked——JVM 只管抛和接,类型检查是 javac 的事。

这和第 060 篇"自动装箱是纯语法糖,JVM 不知道"是同一个套路:Java 语言的很多约束在 JVM 层面并不存在。

1.4 为什么 Java 要设计 Checked 异常

这是个有争议的设计。支持者认为它强制错误处理(Java 是唯一有 Checked 异常的主流语言),反对者说它污染代码签名。

《Effective Java》第 70-77 条给出的建议:

  • 可恢复的情况用 Checked 异常(调用方应该处理)
  • 编程错误用 Unchecked 异常(比如 NPE、越界,是 bug,应该修代码而不是 catch)
  • 不要用 Error(那是 JVM 层面的问题)

现代语言(Kotlin、C#、Scala、Rust 的 Result)大多放弃了 Checked 异常——Kotlin 里所有 Java 的 Checked 异常都变成 Unchecked。

二、异常的三种来源

书里分得很清楚:

2.1 JVM 抛出的 Error

当 Java 虚拟机在运行过程中遇到比较严重的问题时,会抛出 java.lang.Error 的某个子类,如 StackOverflowError、OutOfMemoryError 等。程序一般无法从这种异常里恢复,所以在代码中通常也不必关心这类异常。

Error触发场景
StackOverflowError栈深度超限(无限递归)
OutOfMemoryError堆/元空间耗尽
NoClassDefFoundError类加载失败
ExceptionInInitializerError<clinit> 抛异常
UnsatisfiedLinkError本地方法找不到(第 058 篇见过)
VerifyError字节码验证失败

注意 UnsatisfiedLinkError 是 Error 不是 Exception —— 第 058 篇 jvmgo 抛的就是它。

2.2 指令抛出的 RuntimeException

一部分指令在执行过程中会导致 Java 虚拟机抛出 java.lang.RuntimeException 的某个子类,如 NullPointerException、IndexOutOfBoundsException 等。这类异常一般是代码中的 bug 导致的,需要格外注意。

第 5~9 章我们实现了很多这样的检查:

指令抛的异常篇章
idiv / iremArithmeticException(除以零)第 036 篇
getfield / putfieldNullPointerException第 047 篇
new / arraylengthNegativeArraySizeException第 054 篇
<t>aload / <t>astoreNullPointerException / ArrayIndexOutOfBoundsException第 054 篇
checkcastClassCastException第 047 篇
arraycopyNullPointerException / ArrayStoreException / IndexOutOfBoundsException第 061 篇

但它们的实现都是 panic(...),不是真正的抛异常对象。第 10 章的一个重要课题就是:把这些 panic 改成真正抛异常对象。

(书里其实没有全部改——只实现了 athrow 指令和栈展开,idiv 除零之类仍然是 panic。这是本书的取舍。)

2.3 代码主动抛出的

在代码中抛出和处理异常是由 athrow 指令和方法的异常处理表配合完成的,本章将重点讨论这一点。

throw new IllegalArgumentException("参数错");

这是第 10 章的主角。

三、throw 语句编译成什么

3.1 规范里的例子

void cantBeZero(int i) {
    if (i == 0) {
        throw new TestExc();
    }
}

编译后:

 0  iload_1         // 把参数 1(i)推入操作数栈顶
 1  ifne 12         // 如果 i 不等于 0,直接执行 return 指令
 4  new #2          // 创建 TestExc 实例,把引用推入操作数栈顶
 7  dup             // 复制 TestExc 实例引用
 8  invokespecial #3  // 调用 TestExc 构造函数
11  athrow          // 抛出异常
12  return          // 方法返回

athrow 是唯一陌生的指令,其余都是老朋友:

new          → 分配对象(第 6 章)
dup          → 复制引用(第 5 章)
invokespecial → 调构造函数(第 7 章)
athrow       → 抛异常(第 10 章)← 唯一的新东西

注意 athrow 之后没有 return —— 因为 athrow 不会正常返回,它要么跳到 catch 块,要么终止方法。javac 知道这一点,所以不会生成不可达代码。

athrow 的操作数:一个异常对象引用,从操作数栈弹出。和 dup / pop 一样是"隐式操作数"。

3.2 异常对象似乎只是普通对象?

书里提了一个很好的问题:

从字节码来看,异常对象似乎也只是普通的对象,通过 new 指令创建,然后调用构造函数进行初始化。这是真的吗?

答案:不是。 看 Throwable 的构造链:

// java.lang.Exception
public Exception(String message) {
    super(message);
}

// java.lang.Throwable
public Throwable(String message) {
    fillInStackTrace();            // ← 关键!
    detailMessage = message;
}

public synchronized Throwable fillInStackTrace() {
    if (stackTrace != null || backtrace != null) {
        fillInStackTrace(0);       // ← 调用本地方法
        stackTrace = UNASSIGNED_STACK;
    }
    return this;
}

private native Throwable fillInStackTrace(int dummy);     // ← 本地方法!

所以每次 new 一个异常对象,都会:

  1. 调构造函数
  2. 构造函数调 fillInStackTrace()(Java 方法)
  3. fillInStackTrace() 调 fillInStackTrace(int)(本地方法)
  4. 本地方法抓取当前 JVM 栈,存起来

这就是为什么每个 Java 异常的堆栈轨迹(stack trace)那么准确 —— 它是在异常对象创建时就抓好的,不是抛出时抓的。

一个实用的推论:

Exception e = new Exception("error");    // ← 栈在这里就被抓好了
// ...
throw e;                                  // 抛出时的栈不影响

// 所以这样写是错的(栈是创建时的,不是抛出时的)
static Exception cached = new Exception("cached");
void f() { throw cached; }                // 栈轨迹会是类初始化时的!

3.3 为什么 fillInStackTrace 是本地方法

因为 Java 代码访问不到 JVM 栈。

fillInStackTrace() 是 Java 方法,但它需要遍历当前线程的栈帧——这是 JVM 的内部数据结构,Java 语言没有 API 能访问。

所以必须:

fillInStackTrace()          ← Java 方法,处理状态判断
    └──▶ fillInStackTrace(int)   ← 本地方法,真正抓栈

书里说:

fillInStackTrace() 是用 Java 写的,必须借助另外一个本地方法才能访问 Java 虚拟机栈,这个方法就是重载后的 fillInStackTrace(int) 方法。

这又是一个"Java 做不到,所以必须本地方法"的例子——和第 061 篇的 arraycopy、floatToRawIntBits 是同一个理由。

3.4 第 10 章先给个空实现

书里的处理很务实:

在 10.5 节中,我们会真正实现这个方法,这里先给它一个空的实现。

// ch10/native/java/lang/Throwable.go
package lang

import "jvmgo/ch10/native"
import "jvmgo/ch10/rtda"
import "jvmgo/ch10/rtda/heap"

func init() {
    native.Register("java/lang/Throwable", "fillInStackTrace",
        "(I)Ljava/lang/Throwable;", fillInStackTrace)
}

// private native Throwable fillInStackTrace(int dummy);
func fillInStackTrace(frame *rtda.Frame) {
    // 在 10.5 节实现
}

为什么必须注册一个空实现,而不是干脆不注册?

因为不注册会抛 UnsatisfiedLinkError(第 058 篇的机制)—— 一旦 new 任何异常对象就会崩。而 UnsatisfiedLinkError 本身就是个 Error…… 恶性循环。

所以先注册个空的保证能跑,10.5 节再填内容。这是渐进式开发的标准做法。

注意返回类型是 Ljava/lang/Throwable; —— 因为 fillInStackTrace(int) 返回 this。jvmgo 的空实现什么都不做,返回值由后面的 areturn 指令处理(会从操作数栈取——但现在栈是空的,会取到垃圾值)。

严格来说这是个 bug,但因为调用方 fillInStackTrace() 里 fillInStackTrace(0); 的返回值被丢弃了(是语句不是赋值),所以不影响。10.5 节的实现会正确地把 this 压栈。

四、finally 的实现:一段历史

书里 10.1 节最后:

在 Java 6 之前,Oracle 的 Java 编译器使用 jsr、jsr_w 和 ret 指令来实现 finally 子句。从 Java 6 开始,已经不再使用这些指令,本章不讨论这三条指令。

这是个有意思的历史遗留。

jsr(jump subroutine)的实现方式是:

try {
    foo();
} finally {
    bar();
}

Java 5 及之前:

 0  aload_0
 1  invokevirtual foo
 4  jsr 14              // 跳到 finally 代码(14)
 7  goto 20             // 正常结束
10  astore_1            // 异常处理入口
11  jsr 14              // 也要跳到 finally
14  astore_2            // jsr 的返回地址存在这里
15  aload_0
16  invokevirtual bar
19  ret 2               // 跳回返回地址
20  return

问题:jsr 是"子程序调用",它会让控制流分析变得极其复杂——因为一个 jsr 可能从多个地方跳来,返回地址是运行时的值。这让 class 文件验证器(StackMapTable 验证)难以静态分析。

Java 6 之后改用代码复制:

 0  aload_0
 1  invokevirtual foo
 4  aload_0             // ← finally 代码被复制一份
 5  invokevirtual bar   // (正常路径)
 8  goto 18
11  astore_1            // 异常处理入口
12  aload_0             // ← finally 代码又被复制一份
13  invokevirtual bar   // (异常路径)
16  aload_1
17  athrow              // 重新抛出
18  return

代价是字节码变大(finally 代码被复制 N 份),收益是控制流变得简单,验证器好写了。

第 5 章讲 jsr / ret 时说过它们"已废弃",这里就是原因。jvmgo 不实现它们——反正 Java 6+ 的 javac 也不生成了。

五、第 10 章的实现路线图

10.1 异常处理概述          ← 本篇(063)
     ├─ Checked / Unchecked
     ├─ 三种异常来源
     └─ throw 的字节码 + fillInStackTrace

10.2 异常抛出              ← 本篇(063)
     └─ Throwable.fillInStackTrace 的空实现

10.3 异常处理表            ← 第 064 篇
     ├─ Method.exceptionTable 字段
     ├─ ExceptionHandler{ startPc, endPc, handlerPc, catchType }
     ├─ newExceptionTable() 从 class 文件转换
     ├─ catchType == 0 表示 catch-all(finally)
     └─ findExceptionHandler() 查找逻辑

10.4 实现 athrow 指令      ← 第 065 篇
     ├─ ATHROW.Execute()
     ├─ findAndGotoExceptionHandler():沿栈查找 + 清空操作数栈
     ├─ handleUncaughtException():打印栈轨迹
     └─ OperandStack.Clear() / Thread.ClearStack()

10.5 Java 虚拟机栈信息     ← 第 066 篇
     ├─ StackTraceElement{ fileName, className, methodName, lineNumber }
     ├─ fillInStackTrace() 的真正实现
     ├─ distanceToObject() 计算要跳过的帧数
     ├─ Thread.GetFrames() / Stack.getFrames()
     └─ Class.SourceFile() / Method.GetLineNumber()

10.6 测试                  ← 第 066 篇
     └─ ParseIntTest

四步走,从数据结构到指令到栈信息,最后验收。

六、一个重要的认知:异常处理的本质

把第 10 章要做的所有事抽象成一句话:

异常处理 = 栈展开(stack unwinding)+ 查表跳转

athrow 抛出
    │
    ▼
while (栈不空) {
    frame := 当前帧
    handlerPC := frame.Method().FindExceptionHandler(异常类, pc)
    if (handlerPC > 0) {
        清空 frame 的操作数栈
        把异常对象压入栈顶
        frame.nextPC = handlerPC      ← 跳转到 catch 块
        return true                    ← 处理成功
    }
    弹出 frame                          ← 栈展开:丢弃这一层
}
// 栈空了还没找到
打印栈轨迹,程序终止

三个关键点:

  1. 异常对象在操作数栈上传递——从抛出点传到 catch 块,靠的是"清空栈 + 压入异常对象"
  2. 跳转靠 nextPC——和第 5 章的 Branch() 一样,只是跳转目标来自异常表
  3. 找不到就栈展开——逐帧弹出,直到找到能处理的帧,或者栈空

这个机制和第 7 章的方法返回有几分神似:

方法返回异常处理
怎么"回"弹出当前帧,回到调用者逐帧弹出,直到找到 handler
返回值/异常怎么传压入调用者操作数栈顶压入 handler 所在帧的操作数栈顶
目标地址调用者的 nextPC(invoke 指令设好的)异常表的 handlerPc
找不到怎么办栈空 → 程序结束栈空 → 打印轨迹,程序结束

都是"操作栈帧序列"的游戏,区别只是返回是"弹一层",异常是"弹 N 层"。

本篇小结

第 10 章开篇,异常处理的概念层:

  1. Checked vs Unchecked——Error 和 RuntimeException 及其子类是 Unchecked,其余是 Checked。这是 Java 语言规则,不是 JVM 规范——JVM 从来不检查异常是否被声明,athrow 指令也不区分类型。所以 jvmgo 实现异常处理时不需要(也不能)做这个区分。
  2. 三种异常来源——JVM 抛的 Error(StackOverflowError、OutOfMemoryError、UnsatisfiedLinkError)、指令抛的 RuntimeException(NPE、越界、除零)、代码主动 throw 的。前两类在 jvmgo 里目前都是 panic,第 10 章主要处理第三类。
  3. throw 的字节码——new + dup + invokespecial + athrow,只有 athrow 是新指令。
  4. 异常对象不普通——Throwable 的构造函数会调 fillInStackTrace(),后者调本地方法 fillInStackTrace(int) 抓取当前 JVM 栈。所以栈轨迹是异常对象"创建时"抓的,不是"抛出时"。第 10 章先注册一个空实现保证能跑,10.5 节再填。
  5. finally 的历史——Java 6 之前用 jsr/ret(子程序调用,导致控制流分析困难),Java 6 之后改为代码复制。jvmgo 不实现 jsr/ret。
  6. 异常处理的本质——栈展开 + 查表跳转。和方法返回是同一类操作,只是返回弹一层,异常弹 N 层。

下一篇(第 064 篇)进入实现:异常处理表。ExceptionHandler{startPc, endPc, handlerPc, catchType} 四个字段怎么从 class 文件里读出来,catchType == 0 为什么表示 catch-all,以及 findExceptionHandler() 的查找逻辑。


上一篇【第62篇】本地方法调用测试与总结——jvmgo 的能力跃迁
下一篇【第64篇】异常抛出与异常处理表——athrow 和 ExceptionHandler


转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/xyghehehehe/article/details/167349134

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--