我用 Rust 编写了一个JVM和大S闹矛盾了?具俊晔深夜垂头丧气独自回家,背影略显孤独落寞

8个月前发布 SanS三石
43 0 0

我用 Rust 编写了一个JVM和大S闹矛盾了?具俊晔深夜垂头丧气独自回家,背影略显孤独落寞

这篇文章是作者分享他如何用 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 dont have real I/O private static native void tempPrint(String value);}

它使用的是真正的 rt.jar,里面包含了 OpenJDK 7 的类 —— 因此,在上面的例子中,java.lang.StackTraceElement 类就是来自真正的 JDK!

我对我所学到的东西感到非常满意,无论是关于 Rust 还是关于如何实现一个虚拟机。我对我实现的一个真正的、可运行的、垃圾回收器感到格外高兴。虽然它很一般,但它是我写的,我很喜欢它。既然我已经达成了我最初的目标,我决定在这里停下来。我知道有一些问题,但我没有计划去修复它们。

概述

在这篇文章中,我将给你介绍我的 JVM 是如何运行的。在接下来的文章中,我将更详细地讨论这里所涉及的一些方面。

我用 Rust 编写了一个JVM和大S闹矛盾了?具俊晔深夜垂头丧气独自回家,背影略显孤独落寞

代码组织

这是一个标准的 Rust 项目。我将其分成了三个包(也就是 crates):

reader,它能够读取 .class 文件,并包含了一些类型,用于模型化它们的内容;

vm,包含了一个可以作为库执行代码的虚拟机;

vm_cli,包含了一个非常简单的命令行启动器,用于运行 VM,这与 java 可执行文件的精神是一致的。

我正在考虑将 reader 包提取到一个单独的仓库中,并发布到 crates.io,因为它实际上可能对其他人有所帮助。

我用 Rust 编写了一个JVM和大S闹矛盾了?具俊晔深夜垂头丧气独自回家,背影略显孤独落寞

解析 .class 文件

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

类的元数据,如其名称或源文件名称

超类名称

实现的接口

字段,连同它们的类型和注解

方法和:

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

元数据,如 throws 子句、注解、泛型信息

字节码,以及一些额外的元数据,如异常处理器表和行号表。

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

我用 Rust 编写了一个JVM和大S闹矛盾了?具俊晔深夜垂头丧气独自回家,背影略显孤独落寞

执行方法

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

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

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

我用 Rust 编写了一个JVM和大S闹矛盾了?具俊晔深夜垂头丧气独自回家,背影略显孤独落寞

建模值和对象

Value 类型用于模拟局部变量、栈元素或对象字段可能的值,实现如下:

/// 模拟一个可以存储在局部变量或操作数栈中的通用值#[derive(Debug, Default, Clone, PartialEq)]pub enum Valuea> { /// 一个未初始化的元素,它不应该出现在操作数栈上,但它是局部变量的默认状态 #[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), /// Models a null object Null,}

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

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

我用 Rust 编写了一个JVM和大S闹矛盾了?具俊晔深夜垂头丧气独自回家,背影略显孤独落寞

执行指令

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

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

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

另有一类特殊的指令是那些可以调用另一个方法的指令。解析应调用哪个方法有多种方式:虚拟或静态查找是主要方式,但还有其他方式。解析正确的指令后,rjvm 将向调用堆栈添加一个新帧,并启动方法的执行。除非方法的返回值为 void,否则将把返回值推到堆栈上,并恢复执行。

Java 字节码格式相当有趣,我打算专门发一篇文章来讨论各种类型的指令。

我用 Rust 编写了一个JVM和大S闹矛盾了?具俊晔深夜垂头丧气独自回家,背影略显孤独落寞

异常处理

异常处理是一项复杂的任务,因为它打破了正常的控制流,可能会提前从方法中返回(并在调用堆栈中传播!)。尽管如此,我对自己实现的方式感到相当满意,接下来我将展示一些相关的代码。

首先你需要知道,任何一个 catch 块都对应于方法异常表的一个条目,每个条目包含了覆盖的程序计数器范围、catch 块中第一条指令的地址,以及该块能捕获的异常类名。

接着,CallFrame::execute_instruction 的签名如下:

fn execute_instruction( &mut self, vm: &mut Vm<a>, call_stack: &mut CallStacka>, instruction: Instruction,) -> Resulta>, MethodCallFaileda

其中的类型定义为:

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

标准的 Rust Result 类型是:

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

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

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

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

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

无法执行指令,因为抛出了一个标准的 Java 异常。

因此,执行方法的代码如下:

/// 执行整个方法impl CallFrame { pub fn execute( &mut self, vm: &mut Vm, call_stack: &mut CallStack, ) -> MethodCallResult { 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 和模式匹配如何很好地映射到上述行为描述。我必须说我对这段代码感到相当自豪。

我用 Rust 编写了一个JVM和大S闹矛盾了?具俊晔深夜垂头丧气独自回家,背影略显孤独落寞

垃圾回收

在 rjvm 中,最后一个里程碑是实现垃圾回收器。我选择的算法是一个停止 – 世界(这显然是由于没有线程!)半空间复制收集器。我实现了 Cheney 的算法的一个较差的变体 – 但我真的应该去实现真正的 Cheney 算法。

这个算法的思想是将可用内存分成两部分,称为半空间:一部分将处于活动状态并用于分配对象,另一部分将不被使用。当活动的半空间满了,将触发垃圾收集,所有存活的对象都会被复制到另一个半空间。然后,所有对象的引用都将被更新,以便它们指向新的副本。最后,两者的角色将被交换 – 这与蓝绿部署的工作方式类似。

我用 Rust 编写了一个JVM和大S闹矛盾了?具俊晔深夜垂头丧气独自回家,背影略显孤独落寞

我用 Rust 编写了一个JVM和大S闹矛盾了?具俊晔深夜垂头丧气独自回家,背影略显孤独落寞

我用 Rust 编写了一个JVM和大S闹矛盾了?具俊晔深夜垂头丧气独自回家,背影略显孤独落寞

我用 Rust 编写了一个JVM和大S闹矛盾了?具俊晔深夜垂头丧气独自回家,背影略显孤独落寞

这个算法有以下特点:

显然,它浪费了大量的内存(可能的最大内存的一半!);

分配操作非常快(只需移动一个指针);

复制并压缩对象意味着无需处理内存碎片问题;

压缩对象可以提高性能,因为它更好地利用了缓存行。

实际的 Java 虚拟机使用了更为复杂的算法,通常是分代垃圾收集器,如 G1 或并行 GC,这些都使用了复制策略的进化版本。

我用 Rust 编写了一个JVM和大S闹矛盾了?具俊晔深夜垂头丧气独自回家,背影略显孤独落寞

结论

在编写 rjvm 的过程中,我学到了很多,也很有趣。从一个小项目中能学这么多,我已经很满足了。也许下次我在学习新的编程语言时会选择一个稍微不那么难的项目!

顺便说一句,使用 Rust 语言写代码给我带来了很好的编程体验。正如我之前写过的,我认为它是一种很棒的语言,我在用它来实现我的 JVM 时,确实享受到了它带来的各种乐趣!

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

© 版权声明

相关文章

没有相关内容!

暂无评论

您必须登录才能参与评论!
立即登录
none
暂无评论...