我用 Rust 編寫了一個 JVM

這篇文章是作者分享他如何用 Rust 編寫一個 Java 虛擬機(JVM)的經驗。他強調這是一個玩具級別的 JVM,主要用於學習目的,並非嚴肅的實現。儘管如此,他實現了一些非瑣碎的功能,如控制流語句、對象創建、方法調用、異常處理、垃圾收集等。他還詳細介紹了代碼組織、文件解析、方法執行、值和對象的建模、指令執行、異常處理和垃圾收集等方面的實現細節。

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

最近,我一直在學習 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 是如何運行的。在接下來的文章中,我將更詳細地討論這裏所涉及的一些方面。

01 代碼組織

這是一個標準的 Rust 項目。我將其分成了三個包(也就是 crates):

我正在考慮將 reader 包提取到一個單獨的倉庫中,併發布到 crates.io,因爲它實際上可能對其他人有所幫助。

02 解析 .class 文件

衆所周知,Java 是一種編譯型語言 —— javac 編譯器將你的 .java 源文件編譯成各種 .class 文件,通常分佈在 .jar 文件中,這只是一個 zip 文件。因此,執行一些 Java 代碼的第一件事就是加載一個 .class 文件,其中包含了編譯器生成的字節碼。一個類文件包含了各種東西:

如上所述,對於 rjvm,我創建了一個單獨的包,名爲 reader,它可以解析一個類文件,並返回一個 Rust 結構,該結構模型化了一個類及其所有內容。

03 執行方法

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 中,這些都與每個調用棧幀相關聯。

04 建模值和對象

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 用於存儲字段值。然而,當我實現垃圾收集器時,我修改了這個結構,使用了更低級別的實現,其中包含了大量的指針和類型轉換,相當於 C 語言的風格!在當前的實現中,一個 AbstractObject(模擬一個 “真實” 的對象或數組)僅僅是一個指向字節數組的指針,這個數組包含幾個頭部字節,然後是字段的值。

05 執行指令

執行方法意味着逐一執行其字節碼指令。JVM 擁有一長串的指令(超過兩百條!),在字節碼中由一個字節編碼。許多指令後面跟有參數,且一些具有可變長度。在代碼中,這由類型 Instruction 來模擬:

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

如上所述,方法的執行將保持一個堆棧和一組本地變量,指令通過索引引用它們。它還會將程序計數器初始化爲零 - 即下一條要執行的指令的地址。指令將被處理,程序計數器會更新 - 通常向前推進一格,但各種跳轉指令可以將其移動到不同的位置。這些用於實現所有的流控制語句,例如 if,for 或 while。

另有一類特殊的指令是那些可以調用另一個方法的指令。解析應調用哪個方法有多種方式:虛擬或靜態查找是主要方式,但還有其他方式。解析正確的指令後,rjvm 將向調用堆棧添加一個新幀,並啓動方法的執行。除非方法的返回值爲 void,否則將把返回值推到堆棧上,並恢復執行。

Java 字節碼格式相當有趣,我打算專門發一篇文章來討論各種類型的指令。

06 異常處理

異常處理是一項複雜的任務,因爲它打破了正常的控制流,可能會提前從方法中返回(並在調用堆棧中傳播!)。儘管如此,我對自己實現的方式感到相當滿意,接下來我將展示一些相關的代碼。

首先你需要知道,任何一個 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 和模式匹配如何很好地映射到上述行爲描述。我必須說我對這段代碼感到相當自豪。

07 垃圾回收

在 rjvm 中,最後一個里程碑是實現垃圾回收器。我選擇的算法是一個停止 - 世界(這顯然是由於沒有線程!)半空間複製收集器。我實現了 Cheney 的算法的一個較差的變體 - 但我真的應該去實現真正的 Cheney 算法。

這個算法的思想是將可用內存分成兩部分,稱爲半空間:一部分將處於活動狀態並用於分配對象,另一部分將不被使用。當活動的半空間滿了,將觸發垃圾收集,所有存活的對象都會被複制到另一個半空間。然後,所有對象的引用都將被更新,以便它們指向新的副本。最後,兩者的角色將被交換 - 這與藍綠部署的工作方式類似。

這個算法有以下特點:

實際的 Java 虛擬機使用了更爲複雜的算法,通常是分代垃圾收集器,如 G1 或並行 GC,這些都使用了複製策略的進化版本。

08 結論

在編寫 rjvm 的過程中,我學到了很多,也很有趣。從一個小項目中能學這麼多,我已經很滿足了。也許下次我在學習新的編程語言時會選擇一個稍微不那麼難的項目!

順便說一句,使用 Rust 語言寫代碼給我帶來了很好的編程體驗。正如我之前寫過的,我認爲它是一種很棒的語言,我在用它來實現我的 JVM 時,確實享受到了它帶來的各種樂趣!

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/BBGtwvV5_oISFPU3IC-bkQ