自己动手写 Java 虚拟机笔记 - 第十部分:异常处理机制实现(系列终章)

自己动手写 Java 虚拟机笔记 - 第十部分:异常处理机制实现(系列终章)

Luca Ju
2025-07-07 / 0 评论 / 5 阅读 / 正在检测是否收录...

前言

在前一章中,我们实现了本地方法调用与反射机制,让 JVM 具备了与底层交互和动态访问类信息的能力。本章将聚焦 JVM 的 异常处理机制—— 这是保障程序健壮性的核心功能。Java 异常分为 Checked 异常和 Unchecked 异常,通过 throw 关键字抛出,依赖异常处理表和 athrow 指令实现捕获与处理。本章将完整实现异常的抛出、捕获逻辑,以及堆栈跟踪功能,并通过测试验证异常处理的正确性。作为系列笔记的终章,本章结尾还将对整个 JVM 实现之旅进行总结。

参考资料

《自己动手写 Java 虚拟机》—— 张秀宏

开发环境

工具 / 环境版本说明
操作系统MacOS 15.5基于 Intel/Apple Silicon 均可
JDK1.8用于字节码分析和测试
Go 语言1.23.10项目开发主语言

第十章:异常处理机制实现

异常处理是 Java 语言的重要特性,允许程序在运行时捕获并处理错误,而非直接崩溃。JVM 通过异常处理表记录捕获逻辑,通过 athrow 指令抛出异常,并在栈中查找合适的处理程序。本章将实现这一完整流程。

一、异常概述:类型与继承关系

Java 中的所有异常都继承自 java.lang.Throwable,按是否必须捕获分为两类:

异常类型定义示例
Checked 异常RuntimeExceptionError 的子类,必须显式捕获或声明抛出IOExceptionClassNotFoundException
Unchecked 异常包括 RuntimeException 及其子类(运行时异常)和 Error 及其子类(错误),无需显式捕获NullPointerExceptionOutOfMemoryError

继承关系核心

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 指令的执行流程:

  1. 从操作数栈弹出异常对象引用(必须是非 nullThrowable 实例);
  2. 遍历当前线程的栈帧,在每个方法的异常处理表中查找匹配的异常处理程序;
  3. 找到处理程序后,清空当前栈帧的操作数栈,将异常对象推入栈顶,跳转到处理程序执行;
  4. 若遍历所有栈帧仍未找到处理程序,则终止线程并输出堆栈跟踪。
// 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

字段说明

  • startPcendPc:定义监控的代码范围([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,未被 fooNumberFormatException 捕获,向上传播至 main 方法,最终输出堆栈跟踪。
  • 场景 2:参数为非数字(java ParseIntTest abc)→ Integer.parseInt 抛出 NumberFormatException,被 foocatch 块捕获并处理。

测试结果
两种场景均按预期执行,异常捕获逻辑和堆栈输出正确。

ch10-test.png

系列总结:自己动手写 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

评论 (0)

取消