我用 Rust 編寫了一個JVM

綜藝 4936℃

我用 Rust 編寫了一個JVM - 陸劇吧

這篇文章是作者分享他如何用 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 是如何運行的。在接下來的文章中,我將更詳細地討論這裡所涉及的一些方面。

代碼組織

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

  • reader,它能夠讀取 .class 文件,並包含了一些類型,用於模型化它們的內容;

  • vm,包含了一個可以作為庫執行代碼的虛擬機;

  • vm_cli,包含了一個非常簡單的命令行啟動器,用於運行 VM,這與 java 可執行文件的精神是一致的。

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

解析.class文件

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

  • 類的元數據,如其名稱或源文件名稱

  • 超類名稱

  • 實現的介面

  • 欄位,連同它們的類型和註解

  • 方法和:

    • 們的描述符,這是一個字元串,表示每個參數的類型和方法的返回類型

    • 元數據,如 throws 子句、註解、泛型信息

    • 位元組碼,以及一些額外的元數據,如異常處理器表和行號表。

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

vm 包的主要 API 是 Vm::invoke,用於執行方法。它需要一個 CallStack 參數,這個參數會包含多個 CallFrame,每一個 CallFrame 對應一種正在執行的方法。執行 main 方法時,調用棧將初始為空,會創建一個新的棧幀來運行它。然後,每一個函數調用都會在調用棧中添加一個新的棧幀。當一個方法的執行結束時,與其對應的棧幀將被丟棄並從調用棧中移除。

大多數方法會使用 Java 實現,因此將執行它們的位元組碼。然而,rjvm 也支持原生方法,即直接由 JVM 實現,而非在 Java 位元組碼中實現的方法。在 Java API 的「較底層」中有很多此類方法,這些部分需要與操作系統交互(例如進行 I/O)或需要運行時支持。你可能見過的後者的一些示例包括 System::currentTimeMillisSystem::arraycopyThrowable::fillInStackTrace。在 rjvm 中,這些都是通過 Rust 函數來實現的。

JVM 是一種基於棧的虛擬機,也就是說位元組碼指令主要是在值棧上操作。還有一組由索引標識的局部變數,可以用來存儲值並向方法傳遞參數。在 rjvm 中,這些都與每個調用棧幀相關聯。

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(模擬一個「真實」的對象或數組)僅僅是一個指向位元組數組的指針,這個數組包含幾個頭部位元組,然後是欄位的值。

執行指令

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

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

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

另有一類特殊的指令是那些可以調用另一個方法的指令。解析應調用哪個方法有多種方式:虛擬或靜態查找是主要方式,但還有其他方式。解析正確的指令後,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<>'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 和模式匹配如何很好地映射到上述行為描述。我必須說我對這段代碼感到相當自豪。

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

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

我用 Rust 編寫了一個JVM - 陸劇吧

我用 Rust 編寫了一個JVM - 陸劇吧

我用 Rust 編寫了一個JVM - 陸劇吧

我用 Rust 編寫了一個JVM - 陸劇吧

這個演算法有以下特點:

  • 顯然,它浪費了大量的內存(可能的最大內存的一半!);

  • 分配操作非常快(只需移動一個指針);

  • 複製並壓縮對象意味著無需處理內存碎片問題;

  • 壓縮對象可以提高性能,因為它更好地利用了緩存行。

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

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

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

你是在學習新的編程語言時,是否寫過一些有難度或有意思的軟體?歡迎在評論區交流討論。

標籤: 綜藝