我用 Rust 编写了一个JVM

这篇文章是作者分享他如何用 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,而不是一个严肃的实现。

它不支持:

然而,有一些非常琐碎的东西已经实现了:

以下是测试套件的一部分:

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 包提取到一个单独的仓库中,并发布到 crates.io,因为它实际上可能对其他人有所帮助。


解析 .class 文件


众所周知,Java 是一种编译型语言 —— javac 编译器将你的 .java 源文件编译成各种 .class 文件,通常分布在 .jar 文件中,这只是一个 zip 文件。因此,执行一些 Java 代码的第一件事就是加载一个 .class 文件,其中包含了编译器生成的字节码。一个类文件包含了各种东西:

如上所述,对于 rjvm,我创建了一个单独的包,名为 reader,它可以解析一个类文件,并返回一个 Rust 结构,该结构模型化了一个类及其所有内容。


执行方法


vm 包的主要 API 是 Vm::invoke,用于执行方法。它需要一个 CallStack 参数,这个参数会包含多个 CallFrame,每一个 CallFrame 对应一种正在执行的方法。执行 main 方法时,调用栈将初始为空,会创建一个新的栈帧来运行它。然后,每一个函数调用都会在调用栈中添加一个新的栈帧。当一个方法的执行结束时,与其对应的栈帧将被丢弃并从调用栈中移除。

大多数方法会使用 Java 实现,因此将执行它们的字节码。然而,rjvm 也支持原生方法,即直接由 JVM 实现,而非在 Java 字节码中实现的方法。在 Java API 的“较底层”中有很多此类方法,这些部分需要与操作系统交互(例如进行 I/O)或需要运行时支持。你可能见过的后者的一些示例包括 System::currentTimeMillisSystem::arraycopyThrowable::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 object ,}

顺便提一句,这是 Rust 的枚举类型(求和类型)的一种绝妙抽象应用场景,它非常适合表达一个值可能是多种不同类型的事实。

对于存储对象及其值,我最初使用了一个简单的结构体 Object,它包含一个对类的引用(用来模拟对象的类型)和一个 Vec 用于存储字段值。然而,当我实现垃圾收集器时,我修改了这个结构,使用了更低级别的实现,其中包含了大量的指针和类型转换,相当于 C 语言的风格!在当前的实现中,一个 AbstractObject(模拟一个“真实”的对象或数组)仅仅是一个指向字节数组的指针,这个数组包含几个头部字节,然后是字段的值。


执行指令


执行方法意味着逐一执行其字节码指令。JVM 拥有一长串的指令(超过两百条!),在字节码中由一个字节编码。许多指令后面跟有参数,且一些具有可变长度。在代码中,这由类型 Instruction 来模拟:

/// 表示一个 Java 字节码指令。#[derive(Clone, Copy, Debug, Eq, PartialEq)]pub enum Instruction { Aaload, Aastore, Aconst_, Aload(u8), // ...}

如上所述,方法的执行将保持一个堆栈和一组本地变量,指令通过索引引用它们。它还会将程序计数器初始化为零 - 即下一条要执行的指令的地址。指令将被处理,程序计数器会更新 - 通常向前推进一格,但各种跳转指令可以将其移动到不同的位置。这些用于实现所有的流控制语句,例如 ifforwhile

另有一类特殊的指令是那些可以调用另一个方法的指令。解析应调用哪个方法有多种方式:虚拟或静态查找是主要方式,但还有其他方式。解析正确的指令后,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'a>, MethodCallFailed<'a>>

其中的类型定义为:

/// 指令可能的执行结果enum InstructionCompleted<'a> { /// 表示执行的指令是 return 系列中的一个。调用者 /// 应停止方法执行并返回值。 ReturnFromMethod(Option<Value<'a>>),
/// 表示指令不是 return,因此应从程序计数器的 /// 指令继续执行。 ContinueMethodExecution,}
/// 表示方法执行失败的情况pub enum MethodCallFailed<'a> { InternalError(VmError), ExceptionThrown(JavaException<'a>),}

标准的 Rust Result 类型是:

enum Result<T, E> { Ok(T), Err(E),}

因此,执行一个指令可能会产生四种可能的状态:

  1. 指令执行成功,当前方法的执行可以继续(标准情况);

  2. 指令执行成功,且是一个 return 指令,因此当前方法应返回(可选)返回值;

  3. 无法执行指令,因为发生了某种内部 VM 错误;

  4. 无法执行指令,因为抛出了一个标准的 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 时,确实享受到了它带来的各种乐趣!

你是在学习新的编程语言时,是否写过一些有难度或有意思的软件?欢迎在评论区交流讨论。

展开阅读全文

页面更新:2024-04-25

标签:堆栈   字节   指令   异常   对象   垃圾   类型   代码   文件   方法

1 2 3 4 5

上滑加载更多 ↓
推荐阅读:
友情链接:
更多:

本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828  

© CopyRight 2008-2024 All Rights Reserved. Powered By bs178.com 闽ICP备11008920号-3
闽公网安备35020302034844号

Top