这篇文章是作者分享他如何用 Rust 编写一个 Java 虚拟机(JVM)的经验。他强调这是一个玩具级别的 JVM,主要用于学习目的,并非严肃的实现。尽管如此,他实现了一些非琐碎的功能,如控制流语句、对象创建、方法调用、异常处理、垃圾收集等。他还详细介绍了代码组织、文件解析、方法执行、值和对象的建模、指令执行、异常处理和垃圾收集等方面的实现细节。
链接:https://andreabergia.com/blog/2023/07/i-have-written-a-jvm-in-rust/
未经允许,禁止转载!
作者 | Andrea Bergia 责编 | 明明如月
责编 | 夏萌
出品 | CSDN(ID:CSDNnews)
最近,我一直在学习 Rust,和任何理智的人一样,编写了几个百行的程序后,我决定做点更加有挑战的事情:我用 Rust 写了一个 Java 虚拟机(Java Virtual Machine)。我极具创新地将其命名为 rjvm。你可以在 GitHub 上找到源代码。
我想强调的是,这只是为了学习而构建的一个玩具级别的 JVM,而不是一个严肃的实现。
它不支持:
泛型
线程
反射
注解
I/O
即时编译器
字符串 intern 功能
然而,有一些非常琐碎的东西已经实现了:
控制流语句(if, for, ...)
基本类型和对象的创建
虚拟和静态方法的调用
异常处理
垃圾回收
从 jar文件解析类
以下是测试套件的一部分:
class StackTracePrinting {
public static void main(String[] args) {
Throwable ex = new Exception();
StackTraceElement[] stackTrace = ex.getStackTrace();
for (StackTraceElement element : stackTrace) {
tempPrint(
element.getClassName() + "::" + element.getMethodName() + " - " +
element.getFileName() + ":" + element.getLineNumber());
}
}
// We use this in place of System.out.println because we don't have real I/O
private static native void tempPrint(String value);
}
它使用的是真正的 rt.jar,里面包含了 OpenJDK 7 的类 —— 因此,在上面的例子中,java.lang.StackTraceElement 类就是来自真正的 JDK!
我对我所学到的东西感到非常满意,无论是关于 Rust 还是关于如何实现一个虚拟机。我对我实现的一个真正的、可运行的、垃圾回收器感到格外高兴。虽然它很一般,但它是我写的,我很喜欢它。既然我已经达成了我最初的目标,我决定在这里停下来。我知道有一些问题,但我没有计划去修复它们。
在这篇文章中,我将给你介绍我的 JVM 是如何运行的。在接下来的文章中,我将更详细地讨论这里所涉及的一些方面。
这是一个标准的 Rust 项目。我将其分成了三个包(也就是 crates):
reader,它能够读取 .class 文件,并包含了一些类型,用于模型化它们的内容;
vm,包含了一个可以作为库执行代码的虚拟机;
vm_cli,包含了一个非常简单的命令行启动器,用于运行 VM,这与 java 可执行文件的精神是一致的。
我正在考虑将 reader 包提取到一个单独的仓库中,并发布到 crates.io,因为它实际上可能对其他人有所帮助。
众所周知,Java 是一种编译型语言 —— javac 编译器将你的 .java 源文件编译成各种 .class 文件,通常分布在 .jar 文件中,这只是一个 zip 文件。因此,执行一些 Java 代码的第一件事就是加载一个 .class 文件,其中包含了编译器生成的字节码。一个类文件包含了各种东西:
类的元数据,如其名称或源文件名称
超类名称
实现的接口
字段,连同它们的类型和注解
方法和:
们的描述符,这是一个字符串,表示每个参数的类型和方法的返回类型
元数据,如 throws 子句、注解、泛型信息
字节码,以及一些额外的元数据,如异常处理器表和行号表。
如上所述,对于 rjvm,我创建了一个单独的包,名为 reader,它可以解析一个类文件,并返回一个 Rust 结构,该结构模型化了一个类及其所有内容。
vm 包的主要 API 是 Vm::invoke,用于执行方法。它需要一个 CallStack 参数,这个参数会包含多个 CallFrame,每一个 CallFrame 对应一种正在执行的方法。执行 main 方法时,调用栈将初始为空,会创建一个新的栈帧来运行它。然后,每一个函数调用都会在调用栈中添加一个新的栈帧。当一个方法的执行结束时,与其对应的栈帧将被丢弃并从调用栈中移除。
大多数方法会使用 Java 实现,因此将执行它们的字节码。然而,rjvm 也支持原生方法,即直接由 JVM 实现,而非在 Java 字节码中实现的方法。在 Java API 的“较底层”中有很多此类方法,这些部分需要与操作系统交互(例如进行 I/O)或需要运行时支持。你可能见过的后者的一些示例包括 System::currentTimeMillis、System::arraycopy 或 Throwable::fillInStackTrace。在 rjvm 中,这些都是通过 Rust 函数来实现的。
JVM 是一种基于栈的虚拟机,也就是说字节码指令主要是在值栈上操作。还有一组由索引标识的局部变量,可以用来存储值并向方法传递参数。在 rjvm 中,这些都与每个调用栈帧相关联。
Value 类型用于模拟局部变量、栈元素或对象字段可能的值,实现如下:
/// 模拟一个可以存储在局部变量或操作数栈中的通用值
#[derive(Debug, Default, Clone, PartialEq)]
pub enum Value<'a> {
/// 一个未初始化的元素,它不应该出现在操作数栈上,但它是局部变量的默认状态
#[default]
Uninitialized,
/// 模拟 Java 虚拟机中所有 32 位或以下的数据类型: `boolean`,
/// `byte`, `char`, `short`, and `int`.
Int(i32),
/// Models a `long` value.
Long(i64),
/// Models a `float` value.
Float(f32),
/// Models a `double` value.
Double(f64),
/// Models an object value
Object(AbstractObject<'a>),
/// Models a null object
Null,
}
顺便提一句,这是 Rust 的枚举类型(求和类型)的一种绝妙抽象应用场景,它非常适合表达一个值可能是多种不同类型的事实。
对于存储对象及其值,我最初使用了一个简单的结构体 Object,它包含一个对类的引用(用来模拟对象的类型)和一个 Vec
执行方法意味着逐一执行其字节码指令。JVM 拥有一长串的指令(超过两百条!),在字节码中由一个字节编码。许多指令后面跟有参数,且一些具有可变长度。在代码中,这由类型 Instruction 来模拟:
/// 表示一个 Java 字节码指令。
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Instruction {
Aaload,
Aastore,
Aconst_null,
Aload(u8),
// ...
}
如上所述,方法的执行将保持一个堆栈和一组本地变量,指令通过索引引用它们。它还会将程序计数器初始化为零 - 即下一条要执行的指令的地址。指令将被处理,程序计数器会更新 - 通常向前推进一格,但各种跳转指令可以将其移动到不同的位置。这些用于实现所有的流控制语句,例如 if,for 或 while。
另有一类特殊的指令是那些可以调用另一个方法的指令。解析应调用哪个方法有多种方式:虚拟或静态查找是主要方式,但还有其他方式。解析正确的指令后,rjvm 将向调用堆栈添加一个新帧,并启动方法的执行。除非方法的返回值为 void,否则将把返回值推到堆栈上,并恢复执行。
Java 字节码格式相当有趣,我打算专门发一篇文章来讨论各种类型的指令。
异常处理是一项复杂的任务,因为它打破了正常的控制流,可能会提前从方法中返回(并在调用堆栈中传播!)。尽管如此,我对自己实现的方式感到相当满意,接下来我将展示一些相关的代码。
首先你需要知道,任何一个 catch 块都对应于方法异常表的一个条目,每个条目包含了覆盖的程序计数器范围、catch 块中第一条指令的地址,以及该块能捕获的异常类名。
接着,CallFrame::execute_instruction 的签名如下:
fn execute_instruction(
&mut self,
vm: &mut Vm<'a>,
call_stack: &mut CallStack<'a>,
instruction: Instruction,
) -> Result, MethodCallFailed<'a>>
其中的类型定义为:
/// 指令可能的执行结果
enum InstructionCompleted<'a> {
/// 表示执行的指令是 return 系列中的一个。调用者
/// 应停止方法执行并返回值。
ReturnFromMethod(Option>),
/// 表示指令不是 return,因此应从程序计数器的
/// 指令继续执行。
ContinueMethodExecution,
}
/// 表示方法执行失败的情况
pub enum MethodCallFailed<'a> {
InternalError(VmError),
ExceptionThrown(JavaException<'a>),
}
标准的 Rust Result 类型是:
enum Result {
Ok(T),
Err(E),
}
因此,执行一个指令可能会产生四种可能的状态:
指令执行成功,当前方法的执行可以继续(标准情况);
指令执行成功,且是一个 return 指令,因此当前方法应返回(可选)返回值;
无法执行指令,因为发生了某种内部 VM 错误;
无法执行指令,因为抛出了一个标准的 Java 异常。
因此,执行方法的代码如下:
/// 执行整个方法
impl<'a> CallFrame<'a> {
pub fn execute(
&mut self,
vm: &mut Vm<'a>,
call_stack: &mut CallStack<'a>,
) -> MethodCallResult<'a> {
self.debug_start_execution();
loop {
let executed_instruction_pc = self.pc;
let (instruction, new_address) =
Instruction::parse(
self.code,
executed_instruction_pc.0.into_usize_safe()
).map_err(|_| MethodCallFailed::InternalError(
VmError::ValidationException)
)?;
self.debug_print_status(&instruction);
// 在执行指令之前,将 pc 移动到下一条指令,
// 因为我们希望 "goto" 能够覆盖这一步
self.pc = ProgramCounter(new_address as u16);
let instruction_result =
self.execute_instruction(vm, call_stack, instruction);
match instruction_result {
Ok(ReturnFromMethod(return_value)) => return Ok(return_value),
Ok(ContinueMethodExecution) => { /* continue the loop */ }
Err(MethodCallFailed::InternalError(err)) => {
return Err(MethodCallFailed::InternalError(err))
}
Err(MethodCallFailed::ExceptionThrown(exception)) => {
let exception_handler = self.find_exception_handler(
vm,
call_stack,
executed_instruction_pc,
&exception,
);
match exception_handler {
Err(err) => return Err(err),
Ok(None) => {
// 将异常冒泡至调用者
return Err(MethodCallFailed::ExceptionThrown(exception));
}
Ok(Some(catch_handler_pc)) => {
// 将异常重新压入堆栈,并从 catch 处理器继续执行此方法
self.stack.push(Value::Object(exception.0))?;
self.pc = catch_handler_pc;
}
}
}
}
}
}
}
我知道这段代码中包含了许多实现细节,但我希望它能展示出 Rust 的 Result 和模式匹配如何很好地映射到上述行为描述。我必须说我对这段代码感到相当自豪。
在 rjvm 中,最后一个里程碑是实现垃圾回收器。我选择的算法是一个停止 - 世界(这显然是由于没有线程!)半空间复制收集器。我实现了 Cheney 的算法的一个较差的变体 - 但我真的应该去实现真正的 Cheney 算法。
这个算法的思想是将可用内存分成两部分,称为半空间:一部分将处于活动状态并用于分配对象,另一部分将不被使用。当活动的半空间满了,将触发垃圾收集,所有存活的对象都会被复制到另一个半空间。然后,所有对象的引用都将被更新,以便它们指向新的副本。最后,两者的角色将被交换 - 这与蓝绿部署的工作方式类似。
这个算法有以下特点:
显然,它浪费了大量的内存(可能的最大内存的一半!);
分配操作非常快(只需移动一个指针);
复制并压缩对象意味着无需处理内存碎片问题;
压缩对象可以提高性能,因为它更好地利用了缓存行。
实际的 Java 虚拟机使用了更为复杂的算法,通常是分代垃圾收集器,如 G1 或并行 GC,这些都使用了复制策略的进化版本。
在编写 rjvm 的过程中,我学到了很多,也很有趣。从一个小项目中能学这么多,我已经很满足了。也许下次我在学习新的编程语言时会选择一个稍微不那么难的项目!
顺便说一句,使用 Rust 语言写代码给我带来了很好的编程体验。正如我之前写过的,我认为它是一种很棒的语言,我在用它来实现我的 JVM 时,确实享受到了它带来的各种乐趣!
你是在学习新的编程语言时,是否写过一些有难度或有意思的软件?欢迎在评论区交流讨论。
推荐阅读:
▶谷歌、亚马逊、Meta等多家科技公司被爆员工「假工作」,裁员成最终归宿!
▶小心!别被假的 GitHub 存储库骗了
▶Stability AI 把绘画门槛打为 0!