Der Artikel über KVM erwies sich für die Leser als interessant. Deshalb veröffentlichen wir heute eine neue Übersetzung des Artikels von Serge Zaitsev: Wie die Java Virtual Machine unter der Haube funktioniert.
Ob es uns gefällt oder nicht, Java ist eine der am häufigsten verwendeten und am häufigsten verwendeten Programmiersprachen. Allerdings ist nicht jeder Java-Entwickler neugierig genug, um unter die Haube zu schauen und zu sehen, wie die JVM funktioniert.
Ich werde versuchen, ein Spielzeug (und eine unvollständige) JVM zu schreiben, um die Grundprinzipien der Funktionsweise zu zeigen. Ich hoffe, dieser Artikel hat Ihr Interesse geweckt und Sie dazu inspiriert, die JVM weiter zu erkunden.
Unser bescheidenes Ziel
Fangen wir einfach an:
public class Add {
public static int add(int a, int b) {
return a + b;
}
}
Wir kompilieren unsere Klasse mit javac Add.java und das Ergebnis ist Add.class . Diese Klassendatei ist eine Binärdatei, die die JVM ausführen kann. Wir müssen lediglich eine JVM erstellen, die sie korrekt ausführt.
Wenn wir mit einem Hex-Dump in Add.class hineinschauen , werden wir wahrscheinlich nicht allzu beeindruckt sein: Obwohl wir hier noch keine klare Struktur sehen, müssen wir einen Weg finden, sie zu analysieren: Was bedeutet () V und (II) I , < init> und warum beginnt die Datei mit "cafe babe"?
00000000 ca fe ba be 00 00 00 34 00 0f 0a 00 03 00 0c 07 |.......4........|
00000010 00 0d 07 00 0e 01 00 06 3c 69 6e 69 74 3e 01 00 |........<init>..|
00000020 03 28 29 56 01 00 04 43 6f 64 65 01 00 0f 4c 69 |.()V...Code...Li|
00000030 6e 65 4e 75 6d 62 65 72 54 61 62 6c 65 01 00 03 |neNumberTable...|
00000040 61 64 64 01 00 05 28 49 49 29 49 01 00 0a 53 6f |add...(II)I...So|
00000050 75 72 63 65 46 69 6c 65 01 00 08 41 64 64 2e 6a |urceFile...Add.j|
00000060 61 76 61 0c 00 04 00 05 01 00 03 41 64 64 01 00 |ava........Add..|
00000070 10 6a 61 76 61 2f 6c 61 6e 67 2f 4f 62 6a 65 63 |.java/lang/Objec|
00000080 74 00 21 00 02 00 03 00 00 00 00 00 02 00 01 00 |t.!.............|
00000090 04 00 05 00 01 00 06 00 00 00 1d 00 01 00 01 00 |................|
000000a0 00 00 05 2a b7 00 01 b1 00 00 00 01 00 07 00 00 |...*............|
000000b0 00 06 00 01 00 00 00 01 00 09 00 08 00 09 00 01 |................|
000000c0 00 06 00 00 00 1c 00 02 00 02 00 00 00 04 1a 1b |................|
000000d0 60 ac 00 00 00 01 00 07 00 00 00 06 00 01 00 00 |`...............|
000000e0 00 03 00 01 00 0a 00 00 00 02 00 0b |............|
Sie kennen wahrscheinlich eine andere Methode zum Entladen von Klassendateien. Diese ist häufig nützlicher: Jetzt sehen wir unsere Klasse, ihren Konstruktor und ihre Methode. Sowohl der Konstruktor als auch die Methode enthalten mehrere Anweisungen. Es wird mehr oder weniger klar, was unsere add () -Methode tut: Sie lädt zwei Argumente ( iload_0 und iload_1 ), fasst sie zusammen und gibt das Ergebnis zurück. Die JVM ist eine Stapelmaschine, hat also keine Register, alle Befehlsargumente werden auf einem internen Stapel gespeichert und die Ergebnisse werden ebenfalls auf den Stapel übertragen.
$ javap -c Add
Compiled from "Add.java"
public class Add {
public Add();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static int add(int, int);
Code:
0: iload_0
1: iload_1
2: iadd
3: ireturn
}
Klassenlader
Wie erreichen wir das gleiche Ergebnis wie das Javap-Programm? Wie analysiere ich eine Klassendatei?
Wenn wir uns die JVM-Spezifikation ansehen , lernen wir die Struktur der Klassendateien kennen . Es beginnt immer mit einer 4-Byte-Signatur (CAFEBABE), gefolgt von 2 + 2 Byte für die Version. Klingt einfach!
Da wir Bytes, Short-, Int- und Byte-Sequenzen aus einer Binärdatei lesen müssten, starten wir unsere Loader-Implementierung wie folgt:
type loader struct {
r io.Reader
err error
}
func (l *loader) bytes(n int) []byte {
b := make([]byte, n, n)
// ,
//
// ,
if l.err == nil {
_, l.err = io.ReadFull(l.r, b)
}
return b
}
func (l *loader) u1() uint8 { return l.bytes(1)[0] }
func (l *loader) u2() uint16 { return binary.BigEndian.Uint16(l.bytes(2)) }
func (l *loader) u4() uint32 { return binary.BigEndian.Uint32(l.bytes(4)) }
func (l *loader) u8() uint64 { return binary.BigEndian.Uint64(l.bytes(8)) }
// Usage:
f, _ := os.Open("Add.class")
loader := &loader{r: f}
cafebabe := loader.u4()
major := loader.u2()
minor := loader.u2()
Die Spezifikation fordert uns dann auf, den konstanten Pool zu analysieren. Was ist das? Dies ist der Name eines speziellen Teils der Klassendatei, der die zum Ausführen der Klasse erforderlichen Konstanten enthält. Alle Zeichenfolgen, numerischen Konstanten und Referenzen werden dort gespeichert und haben jeweils einen eindeutigen uint16-Index (eine Klasse kann also bis zu 64 KB-Konstanten haben).
Es gibt verschiedene Arten von Konstanten im Pool, von denen jede ihren eigenen Wertesatz enthält. Wir könnten interessiert sein an:
- UTF8: einfaches String-Literal
- Klasse: Index der Zeichenfolge, die den Klassennamen enthält (indirekte Referenz)
- Name und Typ: Index des Typnamens und Deskriptors, der für Felder und Methoden verwendet wird
- Feld- und Methodenreferenzen: Indizes für Klassen und Konstanten von Typname und Typ.
Wie Sie sehen können, beziehen sich die Konstanten im Pool häufig aufeinander. Da wir eine JVM in Go schreiben und es keine Unionstypen gibt, erstellen wir einen Const-Typ, der verschiedene mögliche Konstantenfelder enthält:
type Const struct {
Tag byte
NameIndex uint16
ClassIndex uint16
NameAndTypeIndex uint16
StringIndex uint16
DescIndex uint16
String string
}
type ConstPool []Const
Dann könnten wir gemäß der JVM-Spezifikation die konstanten Pooldaten wie folgt abrufen:
func (l *loader) cpinfo() (constPool ConstPool) {
constPoolCount := l.u2()
// 1
for i := uint16(1); i < constPoolCount; i++ {
c := Const{Tag: l.u1()}
switch c.Tag {
case 0x01: // UTF8 string literal, 2 bytes length + data
c.String = string(l.bytes(int(l.u2())))
case 0x07: // Class index
c.NameIndex = l.u2()
case 0x08: // String reference index
c.StringIndex = l.u2()
case 0x09, 0x0a: // Field and method: class index + NaT index
c.ClassIndex = l.u2()
c.NameAndTypeIndex = l.u2()
case 0x0c: // Name-and-type
c.NameIndex, c.DescIndex = l.u2(), l.u2()
default:
l.err = fmt.Errorf("unsupported tag: %d", c.Tag)
}
constPool = append(constPool, c)
}
return constPool
}
Jetzt vereinfachen wir unsere Aufgabe erheblich, aber in einer echten JVM müssten wir die langen und doppelten Konstantentypen einheitlich behandeln und ein zusätzliches nicht verwendetes konstantes Element einfügen, wie es die JVM-Spezifikation sagt (da konstante Elemente als 32-Bit betrachtet werden).
Implementieren Sie die String- Methode Resolve (index uint16) , um das Abrufen von String-Literalen anhand von Indizes zu vereinfachen :
func (cp ConstPool) Resolve(index uint16) string {
if cp[index-1].Tag == 0x01 {
return cp[index-1].String
}
return ""
}
Jetzt müssen wir ähnliche Helfer hinzufügen, um die Liste der Schnittstellen, Felder und Methoden von Klassen und ihrer Attribute zu analysieren:
func (l *loader) interfaces(cp ConstPool) (interfaces []string) {
interfaceCount := l.u2()
for i := uint16(0); i < interfaceCount; i++ {
interfaces = append(interfaces, cp.Resolve(l.u2()))
}
return interfaces
}
// field
type Field struct {
Flags uint16
Name string
Descriptor string
Attributes []Attribute
}
//
// - Code,
type Attribute struct {
Name string
Data []byte
}
func (l *loader) fields(cp ConstPool) (fields []Field) {
fieldsCount := l.u2()
for i := uint16(0); i < fieldsCount; i++ {
fields = append(fields, Field{
Flags: l.u2(),
Name: cp.Resolve(l.u2()),
Descriptor: cp.Resolve(l.u2()),
Attributes: l.attrs(cp),
})
}
return fields
}
func (l *loader) attrs(cp ConstPool) (attrs []Attribute) {
attributesCount := l.u2()
for i := uint16(0); i < attributesCount; i++ {
attrs = append(attrs, Attribute{
Name: cp.Resolve(l.u2()),
Data: l.bytes(int(l.u4())),
})
}
return attrs
}
Sowohl Felder als auch Methoden werden als Felder dargestellt, was sehr praktisch und zeitsparend ist. Schließlich können wir alles zusammenfügen und unsere Klasse vollständig analysieren:
type Class struct {
ConstPool ConstPool
Name string
Super string
Flags uint16
Interfaces []string
Fields []Field
Methods []Field
Attributes []Attribute
}
func Load(r io.Reader) (Class, error) {
loader := &loader{r: r}
c := Class{}
loader.u8() // magic (u32), minor (u16), major (u16)
cp := loader.cpinfo() // const pool info
c.ConstPool = cp
c.Flags = loader.u2() // access flags
c.Name = cp.Resolve(loader.u2()) // this class
c.Super = cp.Resolve(loader.u2()) // super class
c.Interfaces = loader.interfaces(cp)
c.Fields = loader.fields(cp) // fields
c.Methods = loader.fields(cp) // methods
c.Attributes = loader.attrs(cp) // methods
return c, loader.err
}
Wenn wir uns nun die Informationen über die Klasse ansehen, werden wir sehen, dass er keine Felder hat, zwei Methoden - <der Init> :() das V und das Add: (II von) I von . Was sind römische Zahlen mit Klammern? Dies sind Deskriptoren. Sie bestimmen, welche Arten von Argumenten eine Methode verwendet und was sie zurückgibt. In diesem Fall akzeptiert <init> (die synthetische Methode zum Initialisieren von Objekten beim Erstellen) keine Argumente und gibt nichts zurück (V = void), während die Methode "add" zwei Datentypen int (I = int32) und akzeptiert Gibt eine Ganzzahl zurück.
Bytecode
Wenn wir genau hinschauen, stellen wir fest, dass jede Methode in unserer analysierten Klasse ein Attribut namens „Code“ hat. Dieses Attribut enthält einen Bruchteil der Bytes als Nutzlast. Bytes: In der Spezifikation lesen wir diesmal im Abschnitt Bytecode , dass das Attribut "Code" mit einem Maxstack-Wert (2 Bytes), dann Maxlocals (2 Bytes), der Länge des Codes (4 Bytes) und dann dem tatsächlichen Code beginnt. Unsere Attribute können also folgendermaßen gelesen werden: Ja, wir haben nur 4 und 5 Byte Code in jeder Methode. Was bedeuten diese Bytes?
<init>:
[0 1 0 1 0 0 0 5 42 183 0 1 177 0 0 0 1 0 7 0 0 0 6 0 1 0 0 0 1]
add:
[0 2 0 2 0 0 0 4 26 27 96 172 0 0 0 1 0 7 0 0 0 6 0 1 0 0 0 3]
<init>: maxstack: 1, maxlocals: 1, code: [42 183 0 1 177]
add: maxstack: 2, maxlocals: 2, code: [26 27 96 172]
Wie gesagt, die JVM ist eine Stapelmaschine. Jeder Befehl wird als einzelnes Byte codiert, auf das zusätzliche Argumente folgen können. Wenn wir uns die Spezifikation ansehen, können wir sehen, dass die "add" -Methode die folgenden Anweisungen enthält: Genau wie wir es in der Javap-Ausgabe am Anfang gesehen haben! Aber wie geht das?
26 = iload_0
27 = iload_1
96 = iadd
172 = ireturn
JVM-Frames
Wenn eine Methode in der JVM ausgeführt wird, verfügt sie über einen eigenen Stapel für temporäre Operanden, eigene lokale Variablen und einen auszuführenden Code. Alle diese Parameter werden in einem einzigen Ausführungsrahmen gespeichert. Darüber hinaus enthalten Frames einen Zeiger auf die aktuelle Anweisung (wie weit wir bei der Ausführung des Bytecodes gekommen sind) und einen Zeiger auf die Klasse, die die Methode enthält. Letzteres wird benötigt, um auf den Pool von Klassenkonstanten sowie auf andere Details zuzugreifen.
Erstellen wir eine Methode, die einen Frame für eine bestimmte Methode erstellt, die mit den angegebenen Argumenten aufgerufen wird. Ich werde hier die Schnittstelle {} als Werttyp verwenden, obwohl die Verwendung der richtigen Vereinigungstypen natürlich sicherer wäre.
type Frame struct {
Class Class
IP uint32
Code []byte
Locals []interface{}
Stack []interface{}
}
func (c Class) Frame(method string, args ...interface{}) Frame {
for _, m := range c.Methods {
if m.Name == method {
for _, a := range m.Attributes {
if a.Name == "Code" && len(a.Data) > 8 {
maxLocals := binary.BigEndian.Uint16(a.Data[2:4])
frame := Frame{
Class: c,
Code: a.Data[8:],
Locals: make(
[]interface{},
maxLocals,
maxLocals
),
}
for i := 0; i < len(args); i++ {
frame.Locals[i] = args[i]
}
return frame
}
}
}
}
panic("method not found")
}
Am Ende hatten wir einen Frame mit initialisierten lokalen Variablen, einem leeren Stapel und vorinstalliertem Bytecode. Es ist Zeit, den Bytecode auszuführen:
func Exec(f Frame) interface{} {
for {
op := f.Code[f.IP]
log.Printf("OP:%02x STACK:%v", op, f.Stack)
n := len(f.Stack)
switch op {
case 26: // iload_0
f.Stack = append(f.Stack, f.Locals[0])
case 27: // iload_1
f.Stack = append(f.Stack, f.Locals[1])
case 96:
a := f.Stack[n-1].(int32)
b := f.Stack[n-2].(int32)
f.Stack[n-2] = a + b
f.Stack = f.Stack[:n-1]
case 172: // ireturn
v := f.Stack[n-1]
f.Stack = f.Stack[:n-1]
return v
}
f.IP++
}
}
Schließlich können wir alles zusammenfügen und ausführen, indem wir unsere add () -Methode aufrufen:
f, _ := os.Open("Add.class")
class, _ := Load(f)
frame := class.Frame("add", int32(2), int32(3))
result := Exec(frame)
log.Println(result)
// OUTPUT:
OP:1a STACK:[]
OP:1b STACK:[2]
OP:60 STACK:[2 3]
OP:ac STACK:[5]
5
Also funktioniert alles. Ja, dies ist eine sehr schwache JVM auf minimal, aber sie macht immer noch das, was die JVM tun soll: Laden Sie den Bytecode herunter und interpretieren Sie ihn (natürlich macht die echte JVM viel mehr).
Was fehlt?
Die restlichen 200 Anweisungen, Laufzeiten, OOP-Systeme und ein paar weitere Dinge.
Es gibt 11 Gruppen von Anweisungen, von denen die meisten alltäglich sind:
- Konstanten (schiebt Null, kleine Zahl oder Werte aus dem Konstantenpool auf den Stapel).
- Lädt (schiebt lokale Variablen auf den Stapel). Ähnliche Anweisungen 32.
- Stores ( ). 32 .
- Stack (pop/dup/swap), .
- Math (add/sub/div/mul/rem/shift/logic). , 36 .
- Conversions (int short, int float ..).
- Comparisons (eq/ne/le/…). , if/else.
- Control (goto/return). .
- References. , , .
- Extended. . , , .
- Reserved. 0xca.
Die meisten Anweisungen sind einfach zu implementieren: Sie nehmen ein oder zwei Argumente aus dem Stapel, führen eine Operation mit ihnen aus und senden das Ergebnis. Das einzige, was Sie hier beachten sollten, ist, dass die Anweisungen für lange und doppelte Typen davon ausgehen, dass jeder Wert zwei Slots auf dem Stapel belegt. Daher benötigen Sie möglicherweise zusätzliche push () und pop (), was das Gruppieren von Anweisungen schwierig macht.
Um Referenzen zu implementieren, müssen Sie über das Objektmodell nachdenken: Wie möchten Sie Objekte und ihre Klassen speichern, wie stellen Sie die Vererbung dar, wo werden Instanzfelder und Klassenfelder gespeichert? Außerdem müssen Sie beim Versenden von Methoden hier vorsichtig sein - es gibt mehrere "invoke" -Anweisungen, die sich unterschiedlich verhalten:
- invokestatisch: Rufen Sie eine statische Methode für eine Klasse auf, keine Überraschungen.
- invokespecial: , , <init> .
- invokevirtual: .
- invokeinterface: , invokevirtual, .
- invokedynamic: call site, Java 7, MethodHandles.
Wenn Sie eine JVM in einer Sprache ohne Garbage Collection erstellen , sollten Sie überlegen, wie Sie dies tun: Referenzzählung, Mark-and-Sweep-Algorithmus usw. Behandeln von Ausnahmen durch Implementieren von athrow , Weitergeben über Frames und Behandeln Die Verwendung von Ausnahmetabellen ist ein weiteres interessantes Thema.
Schließlich ist Ihre JVM unbrauchbar, wenn keine Laufzeitklassen vorhanden sind. Ohne java / lang / Object kann man kaum sehen, wie neu funktioniert Anleitung beim Erstellen neuer Objekte. Ihre Laufzeit kann einige generische JRE-Klassen aus den Paketen java.lang, java.io und java.util bereitstellen, oder es kann sich um etwas Domänenspezifischeres handeln. Höchstwahrscheinlich sollten einige Methoden in Klassen nativ implementiert werden und nicht in Java. Dies wirft die Frage auf, wie solche Methoden gefunden und ausgeführt werden können, und ist ein weiterer Randfall für Ihre JVM.
Mit anderen Worten, es ist nicht einfach, die richtige JVM zu erstellen, aber es ist nicht schwer, genau herauszufinden, wie es funktioniert.
Zum Beispiel habe ich nur ein Sommerwochenende gebraucht. Meine JVM hat noch einen langen Weg vor sich, aber die Struktur sieht mehr oder weniger klar aus: https://github.com/zserge/tojvm (Kommentare sind immer willkommen!)
Die eigentlichen Codefragmente aus diesem Artikel sind noch kleiner und stehen hier als Zusammenfassung zur Verfügung .
Wenn Sie mehr erfahren möchten, können Sie kleine JVMs verwenden:
- Mika: https://github.com/kifferltd/open-mika
- Vogel: https://github.com/ReadyTalk/avian
- NanoVM: https://github.com/harbaum/NanoVM
- Luje: https://github.com/davidgiven/luje (großartige JVM für LuaJIT)
Ich hoffe, dieser Artikel hat Ihr Interesse an Java nicht entmutigt. Virtuelle Maschinen machen Spaß, und die virtuelle Java-Maschine verdient einen genauen Blick.
Ich hoffe dir hat mein Artikel gefallen. Sie können meine Arbeit auf Github oder Twitter verfolgen und über RSS abonnieren .