最近,我花了相当多的时间来学习 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 instruction
enum 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 failed
pub enum MethodCallFailed<'a> {
    InternalError(VmError),
    ExceptionThrown(JavaException<'a>),
}

标准 Rust的Result类型是:

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

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

  • 指令执行成功,当前方法可以继续执行(标准情况);
  • 该指令执行成功,并且它是一个返回指令,因此当前方法应该返回(可选)一个返回值;
  • 该指令无法执行,可能发生了一些内部VM错误;
  • 或者指令无法执行,因为抛出了标准 Java 异常。

执行方法的代码如下:

/// Executes the whole method
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);


            // 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)部署的工作原理。

我用 Rust 写了一个 JVM_rust

我用 Rust 写了一个 JVM_rust_02

我用 Rust 写了一个 JVM_后端_03

我用 Rust 写了一个 JVM_堆栈_04

该算法具有以下特点:

  • 很显然,它浪费了大量内存(就是最大内存的一半!);
  • 分配速度非常快(碰撞指针);
  • 复制和压缩对象,意味着它不必处理内存碎片;
  • 由于更好的缓存行利用率,压缩对象可以提高性能。

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

结论 

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

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

我用 Rust 写了一个 JVM_rust_05

编译:洛逸

作者:安德里亚

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