我用 Rust 编写了一个JVM

奋斗吧
奋斗吧
擅长邻域:未填写

标签: 我用 Rust 编写了一个JVM

2023-07-19 18:23:32 50浏览

这篇文章是作者分享他如何用 Rust 编写一个 Java 虚拟机(JVM)的经验。他强调这是一个玩具级别的 JVM,主要用于学习目的,并非严肃的实现。尽管如此,他实现了一些非琐碎的功能,如控制流语句、对象创建、方法调用、异常处理、垃圾收集等。他还详细介绍了代码组织、文件解析、方法执行、值和对象的建模、指令执行、异常处理和垃圾收集等方面的实现细节。链接:https://andreabergia.co...

5081e441c8b99f4b359f22e5f673c7d7.gif

这篇文章是作者分享他如何用 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 是如何运行的。在接下来的文章中,我将更详细地讨论这里所涉及的一些方面。


3cf5db14c79f1bb58f62ff495d8055aa.png

代码组织


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

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

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

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

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


974df02c76c528e7348e1c52f42a9e98.png

解析 .class 文件

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

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

  • 超类名称

  • 实现的接口

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

  • 方法和:

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

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

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

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


704f268c789455e4753601a4ff22929f.png

执行方法


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 中,这些都与每个调用栈帧相关联。


46ba025b4b8002084f969f2d9cf39941.png

建模值和对象

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<Value> 用于存储字段值。然而,当我实现垃圾收集器时,我修改了这个结构,使用了更低级别的实现,其中包含了大量的指针和类型转换,相当于 C 语言的风格!在当前的实现中,一个 AbstractObject(模拟一个“真实”的对象或数组)仅仅是一个指向字节数组的指针,这个数组包含几个头部字节,然后是字段的值。

63721443f56120d11b3c15f0d3765497.png

执行指令

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

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

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

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

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

359ed854ea05ec435c3559156b3c702e.png

异常处理

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

首先你需要知道,任何一个 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>>

其中的类型定义为:

/// 指令可能的执行结果
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 和模式匹配如何很好地映射到上述行为描述。我必须说我对这段代码感到相当自豪。


d69ae8969c82787e3887d6e2735a34ac.png

垃圾回收

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

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

364317178d4f957d231b40d592dd3aa6.jpeg

434490bc40275bbf1dea2dd6dbef4dc8.jpeg

f2d8aff3b40881aabf047fab7864f36f.jpeg

e919d4b631956b2086c0a65dcf8f3f8e.jpeg

这个算法有以下特点:

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

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

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

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

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


bfad736c9039652f0d38742bd311ef1e.png

结论

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

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

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

推荐阅读:

▶谷歌、亚马逊、Meta等多家科技公司被爆员工「假工作」,裁员成最终归宿!

小心!别被假的 GitHub 存储库骗了

Stability AI 把绘画门槛打为 0!

e56571e97e39817a6719e1d6d0efd19f.jpeg

好博客就要一起分享哦!分享海报

此处可发布评论

评论(0展开评论

暂无评论,快来写一下吧

展开评论

您可能感兴趣的博客

客服QQ 1913284695