前言
在前一章中,我们实现了本地方法调用与反射机制,让 JVM 具备了与底层交互和动态访问类信息的能力。本章将聚焦 JVM 的 异常处理机制—— 这是保障程序健壮性的核心功能。Java 异常分为 Checked 异常和 Unchecked 异常,通过 throw 关键字抛出,依赖异常处理表和 athrow 指令实现捕获与处理。本章将完整实现异常的抛出、捕获逻辑,以及堆栈跟踪功能,并通过测试验证异常处理的正确性。作为系列笔记的终章,本章结尾还将对整个 JVM 实现之旅进行总结。
参考资料
《自己动手写 Java 虚拟机》—— 张秀宏
开发环境
| 工具 / 环境 | 版本 | 说明 |
|---|---|---|
| 操作系统 | MacOS 15.5 | 基于 Intel/Apple Silicon 均可 |
| JDK | 1.8 | 用于字节码分析和测试 |
| Go 语言 | 1.23.10 | 项目开发主语言 |
第十章:异常处理机制实现
异常处理是 Java 语言的重要特性,允许程序在运行时捕获并处理错误,而非直接崩溃。JVM 通过异常处理表记录捕获逻辑,通过 athrow 指令抛出异常,并在栈中查找合适的处理程序。本章将实现这一完整流程。
一、异常概述:类型与继承关系
Java 中的所有异常都继承自 java.lang.Throwable,按是否必须捕获分为两类:
| 异常类型 | 定义 | 示例 |
|---|---|---|
| Checked 异常 | 非 RuntimeException 和 Error 的子类,必须显式捕获或声明抛出 | IOException、ClassNotFoundException |
| Unchecked 异常 | 包括 RuntimeException 及其子类(运行时异常)和 Error 及其子类(错误),无需显式捕获 | NullPointerException、OutOfMemoryError |
继承关系核心:
java.lang.Object
└── java.lang.Throwable
├── java.lang.Error(错误,如 StackOverflowError)
└── java.lang.Exception(异常)
├── Checked 异常(如 IOException)
└── java.lang.RuntimeException(运行时异常,Unchecked)二、异常抛出:throw 关键字与 athrow 指令
在 Java 代码中,通过 throw 关键字抛出异常,对应字节码中的 athrow 指令,负责将异常对象从操作数栈弹出并触发异常处理流程。
1. athrow 指令的核心逻辑
athrow 指令的执行流程:
- 从操作数栈弹出异常对象引用(必须是非
null的Throwable实例); - 遍历当前线程的栈帧,在每个方法的异常处理表中查找匹配的异常处理程序;
- 找到处理程序后,清空当前栈帧的操作数栈,将异常对象推入栈顶,跳转到处理程序执行;
- 若遍历所有栈帧仍未找到处理程序,则终止线程并输出堆栈跟踪。
// ATHROW 异常抛出指令
type ATHROW struct {
base.NoOperandsInstruction
}
func (a *ATHROW) Execute(frame *rtda.Frame) {
// 1. 从操作数栈弹出异常对象
ex := frame.OperandStack().PopRef()
if ex == nil {
panic("java.lang.NullPointerException") // 不能抛出 null
}
thread := frame.Thread()
// 2. 查找异常处理程序
if !findAndGotoExceptionHandler(thread, ex) {
// 3. 未找到处理程序,输出堆栈并终止线程
handleUncaughtException(thread, ex)
}
}三、异常处理表:捕获逻辑的存储结构
每个方法的 Code 属性中包含异常处理表(exception_table),记录该方法中异常捕获的范围、类型和处理程序位置,是异常捕获的核心依据。
1. 异常处理表的结构
// ExceptionHandler 异常处理表中的一项
type ExceptionHandler struct {
startPc int // 异常监控的起始 PC 地址(包含)
endPc int // 异常监控的结束 PC 地址(不包含)
handlerPc int // 异常处理程序的 PC 地址(跳转目标)
catchType *ClassRef // 捕获的异常类型(null 表示捕获所有异常,对应 catch (Throwable))
}
// ExceptionTable 异常处理表(由多个 ExceptionHandler 组成)
type ExceptionTable []*ExceptionHandler字段说明:
startPc和endPc:定义监控的代码范围([startPc, endPc)),该范围内抛出的异常会被当前处理程序监控;handlerPc:当异常被捕获时,程序计数器跳转至此地址执行处理逻辑;catchType:指定捕获的异常类型(通过常量池中的类符号引用),null表示捕获所有异常(对应catch (Throwable))。
2. 异常处理程序的查找逻辑
当异常抛出后,JVM 需要在当前方法的异常处理表中查找最合适的处理程序:
// findExceptionHandler 查找匹配的异常处理程序
func (t ExceptionTable) findExceptionHandler(exClass *Class, pc int) *ExceptionHandler {
for _, handler := range t {
// 1. 检查当前 PC 是否在监控范围内([startPc, endPc))
if pc >= handler.startPc && pc < handler.endPc {
// 2. 若捕获所有异常(catchType 为 null),直接返回
if handler.catchType == nil {
return handler
}
// 3. 解析捕获的异常类型,检查是否与抛出的异常兼容
catchClass := handler.catchType.ResolveClass()
if catchClass == exClass || exClass.IsSubClassOf(catchClass) {
// 异常类型匹配(抛出的异常是捕获类型或其子类)
return handler
}
}
}
return nil // 未找到匹配的处理程序
}匹配规则:
- 优先匹配范围包含当前 PC 且异常类型兼容的处理程序;
- 若存在多个匹配的处理程序,按在异常处理表中的顺序优先选择第一个。
四、异常处理流程:从抛出到捕获
异常处理的完整流程涉及栈帧遍历、处理程序查找和流程跳转,确保异常被正确捕获或向上传播。
1. 查找并执行异常处理程序
// findAndGotoExceptionHandler 在栈中查找异常处理程序并跳转
func findAndGotoExceptionHandler(thread *rtda.Thread, ex *heap.Object) bool {
for {
// 1. 获取当前栈顶栈帧
frame := thread.CurrentFrame()
// 当前指令的 PC(抛出异常的位置)
pc := frame.NextPC() - 1
// 2. 在当前方法的异常处理表中查找处理程序
handler := frame.Method().ExceptionTable().findExceptionHandler(ex.Class(), pc)
if handler != nil {
// 3. 找到处理程序:清空操作数栈,推送异常对象,跳转执行
stack := frame.OperandStack()
stack.Clear()
stack.PushRef(ex)
frame.SetNextPC(handler.handlerPc)
return true
}
// 4. 未找到,弹出当前栈帧,继续在调用栈中查找
thread.PopFrame()
// 5. 若栈为空,说明未找到任何处理程序
if thread.IsStackEmpty() {
break
}
}
return false
}流程说明:
- 从抛出异常的方法开始,逐层遍历调用栈(弹出栈帧),在每个方法的异常处理表中查找匹配的处理程序;
- 找到后,清空当前栈帧的操作数栈,将异常对象推入栈顶,设置程序计数器为
handlerPc执行处理逻辑; - 若遍历所有栈帧仍未找到处理程序,则该异常为 “未捕获异常”,触发线程终止。
五、堆栈跟踪:fillInStackTrace 本地方法
当异常未被捕获时,JVM 需要输出堆栈跟踪信息(包含异常类型、消息和调用栈),帮助定位问题。这一功能通过 Throwable.fillInStackTrace() 本地方法实现。
1. 堆栈跟踪元素的结构
堆栈跟踪由多个 StackTraceElement 组成,每个元素记录调用栈中的一个方法信息:
// StackTraceElement 堆栈跟踪元素
type StackTraceElement struct {
fileName string // 文件名(如 "ParseIntTest.java")
className string // 类名(如 "ParseIntTest")
methodName string // 方法名(如 "bar")
lineNumber int // 行号(-1 表示未知)
}2. fillInStackTrace 实现
该方法填充异常的堆栈信息,记录从异常抛出点到线程启动的完整调用栈:
// 注册本地方法:java/lang/Throwable.fillInStackTrace()
func init() {
native.Register("java/lang/Throwable", "fillInStackTrace", "(I)Ljava/lang/Throwable;", fillInStackTrace)
}
// fillInStackTrace 填充异常的堆栈跟踪信息
func fillInStackTrace(frame *rtda.Frame) {
this := frame.LocalVars().GetThis() // 获取 Throwable 实例
// 从当前线程的栈帧中收集堆栈信息
stacks := collectStackTraceElements(frame.Thread(), this)
// 将堆栈信息存储到异常对象中(通过 extra 字段)
this.SetExtra(stacks)
frame.OperandStack().PushRef(this) // 返回异常对象本身
}
// collectStackTraceElements 收集堆栈跟踪元素
func collectStackTraceElements(thread *rtda.Thread, ex *heap.Object) []*StackTraceElement {
var elements []*StackTraceElement
// 遍历线程的栈帧(跳过 fillInStackTrace 方法本身的栈帧)
for frame := thread.CurrentFrame().Lower(); frame != nil; frame = frame.Lower() {
method := frame.Method()
class := method.Class()
// 创建堆栈元素:包含类名、方法名、文件名和行号
element := &StackTraceElement{
className: class.JavaName(),
methodName: method.Name(),
fileName: class.SourceFile(), // 从类的 SourceFile 属性获取文件名
lineNumber: method.GetLineNumber(frame.NextPC() - 1), // 获取当前 PC 对应的行号
}
elements = append(elements, element)
}
return elements
}功能:通过遍历线程的栈帧,收集每个方法的类名、方法名、文件名和行号,最终存储到异常对象中,为后续打印堆栈跟踪提供数据。
六、测试:异常处理全流程验证
通过 ParseIntTest 测试类验证异常的抛出、捕获和堆栈跟踪功能:
1. 测试代码
public class ParseIntTest {
public static void main(String[] args) {
foo(args); // 调用 foo 方法
}
private static void foo(String[] args) {
try {
bar(args); // 调用 bar 方法,可能抛出异常
} catch (NumberFormatException e) {
// 捕获数字格式化异常
System.out.println("捕获 NumberFormatException:" + e.getMessage());
}
}
private static void bar(String[] args) {
if (args.length == 0) {
// 若没有参数,抛出索引越界异常
throw new IndexOutOfBoundsException("没有输入参数!");
}
// 尝试将参数转换为整数(可能抛出 NumberFormatException)
int x = Integer.parseInt(args[0]);
System.out.println("解析结果:" + x);
}
}2. 测试场景与结果
- 场景 1:无参数运行(
java ParseIntTest)→bar方法抛出IndexOutOfBoundsException,未被foo的NumberFormatException捕获,向上传播至main方法,最终输出堆栈跟踪。 - 场景 2:参数为非数字(
java ParseIntTest abc)→Integer.parseInt抛出NumberFormatException,被foo的catch块捕获并处理。
测试结果:
两种场景均按预期执行,异常捕获逻辑和堆栈输出正确。

系列总结:自己动手写 JVM 的旅程
从第一部分的命令行工具到本章的异常处理,我们完成了一个简易 JVM 的核心功能实现。回顾整个系列,我们走过了以下关键旅程:
1. 基础搭建(第一、二章)
- 实现命令行参数解析,作为 JVM 的入口;
- 设计类路径查找逻辑,支持从 JAR 包、目录加载 Class 文件。
2. 类加载与解析(第三、六章)
- 解析 Class 文件结构,提取魔数、版本号、常量池、字段、方法等信息;
- 实现方法区存储类元信息,通过类加载器完成 “加载→链接→初始化” 流程;
- 解析符号引用为直接引用,建立类、字段、方法的运行时关联。
3. 运行时数据区(第四、五章)
- 实现线程、虚拟机栈、栈帧、局部变量表、操作数栈等核心结构;
- 设计指令集和解释器,支持常量加载、算术运算、控制转移等基础指令;
- 实现方法调用与返回机制,支持静态绑定和动态绑定(多态)。
4. 复杂数据结构(第七、八章)
- 实现数组的动态创建和操作指令,支持基本类型和引用类型数组;
- 通过字符串池实现字符串常量的共享,支持字符串拼接和
intern机制。
5. 扩展能力(第九、十章)
- 设计本地方法注册与调用框架,实现反射核心功能和类库依赖的本地方法;
- 完整实现异常处理机制,支持异常抛出、捕获和堆栈跟踪。
收获与展望
通过亲手实现 JVM,我们深入理解了 “Write once, run anywhere” 的底层逻辑:从 Class 文件的二进制结构到指令执行的每一个细节,从内存管理到异常处理,每一部分都是对计算机体系结构和面向对象思想的深度实践。
这个简易 JVM 仍有许多可扩展之处(如 JIT 编译、垃圾回收、并发支持等),但已覆盖核心功能,足以执行简单的 Java 程序。希望这份笔记能为同样对 JVM 原理感兴趣的开发者提供参考,让我们在探索技术底层的道路上继续前行。
源码地址:https://github.com/Jucunqi/jvmgo.git
评论 (0)