我用 Rust 写了一个 JVM

肉眼品世界

共 10028字,需浏览 21分钟

 ·

2023-08-08 07:56

最近,我花了相当多的时间来学习 Rust,就像任何有理智的人都会做的那样,在编写了几个 100 行程序之后,我决定做一些更加雄心勃勃的事情——我用Rust写了一个 Java 虚拟机。🎉 

我在其中实现了很多独创特性,我把它称为『rjvm』。目前代码已经开源,各位可以在 GitHub 上获取。

https://github.com/andreabergia/rjvm

我想强调的是,这是一个玩具型 JVM,是为了学习目的而构建的,而不是一个严肃的实现。特别是,它不支持:

  • 泛型

  • 线程

  • 反射

  • 注释

  • 输入/输出

  • 及时编译器

  • 字符串处理


实际上,我已经实现了大多数重要的事情。比如

  • 控制流语句 ( 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);}

我使用真实的包,它来自OpenJDK 7 的rt.jar类。所以在上面的示例中,该类来自真实的 JDK!java.lang.StackTraceElement。

我对自己学到的关于 Rust 知识,以及如何实现虚拟机的知识感到非常满意。

特别是,我非常兴奋能够实现一个真正的、有效的垃圾收集器。它很普通,但它是我亲自写出来的,我很喜欢它。💘 

鉴于我已经实现了最初的目标,我决定停止这个项目。我知道存在一些错误,但我并不打算修复它们。

概述 


在这篇文章中,我将向概述 JVM 的工作原理。


代码组织 


该代码是一个标准的 Rust 项目。我把它分成了三个代码空间(即包):

  • reader,它能够读取.class文件,并包含对其内容进行建模以及各种数据类型;

  • vm,其中包含可以将代码作为库执行的虚拟机;

  • vm_cli,其中包含一个非常简单的命令行启动器来运行虚拟机,可执行java文件。

我正在考虑将reader提取到单独的存储库中并将其发布到crates.io上,因为它实际上对其他的开发者可能有用。

解析.class文件 


如大家所知晓的,Java 是一种半编语言 -javac编译器获取.java源文件并生成各种.class文件,通常压缩在一个.jar文件中 - 这是一zip文件因此,执行些 Java 代码要做的第一件事就是加载一个.class文件,其中包含编译器生成的字节码。

其中,类文件包含如下内容:

  • 有关类的元数据,例如其名称或源文件名

  • 超类名称

  • 实现的接口

  • 字段及其类型与注释

  • 接下来是方法:

    • 它们的描述符,它是一个字符串,表示每个参数的类型和方法的返回类型

    • 元数据,例如throws子句、注释、泛型信息

    • 字节码以及一些额外的元数据,例如异常处理程序表与行号表。


就像前面所描述的,我将rjvm创建了一个单独的盒子,名为reader,它可以解析类文件,并返回一个对类及其所有内容进行建模的Rust 结构。

https://github.com/andreabergia/rjvm/blob/main/reader/src/class_file.rs


执行方法 


vm包的主要 API是Vm::invoke,用于执行方法。

它需要一个CallStack,其中包含各种CallFrame, 一个用于正在执行的每个方法。对于执行main,调用堆栈最初将为空,并且将创建一个新的栈帧来运行它。接下来,每次函数调用都会向调用堆栈添加一个新帧。当方法执行完成时,其相应的帧将被丢弃,并从调用堆栈中删除。

大多数方法将用 Java 实现,因此它们的字节码将被执行。但是,rjvm也支持本机方法,即直接由 JVM 实现而不是在 Java 字节码中实现的方法。其中相当多的部分位于 Java API 的“较低部分”,需要与操作系统进行交互(例如执行 I/O)或支持运行时。

你可能见过的后者的一些示例包括System::currentTimeMillis、System::arraycopy或Throwable::fillInStackTrace。在 中rjvm,这些是由Rust 函数实现的。

JVM是基于堆栈的虚拟机,即字节码指令主要在堆栈上操作。还有一组由索引标识的局部变量,可用于存储值并将参数传递给方法。这些与 中的每个调用帧相关联rjvm。

值与对象建模 


类型Value对局部变量、堆栈元素或对象字段的可能值进行建模,并按如下方式实现:

/// Models a generic value that can be stored in a local variable or on the stack.#[derive(Debug, Default, Clone, PartialEq)]pub enum Value<'a> {    /// An unitialized element. Should never be on the stack,    /// but it is the default state for local variables.    #[default]    Uninitialized,
/// Models all the 32-or-lower-bits types in the jvm: `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,}

顺便说一句,这里的 sum 类型(如 Rust 的enum)是一种美妙的抽象——它非常适合表达一个值可能具有多种不同类型的情况。

为了存储对象和它的值,我最初实现了一个名为Object 的简单结构,Object其中包含对类的引用(使用对象类型进行建模)和Vec<Value>用来存储字段值 。

在我实现垃圾收集器时,我修改了它并以使用较低级别的实现,里带有大量的指针和强制转换 - 相当 C 语言风格!

在当前的实现中,一个 AbstractObject(模拟“真实”对象或数组)是指向字节数组的指针,其中包含几个标头字,然后才是字段值。

执行指令 


执行一个方法意味着一次执行一个字节码指令。


JVM 有着大量的指令(超过 200 条!),由字节码中的一个字节进行编码。许多指令后面都有参数,有些指令的长度是可变的。


这是在代码中通过Instruction类型建模:

/// Represents a Java bytecode instruction.#[derive(Clone, Copy, Debug, Eq, PartialEq)]pub enum Instruction {    Aaload,    Aastore,    Aconst_null,    Aload(u8),    // ...


如上所述,方法的执行将保留一个堆栈和一个局部变量数组,指令通过其索引引用它们。此外,它还会将程序计数器初始化为零,即下一条要执行的指令地址。该指令将被处理并更新程序计数器 ,通常情况是加 1,但各种跳转指令可以将其移动到不同的位置。

这些用于实现所有流控制语句,例如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<InstructionCompleted<'a>, MethodCallFailed<'a>>

其中类型为:

/// Possible execution result of an instructionenum InstructionCompleted<'a> {    /// Indicates that the instruction executed was one of the return family. The caller    /// should stop the method execution and return the value.    ReturnFromMethod(Option<Value<'a>>),
/// Indicates that the instruction was not a return, and thus the execution should /// resume from the instruction at the program counter. ContinueMethodExecution,}
/// Models the fact that a method execution has failedpub enum MethodCallFailed<'a> { InternalError(VmError), ExceptionThrown(JavaException<'a>),}

标准 Rust的Result类型是:

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

因此,执行一条指令会导致四种可能的状态:

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

  • 该指令执行成功,并且它是一个返回指令,因此当前方法应该返回(可选)一个返回值;

  • 该指令无法执行,可能发生了一些内部VM错误;

  • 或者指令无法执行,因为抛出了标准 Java 异常。


执行方法代码如下:

/// Executes the whole methodimpl<'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);
// Move pc to the next instruction, _before_ executing it, // since we want a "goto" to override this 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) => { // Bubble exception up to the caller return Err(MethodCallFailed::ExceptionThrown(exception)); } Ok(Some(catch_handler_pc)) => { // Re-push exception on the stack and continue // execution of this method from the catch handler self.stack.push(Value::Object(exception.0))?; self.pc = catch_handler_pc; } } } } } }}

我知道这段代码中有相当多的实现细节,但我希望它能让大有了解如何使用 Rust的Result和模式匹配很奇妙地映射到上述行为。

不得不说我对自己写的这段代码感到由衷地自豪。😊

垃圾收集 


rjvm最后的里程碑是垃圾收集器的实现。


我选择的算法是一个停止世界。原因很简单,因为没有线程!实现半空间复制收集器。


我已尼算法(https://en.wikipedia.org/wiki/Cheney%27s_algorithm的一个(较差的体,但我真的应该去实现真正的东西......😅


这个算是将可用内存分成两部分,称为半空间:一部分将处于活动状态并用于分配对象,另一部分将不再使用。


当空间满了的时候,将触发垃圾收集,所有活动对象将被复制到另一个半空间。然后,所有对象的引用都将被更新,以便它们被指向新的副本。最后,两者的角色将互换——类似于蓝绿(https://www.redhat.com/en/topics/devops/what-is-blue-green-deployment)部署的工作原理。

该算法具有以下特点:

  • 很显然,它浪费了大量内存(就是最大内存的一半!);

  • 分配速度非常快(碰撞指针);

  • 复制和压缩对象,意味着它不必处理内存碎片;

  • 由于更好的缓存行利用率,压缩对象可以提高性能。

当然,真正的 Java VM 使用更复杂的算法,通常是分代垃圾收集器,例如 G1 或并行 GC,它们使用复制策略的演变版本。

结论 


在写rjvm的过程中,我学到了很多,得到了甚多乐趣。当然,不能要求从副业项目中得到更多......也许下次我会选择一些不那么雄心勃勃的东西,来学习另一门新的编程语言!🤭


顺便再说一句,我想说从 Rust 中获得了非常多的乐趣。我认为它是一种很棒的语言,正如我之前写的那样,我很喜欢使用它来实现自己的JVM!

编译:洛逸

作者:安德里亚

https://andreabergia.com/blog/2023/07/i-have-written-a-jvm-in-rust/

推荐阅读:

被 GPT-4 Plus 账号价格劝退了!

世界的真实格局分析,地球人类社会底层运行原理

不是你需要中台,而是一名合格的架构师(附各大厂中台建设PPT)

企业IT技术架构规划方案

论数字化转型——转什么,如何转?

华为干部与人才发展手册(附PPT)

【中台实践】华为大数据中台架构分享.pdf

华为的数字化转型方法论

华为如何实施数字化转型(附PPT)

华为大数据解决方案(PPT)


浏览 146
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报