上一篇【第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 的最后一个功能模块,也是让它从"能跑"变成"能正确处理错误"的关键。
这一篇先把概念理清楚:
- Checked vs Unchecked——谁强制你处理,谁不强制(注意:这是 Java 语言规则,不是 JVM 规范)
- 异常的三种来源——JVM 抛的
Error、指令抛的RuntimeException、代码主动throw的 throw语句编译成什么——new+dup+invokespecial+athrow- 异常对象不普通——构造函数里藏着一个
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 / irem | ArithmeticException(除以零) | 第 036 篇 |
getfield / putfield | NullPointerException | 第 047 篇 |
new / arraylength | NegativeArraySizeException | 第 054 篇 |
<t>aload / <t>astore | NullPointerException / ArrayIndexOutOfBoundsException | 第 054 篇 |
checkcast | ClassCastException | 第 047 篇 |
arraycopy | NullPointerException / 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 一个异常对象,都会:
- 调构造函数
- 构造函数调
fillInStackTrace()(Java 方法) fillInStackTrace()调fillInStackTrace(int)(本地方法)- 本地方法抓取当前 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 ← 栈展开:丢弃这一层
}
// 栈空了还没找到
打印栈轨迹,程序终止
三个关键点:
- 异常对象在操作数栈上传递——从抛出点传到 catch 块,靠的是"清空栈 + 压入异常对象"
- 跳转靠
nextPC——和第 5 章的Branch()一样,只是跳转目标来自异常表 - 找不到就栈展开——逐帧弹出,直到找到能处理的帧,或者栈空
这个机制和第 7 章的方法返回有几分神似:
| 方法返回 | 异常处理 | |
|---|---|---|
| 怎么"回" | 弹出当前帧,回到调用者 | 逐帧弹出,直到找到 handler |
| 返回值/异常怎么传 | 压入调用者操作数栈顶 | 压入 handler 所在帧的操作数栈顶 |
| 目标地址 | 调用者的 nextPC(invoke 指令设好的) | 异常表的 handlerPc |
| 找不到怎么办 | 栈空 → 程序结束 | 栈空 → 打印轨迹,程序结束 |
都是"操作栈帧序列"的游戏,区别只是返回是"弹一层",异常是"弹 N 层"。
本篇小结
第 10 章开篇,异常处理的概念层:
- Checked vs Unchecked——
Error和RuntimeException及其子类是 Unchecked,其余是 Checked。这是 Java 语言规则,不是 JVM 规范——JVM 从来不检查异常是否被声明,athrow指令也不区分类型。所以 jvmgo 实现异常处理时不需要(也不能)做这个区分。 - 三种异常来源——JVM 抛的
Error(StackOverflowError、OutOfMemoryError、UnsatisfiedLinkError)、指令抛的RuntimeException(NPE、越界、除零)、代码主动throw的。前两类在 jvmgo 里目前都是panic,第 10 章主要处理第三类。 throw的字节码——new+dup+invokespecial+athrow,只有athrow是新指令。- 异常对象不普通——
Throwable的构造函数会调fillInStackTrace(),后者调本地方法fillInStackTrace(int)抓取当前 JVM 栈。所以栈轨迹是异常对象"创建时"抓的,不是"抛出时"。第 10 章先注册一个空实现保证能跑,10.5 节再填。 finally的历史——Java 6 之前用jsr/ret(子程序调用,导致控制流分析困难),Java 6 之后改为代码复制。jvmgo 不实现jsr/ret。- 异常处理的本质——栈展开 + 查表跳转。和方法返回是同一类操作,只是返回弹一层,异常弹 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




