ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

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


摘要

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

这意味着:

try{intx=Integer.parseInt("abc");}catch(NumberFormatExceptione){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:必须处理或声明publicvoidreadFile()throwsIOException{// ✓ 声明FileReaderr=newFileReader("x.txt");}publicvoidreadFile2(){try{newFileReader("x.txt");}catch(IOExceptione){}// ✓ 捕获}publicvoidreadFile3(){newFileReader("x.txt");// ✗ 编译错误}// Unchecked:随意publicvoidf(){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")publicstatic<TextendsThrowable>voidsneakyThrow(Throwablet)throwsT{throw(T)t;}publicvoidf(){sneakyThrow(newIOException("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指令和方法的异常处理表配合完成的,本章将重点讨论这一点。

thrownewIllegalArgumentException("参数错");

这是第 10 章的主角。

三、throw 语句编译成什么

3.1 规范里的例子

voidcantBeZero(inti){if(i==0){thrownewTestExc();}}

编译后:

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.ExceptionpublicException(Stringmessage){super(message);}// java.lang.ThrowablepublicThrowable(Stringmessage){fillInStackTrace();// ← 关键!detailMessage=message;}publicsynchronizedThrowablefillInStackTrace(){if(stackTrace!=null||backtrace!=null){fillInStackTrace(0);// ← 调用本地方法stackTrace=UNASSIGNED_STACK;}returnthis;}privatenativeThrowablefillInStackTrace(intdummy);// ← 本地方法!

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

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

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

一个实用的推论:

Exceptione=newException("error");// ← 栈在这里就被抓好了// ...throwe;// 抛出时的栈不影响// 所以这样写是错的(栈是创建时的,不是抛出时的)staticExceptioncached=newException("cached");voidf(){throwcached;}// 栈轨迹会是类初始化时的!

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.gopackagelangimport"jvmgo/ch10/native"import"jvmgo/ch10/rtda"import"jvmgo/ch10/rtda/heap"funcinit(){native.Register("java/lang/Throwable","fillInStackTrace","(I)Ljava/lang/Throwable;",fillInStackTrace)}// private native Throwable fillInStackTrace(int dummy);funcfillInStackTrace(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


RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进