Eine in Rust geschriebene JVM
(andreabergia.com)rjvm, ein Side-Project zum Lernen von Rust, ist zwar eine JVM als Lern- und Toy-Projekt, implementiert aber tatsächlich das Einlesen echter.class- undjar-Dateien sowie die Ausführung von Java-Bytecode- Nicht unterstützt werden Threads, Reflection, Annotationen, I/O, ein JIT-Compiler und String Interning, aber Kontrollfluss, Objekterzeugung, Methodenaufrufe, Exceptions und Garbage Collection sind enthalten
- Die Beispiele verwenden das echte
rt.jaraus OpenJDK 7, sodass Klassen wiejava.lang.StackTraceElementaus einem echten JDK geladen werden - Der Code ist in die drei Rust-Crates
reader,vmundvm_cliaufgeteilt und behandelt alles von.class-Parsing über den Call Stack und Native Methods bis hin zur Modellierung von Werten und Objekten - Der letzte Meilenstein war ein stop-the-world semispace copying collector; nach Erreichen des Ziels wird das Projekt eingestellt, und es gibt auch keinen Plan, bekannte Bugs zu beheben
Die Lern-Rust-JVM rjvm
rjvmist eine Toy Java Virtual Machine, die zum Lernen von Rust gebaut wurde- Der Code ist auf GitHub öffentlich verfügbar
- Es handelt sich eher um eine Implementierung zu Lernzwecken als um eine ernsthafte JVM-Implementierung; deshalb werden folgende Funktionen nicht unterstützt
- Threads
- Reflection
- Annotationen
- I/O
- JIT-Compiler
- String Interning
- Generics standen anfangs auf der Liste der nicht unterstützten Features, funktionieren aber tatsächlich
Wie weit ist die Implementierung?
rjvmist nicht nur ein einfacher Parser, sondern implementiert direkt verschiedene JVM-Funktionen- Kontrollfluss wie
ifundfor - Primitive Typen und Objekterzeugung
- Aufrufe virtueller und statischer Methoden
- Exception-Behandlung
- Garbage Collection
- Klassenauflösung aus
jar-Dateien
- Kontrollfluss wie
- Im Testcode sind Beispiele enthalten, die
Throwable,ExceptionundStackTraceElementverwenden - Da echtes I/O fehlt, wird statt
System.out.printlneine Native Method namenstempPrintverwendet - Die Beispiele verwenden das echte
rt.jaraus OpenJDK 7, und auchjava.lang.StackTraceElementstammt aus dem echten JDK
Drei Rust-Crates
- Das Projekt ist ein normales Rust-Projekt und besteht aus drei Crates
reader: liest.class-Dateien ein und enthält Typen, die deren Inhalt modellierenvm: stellt eine ausführbare virtuelle Maschine als Bibliothek bereitvm_cli: enthält einen einfachen Kommandozeilen-Launcher ähnlich derjava-Executable
- Es wird geprüft, den
reader-Crate in ein eigenes Repository auszulagern und auf crates.io zu veröffentlichen
Parsing von .class-Dateien
- Java-Code wird mit
javaczu.class-Dateien kompiliert und meist in.jar-Dateien ausgeliefert, die gewöhnlich imzip-Format vorliegen - Um Java-Code auszuführen, muss zuerst die vom Compiler erzeugte
.class-Datei geladen werden, die den Bytecode enthält - Eine Class-Datei enthält außerdem Informationen, die für Ausführung und Typauflösung benötigt werden
- Metadaten wie Klassenname und Name der Quelldatei
- Name der Oberklasse
- implementierte Interfaces
- Felder und Feldtypen, Annotationen
- Method Descriptors,
throws-Klauseln, Annotationen, Generics-Informationen - Bytecode, Exception-Handler-Tabellen, Zeilennummern-Tabellen
- Der
reader-Crate parst Class-Dateien und gibt eine Rust-Struct zurück, die die Klasse und ihren Inhalt modelliert
Methodenausführung und Call Stack
- Die wichtigste API des
vm-Crates istVm::invoke, die zum Ausführen von Methoden verwendet wird - Jede laufende Methode besitzt im
CallStackgenau einenCallFrame - Beim Ausführen von
mainist der Call Stack anfangs leer, und für die Ausführung wird ein neuer Frame angelegt - Bei jedem Funktionsaufruf wird ein neuer Frame zum Call Stack hinzugefügt; wenn die Methodenausführung endet, wird dieser Frame wieder entfernt
- Die meisten Methoden sind in Java-Bytecode implementiert, aber
rjvmunterstützt auch Native Methods- Native Methods sind keine Java-Bytecode-Methoden, sondern werden direkt von der JVM selbst implementiert
- Beispiele sind
System::currentTimeMillis,System::arraycopyundThrowable::fillInStackTrace - In
rjvmsind sie als Rust-Funktionen implementiert
- Die JVM ist eine stackbasierte virtuelle Maschine, daher arbeiten Bytecode-Instruktionen überwiegend auf einem Wertestack
- Zu jedem Call Frame gehören ein Wertestack und ein Satz lokaler Variablen, die über Indizes identifiziert werden
Modellierung von Werten und Objekten
Valuemodelliert Werte, die in lokalen Variablen, Stack-Elementen oder Objektfeldern gespeichert werden könnenValueist als Rust-enumimplementiert und umfasst folgende ZuständeUninitializedInt(i32)Long(i64)Float(f32)Double(f64)Object(AbstractObject<'a>)Null
- Sum Types wie Rust-
enumeignen sich gut dafür auszudrücken, dass ein einzelner Wert einer von mehreren Typen sein kann - Die Objektspeicherung begann zunächst mit einer einfachen
Object-Struct auf Basis vonVec<Value>, die eine Klassenreferenz und Feldwerte enthält - Nach der Implementierung des Garbage Collectors wurde sie durch eine Low-Level-Implementierung mit vielen Pointern und Casts ersetzt
- Das aktuelle
AbstractObjectmodelliert reale Objekte oder Arrays und ist ein Byte-Array-Pointer mit einigen Header-Words und den Feldwerten
Ausführung von Bytecode-Instruktionen
- Die Ausführung einer Methode ist ein Prozess, bei dem Bytecode-Instruktionen nacheinander verarbeitet werden
- Es gibt mehr als 200 JVM-Instruktionen, und sie sind im Bytecode als 1 Byte kodiert
- Viele Instruktionen haben nachfolgende Argumente, und einige sind variabel lang
rjvmmodelliert Java-Bytecode-Instruktionen mit dem TypInstruction- Bei der Methodenausführung werden ein Wertestack und ein Array lokaler Variablen geführt, und der Program Counter für die nächste auszuführende Instruktion wird mit 0 initialisiert
- Normalerweise wird der Program Counter nach einer Instruktion auf die nächste Instruktion gesetzt, Sprunginstruktionen können ihn aber an eine andere Position verschieben
- Sprunginstruktionen werden zur Implementierung von Kontrollflusskonstrukten wie
if,forundwhileverwendet - Es gibt außerdem eine eigene Instruktionsfamilie für Aufrufe anderer Methoden
- Die Entscheidung, welche Methode aufgerufen wird, kann etwa über virtuelle oder statische Auflösung erfolgen
- Nach der Auflösung der Zielmethode wird ein neuer Frame zum Call Stack hinzugefügt und die Ausführung beginnt
- Ist der Rückgabewert nicht
void, wird er auf den Stack gepusht und die Ausführung fortgesetzt
Exception-Behandlung
- Exceptions durchbrechen den normalen Kontrollfluss, führen zu einem vorzeitigen Return aus einer Methode und können sich über den Call Stack ausbreiten; das macht die Implementierung komplex
- Jeder
catch-Block entspricht einem Eintrag in der Exception-Tabelle einer Methode - Ein Eintrag in der Exception-Tabelle enthält die Informationen, die nötig sind, um einen Handler zu finden
- den gültigen Bereich des Program Counters
- die Adresse der ersten Instruktion des
catch-Blocks - den Namen der Exception-Klasse, die der Block abfängt
CallFrame::execute_instructionverwendet RustsResult, um das Ergebnis der Instruktionsausführung darzustellen- Das Ergebnis der Instruktionsausführung ist in vier Zustände unterteilt
- erfolgreich, und die aktuelle Methodenausführung wird fortgesetzt
- erfolgreich, und wegen einer
return-Instruktion endet die aktuelle Methode mit einem Rückgabewert - Ausführung wegen eines internen VM-Fehlers fehlgeschlagen
- Ausführung wegen einer geworfenen Java-Exception fehlgeschlagen
- Die Methodenausführungsschleife parst eine Instruktion, setzt den Program Counter auf die nächste Adresse und führt dann die Instruktion aus
- Tritt eine Exception auf, wird ein passender Exception-Handler für die aktuelle Instruktionsposition gesucht
- Gibt es keinen Handler, wird die Exception an den Aufrufer weitergereicht
- Gibt es einen Handler, wird das Exception-Objekt erneut auf den Stack gepusht und die Ausführung an der Position des
catch-Handlers fortgesetzt
- Rusts
Resultund Pattern Matching eignen sich gut dazu, dieses Verhalten in der Codestruktur abzubilden
Garbage Collection
- Der letzte große Meilenstein von
rjvmwar die Implementierung eines Garbage Collectors - Als Algorithmus wurde ein stop-the-world semispace copying collector gewählt
- Da es keine Threads gibt, ergibt sich ein stop-the-world-Ansatz ganz natürlich
- Die Implementierung ist eine einfachere Variante von Cheney's algorithm; der Code befindet sich in gc.rs
- Dieser Ansatz teilt den verfügbaren Speicher in zwei Semispaces
- einer ist der aktive Bereich für Objektallokationen
- der andere bleibt ungenutzt
- ist der aktive Bereich voll, werden lebende Objekte in den anderen Semispace kopiert
- alle Objektreferenzen werden so aktualisiert, dass sie auf die neuen Kopien zeigen
- anschließend werden die Rollen der beiden Semispaces vertauscht
- Das Verfahren wird als ähnlich zu Blue-Green Deployment beschrieben
- Die Vor- und Nachteile des Algorithmus sind klar
- wegen des Halbierungsprinzips kann höchstens die Hälfte des Speichers genutzt werden, was zu erheblicher Speicherverschwendung führt
- Allokation ist sehr schnell, da nur ein Pointer erhöht wird
- durch das Kopieren und Kompaktieren von Objekten muss Speicherfragmentierung nicht gesondert behandelt werden
- die Objektkompaktierung kann durch bessere Cache-Line-Nutzung die Performance steigern
- Echte Java-VMs verwenden normalerweise ausgefeiltere generational garbage collectors wie G1 oder Parallel GC
Das Ende des Projekts
- Beim Bau von
rjvmwurden viel über Rust und die Implementierung virtueller Maschinen gelernt, besonders zufriedenstellend war die Umsetzung eines tatsächlich funktionierenden Garbage Collectors - Der Garbage Collector ist zwar nicht besonders ausgereift, funktioniert aber tatsächlich
- Da das ursprüngliche Ziel erreicht wurde, wird das Projekt an dieser Stelle beendet
- Es gibt bekannte Bugs, aber keinen Plan, sie zu beheben
- Rust war eine Sprache, die beim Implementieren einer JVM viel Freude gemacht hat; in weiteren Beiträgen soll
rjvmund die Funktionsweise der JVM noch detaillierter behandelt werden
1 Kommentare
Hacker-News-Kommentare
Der schwierige Teil bei der Implementierung eines Garbage Collectors ist sicherzustellen, dass alle Referenzen korrekt als Wurzeln erfasst werden, besonders bei einem verschiebenden Collector.
Die Methode
do_garbage_collectionist alsunsafemarkiert[1], aber es gibt keine Erklärung, was der Aufrufer garantieren muss, damit sie sicher aufgerufen werden kann.Ich frage mich, wie sichergestellt wird, dass alle Referenzen auf den Heap als Wurzeln erfasst werden, und das ist kein triviales Problem[2][3][4].
Ich habe außerdem das Repository geklont und
cargo testausgeführt, und alle Tests schlagen mit dem Fehlershould be able to add entries to the classpath: InvalidEntry(".../vm/rt.jar")fehl:vm/tests/integration/real_code_tests.rs:15:10[1] https://github.com/andreabergia/rjvm/blob/be9c54066c64a82879...
[2] https://manishearth.github.io/blog/2021/04/05/a-tour-of-safe...
[3] https://without.boats/blog/shifgrethor-iii/
[4] https://coredumped.dev/2022/04/11/implementing-a-safe-garbag...
Daher kann sie diesen Stack durchlaufen, um Parameter und lokale Variablen zu finden und als Wurzeln zu verwenden.
Dieser Ansatz kostet Performance, macht das Nachverfolgen für den Garbage Collector aber viel einfacher und erleichtert auch die Implementierung von Nebenläufigkeits- und Kontrollfluss-Primitiven wie Coroutines oder Continuations.
Tolles Projekt, Glückwunsch dazu.
Allerdings wirkt der Punkt „Nicht unterstützt: Generics“ etwas seltsam.
Ich frage mich, was es überhaupt bedeuten soll, Generics in einer JVM zu unterstützen.
Auf Bytecode-Ebene müsste man wegen Type Erasure doch einfach alles als
Object, also als Referenztyp, behandeln können, oder? Oder ist der Parser für Klassendefinitionen gemeint? Selbst dann scheint es abgesehen von der Grundsyntax keine Logik zu geben, die prüft, ob die Class Files gültig sind.Eigentlich muss man nur das Signature-Attribut lesen, das die Generic-Informationen von Klassen, Methoden und Feldern enthält (https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.ht...).
Ich habe es gerade tatsächlich getestet, und auch der folgende Code funktioniert :-)
public class Generic { public static void main(String[] args) { List strings = new ArrayList(10); strings.add("hey"); strings.add("hackernews"); for (String s : strings) { tempPrint(s); } } private static native void tempPrint(String value); }checkcastgemeint: https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.ht...Das wird zum Beispiel erzeugt, wenn man Code wie
final Main value = list.get(0);schreibt.http://henrikeichenhardt.blogspot.com/2013/05/how-are-java-g...
Dass es dagegen kein String-Interning gibt, ist sehr seltsam. Die Implementierung ist auch eher trivial, und ohne das ist es schwer, das noch eine JVM zu nennen.
Dass Strings nach Referenz gleich werden, ist wichtig und Teil der JLS.
Dass es keine Threads gibt, macht den gesamten Versuch eher zu einem Spielzeugprojekt.
Wirklich großartig. Als ich 1992 zu den Java-Arbeiten stieß, hieß es noch Oak, und die Gruppe, in der ich war, schaute in die Richtung, ein komplettes Betriebssystem in Java zu schreiben
Die Idee war, die Angriffsfläche eines eingebetteten Betriebssystems zu verringern, indem nur das absolut Nötige als „Maschinencode“, also als native Methoden, übrig bleibt
Ursprünglich war Java dafür gedacht, auf Dingen wie Fernsehern oder Haushaltsgeräten zu laufen, und damals wurden die nativen Methoden nicht in Rust, sondern in C geschrieben
Eine in Rust geschriebene JVM bringt in diesen gesamten Prozess ziemlich starke Speichersicherheit ein
Es schreibt viel Betriebssystemlogik in Java oder Kotlin, mischt aber gleichzeitig viele Systemdienste in nativem Code hinein und verbindet sie über das bekannte oder berüchtigte Binder-IPC
[0] - https://en.wikipedia.org/wiki/Singularity_(operating_system)
[1] - https://en.wikipedia.org/wiki/Midori_(operating_system)
Sehenswert ist auch https://jacobin.org/ als in Go geschriebene Implementierung von JVM 17
Die Jacobiner waren ein revolutionärer politischer Klub der Französischen Revolution der 1790er Jahre und sind auch der Name des Magazins auf https://jacobin.com
Ich frage mich, ob man wegen des Lifetime-Parameters in dieser Signatur an Grenzen gestoßen ist
fn execute_instruction(&mut self, vm: &mut Vm<'a>, call_stack: &mut CallStack<'a>, instruction: Instruction) -> Result, MethodCallFailed<'a>>Wenn man der
Err-Variante vonResulteine Lifetime hinzufügt und diese Lifetime invariant ist, wie hier wegenvmundcall_stack, kann man normalerweise weder den?-Operator noch frühe Rückgaben verwenden[1]Dadurch wird die Fehlerbehandlung ausführlicher und schwerer lesbar, deshalb frage ich mich, ob das in der Praxis auch so war
[1] https://users.rust-lang.org/t/nll-and-early-return-not-allow...
'anicht für die veränderlichen Referenzen aufvmodercall_stackverwendet wirdDas Problem liegt wohl weniger an der Invarianz selbst als daran, wie Rust die Lifetimes veränderlicher Referenzen ableitet, und dieser Code umgeht das
Dann verstehe ich allerdings nicht, welche Bedeutung
'abeiVMundCallStackhatWenn man eine beliebige uneingeschränkte Lifetime daraus machen kann, sogar
'static[1][2][3], dann erzwingt sie überhaupt keine EinschränkungIch frage mich, was dieser Lifetime-Parameter hier bewirkt und warum man ihn nicht entfernt
[1] https://github.com/andreabergia/rjvm/blob/be9c54066c64a82879...
[2] https://github.com/andreabergia/rjvm/blob/be9c54066c64a82879...
[3] https://github.com/andreabergia/rjvm/blob/be9c54066c64a82879...
Ein großartiges Lernprojekt, und es ist schön zu sehen, dass der Autor Spaß daran hat
Eine VM von Grund auf zu implementieren macht wirklich Spaß, und ich habe bei solcher Arbeit früher viel gelernt
Wenn du einmal Garbage Collection anbinden willst, könnte sich ein Blick auf MMTk lohnen (https://www.mmtk.io/)
Es bietet hochwertige Sammelalgorithmen, die dafür ausgelegt sind, in verschiedene VMs eingesteckt zu werden, und ist in Rust geschrieben
Ich wollte es einmal für ein Spielzeugprojekt verwenden, habe dann aber aufgegeben, weil ich einen Mac habe
Sehr gut gemacht
Eine VM zu bauen macht immer Spaß, und zusammen mit Rusts Typsystem dürfte das eine interessante Lernerfahrung gewesen sein
Falls du auf Jobsuche bist, kannst du mich gern über Twitter, Mastodon oder die Firmen-E-Mail kontaktieren. Anhand der User-ID hier solltest du mich finden können.
Wenn ich solche großartigen Projekte sehe, fühle ich mich regelrecht überwältigt.
Es wäre schön, wenn der ursprüngliche Autor erklären würde, wie man mit Rust anfängt und sich ein gewisses Fundament aneignet, um so etwas versuchen zu können.
Ich möchte nicht zu sehr abschweifen, aber persönlich ringe ich in letzter Zeit ziemlich mit genau diesem Gefühl.
Ich arbeite seit fast 10 Jahren als professioneller Softwareentwickler, und angesichts meiner aktuellen Rolle und meiner tatsächlichen Fähigkeit, Produkte auszuliefern, weiß ich, dass ich kompetent bin und kein Fake-Entwickler.
Wenn ich mir in letzter Zeit aber Entwicklerblogs anschaue, bin ich überwältigt, als wüsste ich nicht genug und wäre kein „echter“ Entwickler.
Ich glaube, dieses Gefühl entsteht, weil ich mir ein ideales Bild eines Entwicklers im Kopf aufgebaut habe und mich mit diesem eingebildeten Maßstab vergleiche.
Ich bewundere Menschen mit viel tiefem Wissen, die sich klar und prägnant ausdrücken, und frage mich gleichzeitig, warum ich nicht so bin.
Wenn die Arbeit vorbei ist und ich mich um die Familie gekümmert habe, bleibt fast keine Energie mehr, noch irgendetwas zu tun, und ich weiß auch, dass Programmieren nicht alles ist, aber ich möchte trotzdem mehr lernen und wachsen.
Ich weiß, dass das weder gesund noch vernünftig ist, aber in letzter Zeit ist dieses Gefühl schwer abzuschütteln.
Früher hatte ich ein wenig VM-Erfahrung und habe vor ein paar Jahren sogar eine kurze Blogserie darüber geschrieben.
In einem früheren Job habe ich auch ein wenig an JVM-Bytecode herumgespielt, um ein sehr ungewöhnliches Kundenproblem zu lösen.
Außerdem habe ich vor ein paar Jahren das großartige https://craftinginterpreters.com/ gelesen und mir dort Ideen geholt.
Aber dieses Projekt war eindeutig groß und komplex.
Es hat viel Zeit gekostet und lag wie viele meiner Side Projects auch ein paar Mal brach, aber ich freue mich, dass ich es fertiggestellt habe :-)
Ich habe mich vor Kurzem intensiv mit Sockets beschäftigt, aber noch vor zwei Wochen hatte ich nur ein grobes Verständnis auf hoher Ebene, das ich mir durch überflogenene Manpages, Dokumentation und Blogposts angeeignet hatte.
Ich wollte die Grundlagen von Networking verstehen und habe beschlossen, so viel wie möglich zu lesen; eine Woche später hatte ich genug gelernt, um Socket-Code in Python und C zu schreiben.
Da ich Python ziemlich gut kann, habe ich nach dem tiefen Einarbeiten auch die
sockets-Bibliothek besser verstanden.Wenn du Technik A in Sprache X besser beherrschen willst, würde ich empfehlen, so viel wie möglich über Technik A zu lesen oder anzuschauen und etwas damit in Sprache Y zu bauen.
Wenn du dann zu Sprache X zurückkehrst, bist du mit den Konzepten rund um Technik A bereits ziemlich vertraut.
Eine einfache Sprach-VM besteht aus einer Darstellung von Objekten im Speicher, einem Bytecode-Interpreter, einem einfachen Garbage Collector und einem Loader.
Den Bytecode-Interpreter kann man als Stack betrachten, als eine Art, Funktionen auf diesem Stack darzustellen, und als Schleife, die jeden Bytecode interpretiert und den Programmzähler weiterbewegt.
Wenn es 0 Stunden sind, ist das natürlich nichts, wofür man kritisiert werden sollte, und es gibt auch viele andere Dinge, auf die man sich konzentrieren muss. Aber wenn jemand über Jahre hinweg im Schnitt 10–20 Stunden pro Woche an Side Projects arbeitet, ist es nicht überraschend, dass dabei beeindruckende Ergebnisse herauskommen.
Um schamlos ein ähnliches Projekt zu bewerben: https://github.com/tenaf0/rust-jvm3
Ich entwickle ein freies Betriebssystem für 386(486)-AT-Klone. Es ist nur ein Hobby und nichts Großes und Professionelles wie GNU :-)
osdev.org, sandpile.org, RBIL und freevga sind ebenfalls nützliche Referenzen.
Der größte Problembereich ist die Hardware-Unterstützung.
Es gibt auch viele gute alte gedruckte Bücher mit Rezepten für Dinge wie zuverlässiges Port-I/O oder undokumentierte Hardware-Tricks.
Intel® 64 and IA-32 Architectures Software Developer’s Manual Combined Volumes: 1, 2A, 2B, 2C, 2D, 3A, 3B, 3C, 3D, and 4
Microsoft MS-DOS Programmer's Reference, einschließlich real-mode-BIOS-Aufrufen
PC Interrupts
Undocumented PC
PC Intern
Programmer's Guide To The EGA, VGA, And Super VGA Cards
Graphics Programming Black Book Special Edition
Es lohnt sich außerdem, Fortschritte in der Betriebssystementwicklung nach der Ära von Monokernel, Mikrokernel und Hybridansätzen experimentell auszuprobieren.
Capability-basierte Architekturen wie seL4 haben mehrere inhärente Vorteile bei Performance und Sicherheit, darunter Capabilities und hervorragende IPC.
Eine POSIX-kompatible Schicht ist ebenfalls wichtig. Selbst eingebettete Betriebssysteme ohne Thread- oder Prozesskonzepte können POSIX implementieren.
Ein Hypervisor lässt sich mit Intel VT-[xd] deutlich leichter ergänzen; ohne das kann man auf Emulation zurückfallen. Übersetzende Emulation bietet sehr gute Performance.
Man muss darin gut werden, Interrupt-Handler zu verallgemeinern und schnell zu machen, Race Conditions zu vermeiden und Lock-Free-Patterns zu verwenden.
Es ist auch nötig, nicht unterstützte Instruktionen einschließlich x87 und MMX umzuschreiben oder abzufangen.
Der Grund, warum reine Mikrokernel gescheitert sind, war die gewachsene Komplexität bei der transaktionalen Reihenfolge und Verwaltung vieler Ressourcen.
Mikrokernel-Architekturen haben theoretisch große Vorteile in Sicherheit und Betrieb, konnten sich in ihrer reinen Form aber nicht breit durchsetzen.