Dies ist der dritte Artikel aus der Serie „Entwicklung einer eigenen Programmiersprache in Java“. Für ein besseres Verständnis sollten Sie den ersten und zweiten Artikel lesen.
In diesem Artikel lernen wir, wie wir unsere Programmiersprache (Jimple) in Java-Bytecode kompilieren. Außerdem vergleichen wir die Ausführungszeit der Jimple-Code-Interpretation (aus dem ersten Artikel) mit dem in Java-Bytecode kompilierten Jimple.
Java-Bytecode
Java-Bytecode ist ein Satz von Anweisungen für die Java Virtual Machine (JVM). Quellcode, der in Java und anderen JVM-kompatiblen Sprachen geschrieben wurde, wird in Bytecode kompiliert. Die JVM führt Bytecode aus, indem sie ihn in Maschinencode umwandelt, der für eine bestimmte Plattform verständlich ist. Jedoch wird Bytecode nicht sofort in Maschinencode umgewandelt, sondern zunächst von der JVM interpretiert. Wenn die JVM erkennt, dass Code häufig ausgeführt wird (oder aus anderen Gründen), kann sie ihn in Maschinencode kompilieren. Dieser Ansatz wird JIT-Kompilierung (Just-In-Time) genannt. Es gibt auch AOT-Compiler (Ahead-Of-Time), die es Anwendungsentwicklern ermöglichen, Programme vor der Ausführung in Maschinencode zu kompilieren. Dies kann die Anwendungsstartzeit verbessern und den Speicherverbrauch reduzieren, was besonders für Microservices und Serverless-Anwendungen nützlich ist.
Lassen Sie uns das berühmte „Hello, world!“ Programm in Java kompilieren und schauen, wie sein Bytecode aussieht. Dafür erstellen wir eine Datei HelloWorld.java mit folgendem Inhalt:
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
Dann führen wir folgenden Befehl aus:
javac HelloWorld.java
Nach der Kompilierung erscheint im aktuellen Verzeichnis eine Datei HelloWorld.class, die den Bytecode enthält. Diese Datei ist normalerweise kleiner als der Quellcode, da Bytecode für die Ausführung durch die Java Virtual Machine optimiert ist. Diese Datei ist binär (binary) und kann mit jedem Hex-Editor betrachtet werden.
Wie aus der Abbildung ersichtlich ist, ist Bytecode in dieser Form für Menschen schwer lesbar. Für eine bequeme Bytecode-Lektüre können wir das spezielle Utility javap verwenden, das Teil des JDK ist. Dieses Utility ermöglicht es, den Inhalt von .class Dateien in lesbarer Form anzuzeigen. Dafür führen wir folgenden Befehl aus:
javap -v HelloWorld
Die Ausgabe wird etwa so aussehen:
public class HelloWorld
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#15 // java/lang/Object."":()V
#2 = Fieldref #16.#17 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #18 // Hello, World!
#4 = Methodref #19.#20 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Class #21 // HelloWorld
#6 = Class #22 // java/lang/Object
#7 = Utf8
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 main
#12 = Utf8 ([Ljava/lang/String;)V
#13 = Utf8 SourceFile
#14 = Utf8 HelloWorld.java
#15 = NameAndType #7:#8 // "":()V
#16 = Class #23 // java/lang/System
#17 = NameAndType #24:#25 // out:Ljava/io/PrintStream;
#18 = Utf8 Hello, World!
#19 = Class #26 // java/io/PrintStream
#20 = NameAndType #27:#28 // println:(Ljava/lang/String;)V
#21 = Utf8 HelloWorld
#22 = Utf8 java/lang/Object
#23 = Utf8 java/lang/System
#24 = Utf8 out
#25 = Utf8 Ljava/io/PrintStream;
#26 = Utf8 java/io/PrintStream
#27 = Utf8 println
#28 = Utf8 (Ljava/lang/String;)V
{
public HelloWorld();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
LineNumberTable:
line 2: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String Hello, World!
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 4: 0
line 5: 8
}
Selbst in dieser Form sieht der Bytecode nicht sehr lesbar aus, aber man kann bereits die Programmstruktur erkennen. Zum Beispiel sehen wir die Symboltabelle, Informationen über Methoden und verwendete Klassen. Man kann auch erkennen, dass die main Methode eine Referenz auf den Standard-Ausgabestrom System.out erhält und dann die println Methode aufruft, um den String „Hello, World!“ auszugeben. Mnemonics (z.B. getstatic, ldc, invokevirtual) sind symbolische Darstellungen von Bytecode-Anweisungen, die von der JVM ausgeführt werden. Wir werden nicht tief in Mnemonics eintauchen, aber das ist genau die Abstraktionsebene, auf der wir bei der Kompilierung von Jimple in Bytecode arbeiten werden.
Stack-Architektur der JVM
Die Java Virtual Machine hat eine stack-orientierte Architektur. Das bedeutet, dass Operationen auf Werten ausgeführt werden, die in einem Stack gespeichert sind. Die virtuelle Maschine verwendet den Stack, um Operanden, lokale Variablen und Zwischenergebnisse von Berechnungen zu speichern. Zum Beispiel werden bei der Addition zweier Zahlen beide Zahlen auf den Stack gelegt, dann wird die Additionsoperation ausgeführt, die diese beiden Zahlen vom Stack nimmt, sie addiert und das Ergebnis zurück auf den Stack legt.
Als Beispiel kompilieren wir eine Methode, die zwei Ganzzahlen addiert:
int sum(int a, int b) {
return a + b;
}
Der Bytecode für diese Methode würde folgendermaßen aussehen:
iload_1 // Lade das erste int-Argument auf den Stack iload_2 // Lade das zweite int-Argument auf den Stack iadd // Nimm die obersten beiden Werte vom Stack, addiere sie und lege das Ergebnis zurück auf den Stack ireturn // Der oberste Stack-Wert wird genommen und an die aufrufende Methode zurückgegeben }
Ein einzelner Stack reicht jedoch nicht für eine effiziente Methodenausführung aus, daher verwendet die virtuelle Maschine das Konzept von Frames. Ein Frame ist eine Datenstruktur, die alle für die Ausführung einer bestimmten Methode notwendigen Daten enthält, einschließlich lokaler Variablen, Operanden und Informationen über den Stack-Zustand. Jedes Mal, wenn eine Methode oder ein Konstruktor aufgerufen wird, wird ein neuer Frame erstellt und auf den Call Stack gelegt. Wenn eine Methode abgeschlossen wird, wird ihr Frame vom Call Stack entfernt. Der Rückgabewert der Methode wird auf den Operanden-Stack der aufrufenden Methode gelegt.
Womit Bytecode generieren?
Zur Kompilierung von Jimple in Java-Bytecode werden wir die bewährte ASM Bibliothek verwenden. Sie ermöglicht es, mit Java-Bytecode auf niedriger Ebene zu arbeiten und bietet auch nützliche Werkzeuge zur Validierung und Analyse des generierten Codes.
Ein Beispiel für die Generierung des „Hello, world!“ Programms mit ASM könnte folgendermaßen aussehen:
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
cw.visit(V17, ACC_PUBLIC, "HelloWorld", null, "java/lang/Object", null);
// Standard-Konstruktor generieren
MethodVisitor mv = cw.visitMethod(ACC_PUBLIC, "", "()V", null, null);
mv.visitCode();
mv.visitVarInsn(ALOAD, 0);
mv.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "", "()V", false);
mv.visitInsn(RETURN);
mv.visitMaxs(0, 0);
mv.visitEnd();
// public static void main(String[] args) generieren
mv = cw.visitMethod(ACC_PUBLIC + ACC_STATIC, "main", "([Ljava/lang/String;)V", null, null);
mv.visitCode();
// System.out
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
// "Hello, World!" String
mv.visitLdcInsn("Hello, World!");
// println Aufruf
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
mv.visitInsn(RETURN);
mv.visitEnd();
cw.visitEnd();
// Generierte Klasse in Datei schreiben
try (FileOutputStream fos = new FileOutputStream("HelloWorld.class")) {
fos.write(cw.toByteArray());
}
Ab JDK 24 können Class-Dateien ohne Drittanbieter-Bibliotheken geparst, generiert und transformiert werden, da eine eingebaute API hinzugefügt wurde, die diese Operationen vereinfacht (JEP 484: Class-File API).
Es gibt noch einen anderen Weg, Bytecode aus einer neuen Sprache zu generieren – zuerst Jimple in eine andere Sprache (z.B. Java oder Kotlin) umzuwandeln und dann mit einem Standard-Compiler (z.B. javac für Java) zu kompilieren. Diese Art der Kompilierung wird Transpilierung genannt.
Wieder JimpleBaseVisitor
Zur Kompilierung von Jimple in Java-Bytecode verwenden wir die bereits bekannte (aus den vorherigen beiden Artikeln) abstrakte Klasse JimpleBaseVisitor (generiert von ANTLR). Sie ermöglicht es, den AST-Baum (Abstract Syntax Tree) zu durchlaufen und Aktionen an jedem Baumknoten auszuführen. Wir werden Methoden für jeden Knotentyp überschreiben, um entsprechenden Bytecode zu generieren.
Wir erstellen eine neue Klasse JimpleCompilerVisitor, die von JimpleBaseVisitor erbt und Methoden für die Bytecode-Generierung implementiert. Es ist bekannt, dass jede Methode dieser Klasse einen Wert eines bestimmten Typs zurückgeben muss. In unserem Fall geben wir Informationen über den Knotentyp im Baum zurück – CompilationInfo.
Warum brauchen wir Typinformationen? Viele Bytecode-Anweisungen hängen von den Operandentypen ab, daher müssen wir wissen, welchen Datentyp wir gerade verarbeiten. Zum Beispiel, wenn wir eine Ganzzahl verarbeiten, sollten wir Anweisungen für die Arbeit mit
Ganzzahlen verwenden. Viele Anweisungen haben Präfixe und/oder Suffixe, die die Operandentypen angeben, mit denen sie arbeiten. Dazu gehören folgende:
|
Suffix/Präfix |
Operandentyp |
|
i |
integer |
|
l |
long |
|
s |
short |
|
b |
byte |
|
c |
character |
|
f |
float |
|
d |
double |
|
a |
Referenz |
Ein anderes typbezogenes Problem ist, dass wir in Jimple keine expliziten Typen für Funktionsparameter und Rückgabewerte verwenden. Als ich den Jimple-Interpreter schrieb (erster Artikel), identifizierte ich Typen zur Laufzeit. Das war ziemlich einfach zu implementieren und die Sprache selbst sieht einfacher aus. Aber in einem Compiler müssen wir Typen im Voraus kennen, um korrekten Bytecode zu generieren (oder immer Object zurückgeben). Daher müssen wir den Rückgabetyp einer Funktion basierend auf ihrem Körper berechnen. Zur Identifizierung des Typs eines beliebigen Ausdrucks wurde eine getExpressionType Methode mit einem Parameter vom Typ ExpressionContext (Basisklasse aller Ausdrücke) geschrieben.
Compiler-Implementierung
Da die Wurzelregel unserer Grammatik die program Regel ist (siehe Jimple.g4 Datei), beginnt die Code-Kompilierung genau damit. Dafür rufen wir die visitProgram Methode am JimpleCompilerVisitor Objekt auf und geben ihr ein Objekt der Klasse ProgramContext. Nach dem Aufruf dieser Methode werden alle AST-Knoten verarbeitet und wir können den generierten Bytecode mit der getBytecode Methode erhalten. Der Bytecode, der ein Array von Bytes ist, kann in eine Datei geschrieben und mit der JVM ausgeführt werden.
JimpleCompilerVisitor visitor = new JimpleCompilerVisitor(); visitor.visitProgram(parser.program()); byte[] bytecode = visitor.getBytecode();
In der visitProgram Methode wird die Haupt-ClassWriter Klasse erstellt, die für die Bytecode-Generierung verwendet wird. In dieser Methode erstellen wir auch einen Konstruktor und eine main Methode. Methoden in ASM werden durch Aufruf von visitMethod erstellt, das ein Objekt vom Typ MethodVisitor zurückgibt. Mit dieser Klasse können wir Bytecode für jede Methode generieren. Die visitMethod Methode nimmt Parameter: Zugriffsattribute (z.B. ACC_PUBLIC), Methodenname, ihren Deskriptor und andere.
Besondere Aufmerksamkeit verdient der Methodendeskriptor: Er definiert die Parametertypen, die die Methode akzeptiert, und welchen Typ die Methode zurückgibt. Deskriptoren in der JVM sind auf spezielle Weise kodiert. Zum Beispiel sieht er für die main-Methode wie „([Ljava/lang/String;)V“ aus, was so entschlüsselt werden kann, dass die Methode ein Argument vom Typ String-Array nimmt – [Ljava/lang/String; und void zurückgibt – V. Eine Beschreibung der Deskriptoren findet sich in der JVM-Spezifikation.
Da es mehrere Methoden geben kann, die sich gegenseitig aufrufen (und sogar sich selbst), verwenden wir einen Stack, um alle Methoden zu speichern, wobei der „Kopf“ des Stacks die aktuelle Methode speichert. Um also die Durchquerung des gesamten Baums zu starten, müssen wir den MethodVisitor (der main Methode) auf den Stack legen und die Elternimplementierung visitProgram (super.visitProgram(ctx)) aufrufen. Am Ende der visitProgram Methode können wir den generierten Bytecode mit der toByteArray Methode am ClassWriter Objekt erhalten.
public CompilationInfo visitProgram(JimpleParser.ProgramContext ctx) {
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES);
// create the class with name "JimpleAutoGenApp"
cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "JimpleAutoGenApp", null, "java/lang/Object", null);
// Define a constructor
MethodVisitor constructor = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null);
constructor.visitCode();
constructor.visitVarInsn(Opcodes.ALOAD, 0);
constructor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false);
constructor.visitInsn(Opcodes.RETURN);
constructor.visitEnd();
// Create the main method
MethodVisitor mainMethod = cw.visitMethod(Opcodes.ACC_PUBLIC + Opcodes.ACC_STATIC, "main", "([Ljava/lang/String;)V", null, null);
mainMethod.visitCode();
// set mainMethod as current (put on the top)
methods.push(mainMethod);
super.visitProgram(ctx);
methods.pop();
mainMethod.visitInsn(Opcodes.RETURN);
mainMethod.visitEnd();
cw.visitEnd();
this.bytecode = cw.toByteArray();
return VOID;
}
Um letztendlich Bytecode zu erhalten, müssen wir alle notwendigen visitXxx Methoden implementieren. Sie werden entsprechenden Bytecode für jedes Sprachkonstrukt generieren. Betrachten wir einen einfachen Fall, wenn ein String im Programmtext auftritt. In diesem Fall müssen wir eine ldc <index> Anweisung generieren, wobei index der Index aus dem Konstantenpool der aktuellen Klasse ist. Woher bekommen wir ihn? In ASM wird das automatisch mit der visitLdcInsn Methode gemacht. Als Parameter kann man dieser Methode einen String, eine Zahl, einen Typ usw. übergeben. In unserem Fall übergeben wir einen String. Nach dem Aufruf dieser Methode wird der String in den Konstantenpool der Klasse gelegt und eine ldc Anweisung mit dem entsprechenden Index wird zum Bytecode hinzugefügt. Am Ende der Methode geben wir Informationen über den Knotentyp zurück – CompilationInfo.STRING. Für andere Typen müssen ähnliche Methoden implementiert werden.
@Override
public CompilationInfo visitStringExpr(JimpleParser.StringExprContext ctx) {
String value = StringUtil.cleanStringLiteral(ctx.STRING_LITERAL().getText());
getCurrentMethod().visitLdcInsn(value);
return CompilationInfo.STRING;
}
Ein komplexerer Fall sind arithmetische Operationen. Betrachten wir zum Beispiel die Addition. In Jimple kann sie so aussehen: a + b, wobei a und b Variablen oder Konstanten sein können. In diesem Fall müssen wir Anweisungen zum Laden der Werte a und b auf den Stack generieren und dann eine Anweisung zur Addition dieser Werte. Dafür rufen wir die getExpressionType Methode für jeden der Operanden auf, um ihre Typen zu erhalten. Dann generieren wir je nach Operandentypen die entsprechende Additionsanweisung. Zum Beispiel, wenn beide Operanden vom Typ long sind, generieren wir die ladd Anweisung. Wenn einer der Operanden vom Typ double ist, generieren wir die dadd Anweisung.
Wir müssen auch den Fall berücksichtigen, wenn einer der Operanden ein String ist. In diesem Fall sollte die + Operation als String-Konkatenation interpretiert werden. Dafür verwenden wir die StringBuilder Klasse, um den resultierenden String zu assemblieren.
Ein anderer wichtiger Punkt ist die Typumwandlung. In Java gibt es implizite und explizite Typumwandlung. In unserem Compiler implementieren wir implizite Typumwandlung, um die Verwendung der Sprache zu vereinfachen. Zum Beispiel, wenn ein Operand vom Typ long und der andere vom Typ double ist, wandeln wir automatisch long in double um. Dafür verwenden wir die convertAfterFirstArg und convertAfterSecondArg Methoden, die entsprechende Anweisungen für die Typumwandlung generieren. Die Methode selbst gibt Informationen über den resultierenden Typ dieses Ausdrucks zurück.
public CompilationInfo visitPlusMinusExpr(JimpleParser.PlusMinusExprContext ctx) {
MethodVisitor method = getCurrentMethod();
int typeLeft = getExpressionType(ctx.left);
int typeRight = getExpressionType(ctx.right);
// check if plus is string concat operator
if (ctx.op.getType() == JimpleParser.PLUS && (isStaticString(typeLeft) || isStaticString(typeRight))) {
method.visitTypeInsn(NEW, "java/lang/StringBuilder");
method.visitInsn(DUP);
method.visitMethodInsn(INVOKESPECIAL, "java/lang/StringBuilder", "", "()V", false);
CompilationInfo left = visit(ctx.left);
String typeDescrLeft = makeFuncDescriptor(left, "Ljava/lang/StringBuilder;");
method.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", typeDescrLeft, false);
CompilationInfo right = visit(ctx.right);
String typeDescrRight = makeFuncDescriptor(right, "Ljava/lang/StringBuilder;");
method.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", typeDescrRight, false);
method.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "toString", "()Ljava/lang/String;", false);
return CompilationInfo.STRING;
}
int commonType = commonType(typeLeft, typeRight);
switch (ctx.op.getType()) {
case JimpleParser.PLUS:
visit(ctx.left);
convertAfterFirstArg(typeLeft, typeRight);
visit(ctx.right);
convertAfterSecondArg(typeLeft, typeRight);
method.visitInsn(getAddOpcode(commonType));
return new CompilationInfo(commonType);
case JimpleParser.MINUS:
visit(ctx.left);
convertAfterFirstArg(typeLeft, typeRight);
visit(ctx.right);
convertAfterSecondArg(typeLeft, typeRight);
method.visitInsn(getSubOpcode(commonType));
return new CompilationInfo(commonType);
default:
throw new UnsupportedOperationException("Unsupported operator: " + ctx.op.getText());
}
}
Ein anderes wichtiges, aber nicht komplexes zu implementierendes Konstrukt ist die bedingte if Anweisung. Diese Anweisung besteht aus drei Teilen: Bedingung, if Körper und optionalem else Körper. Im Bytecode können wir die if Anweisung mit bedingten und unbedingten Sprunganweisungen implementieren. Zuerst generieren wir Code für die Auswertung der Bedingung. Dann erstellen wir zwei Labels: eins für den Sprung zum else Körper und ein anderes für den Ausstieg aus der gesamten if Anweisung. Wenn die Bedingung falsch ist, springen wir zum labelFalse Label, andernfalls führen wir den if Körper aus. Wenn ein else Körper vorhanden ist, fügen wir eine unbedingte Sprunganweisung (goto) zum Ausstiegslabel nach der Ausführung des if Körpers hinzu.
public CompilationInfo visitIfStatement(JimpleParser.IfStatementContext ctx) {
visit(ctx.expression());
Label labelFalse = new Label();
Label labelExit = new Label();
boolean hasElse = ctx.elseStatement() != null;
// if the value is false, then go to the else statement or exit if there is no else
MethodVisitor method = getCurrentMethod();
method.visitJumpInsn(IFEQ, hasElse ? labelFalse : labelExit);
// true
visit(ctx.statement());
if (hasElse) {
method.visitJumpInsn(GOTO, labelExit);
// false
method.visitLabel(labelFalse);
visit(ctx.elseStatement());
}
method.visitLabel(labelExit);
return CompilationInfo.VOID;
}
Monomorphisierung
Wie oben erwähnt, hat Jimple keine expliziten Typen für Funktionsparameter und Rückgabewerte. Typen werden während der Kompilierung berechnet. Das wiederum ermöglicht es, eine interessante Fähigkeit in der Sprache zu implementieren. Betrachten Sie folgendes Beispiel:
fun sum(a, b) {
return a + b
}
println sum(4, 2)
println sum("4", "2")
In diesem Beispiel wird die sum Funktion einmal deklariert, aber zweimal aufgerufen: das erste Mal mit zwei Ganzzahlen und das zweite Mal mit zwei Strings. Während der Kompilierung (in Jimple) werden zwei Versionen der sum Funktion generiert: eine für Ganzzahlen und eine andere für Strings. Das ermöglicht es, denselben Code für verschiedene Datentypen zu verwenden. Dieser Prozess wird Monomorphisierung genannt, und die Funktion ist polymorph. In Java müsste das durch Definition mehrerer Methoden implementiert werden (siehe Methodenüberladung).
Im Bytecode würde das folgendermaßen aussehen:
public static main([Ljava/lang/String;)V GETSTATIC java/lang/System.out : Ljava/io/PrintStream; LDC 4 LDC 2 INVOKESTATIC JimpleAutoGenApp.sum (JJ)J INVOKEVIRTUAL java/io/PrintStream.println (J)V GETSTATIC java/lang/System.out : Ljava/io/PrintStream; LDC "4" LDC "2" INVOKESTATIC JimpleAutoGenApp.sum (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V RETURN private static sum(JJ)J LLOAD 0 LLOAD 2 LADD LRETURN private static sum(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; NEW java/lang/StringBuilder DUP INVOKESPECIAL java/lang/StringBuilder. ()V ALOAD 0 INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder; ALOAD 1 INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder; INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String; ARETURN
Textifier und ASMifier
ASM bietet zwei nützliche Klassen für die textuelle Visualisierung von Bytecode: Textifier und ASMifier. Die erste Klasse ermöglicht es, Bytecode in Textform auszugeben, ähnlich der javap -v Ausgabe. Die zweite Klasse ermöglicht es, Bytecode als Java-Code auszugeben, der zur Generierung von Bytecode mit ASM verwendet werden kann. Das ist nützlich für Debugging und das Verständnis, wie ASM verwendet wird. Verwendungsbeispiel:
Textifier.main(new String[]{FooBar.class.getName()});
ASMifier.main(new String[]{FooBar.class.getName()});
Testen
Bereits im ersten Artikel wurde für das Testen des Jimple-Interpreters ein spezielles Mini-Framework geschrieben, das es ermöglicht, Jimple-Code-Stücke auszuführen und die tatsächliche Programmausgabe mit der erwarteten zu vergleichen. Um den Compiler zu testen, können wir denselben Ansatz verwenden. Anstatt den Interpreter auszuführen, kompilieren wir Jimple-Code in JVM-Bytecode und führen die kompilierte Klasse direkt aus dem Test aus. Das Schönste ist, dass der Compiler mit denselben Testprogrammen getestet werden kann wie der Interpreter.
Bytecode-Validierung
Die manuelle Generierung von Bytecode, selbst mit einer Bibliothek, ist nicht die trivialste Aufgabe. Es ist leicht, einen Fehler zu machen, der zu fehlerhaftem Bytecode führt. Die ASM-Bibliothek bietet die CheckClassAdapter Klasse zur Validierung des generierten Bytecodes. Diese Klasse ermöglicht es, Bytecode auf Einhaltung einiger JVM-Spezifikationsregeln zu überprüfen. Wenn der Bytecode fehlerhaft ist, wird eine Ausnahme mit einer Fehlerbeschreibung ausgelöst. CheckClassAdapter kann sowohl nach als auch während der Bytecode-Generierung verwendet werden. Im zweiten Fall müssen wir einfach ClassWriter in CheckClassAdapter wrappen, da letzterer auch das ClassVisitor Interface implementiert.
Betrachten wir folgendes korrektes Beispiel für Bytecode-Generierung mit CheckClassAdapter:
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES); CheckClassAdapter cv = new CheckClassAdapter(cw); cv.visit(Opcodes.V1_8, ACC_PUBLIC, CLASS_NAME, null, "java/lang/Object", null); MethodVisitor method = cv.visitMethod(ACC_PUBLIC + ACC_STATIC, "main", "([Ljava/lang/String;)V", null, null); method.visitCode(); method.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); method.visitLdcInsn(12L); method.visitLdcInsn(33L); method.visitMethodInsn(INVOKESTATIC, "java/lang/Math", "max", "(JJ)J", false); method.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(J)V", false); method.visitInsn(RETURN); method.visitEnd();
Der Code wird erfolgreich generiert und ausgeführt. Das ist äquivalent zu folgendem Java-Code:
public static void main(String[] args) {
System.out.println(Math.max(12L, 33L));
}
Wenn wir jedoch versehentlich einen Fehler machen und zum Beispiel statt method.visitLdcInsn(33L); schreiben method.visitLdcInsn(„33“);, dann tritt bei der Bytecode-Generierung eine IllegalArgumentException mit einer Fehlermeldung auf:
java.lang.IllegalArgumentException: Error at instruction 3: Argument 2: expected J, but found R main([Ljava/lang/String;)V 00000 R : : GETSTATIC java/lang/System.out : Ljava/io/PrintStream; 00001 R : R : LDC 12 00002 R : R J : LDC "33" 00003 R : R J R : INVOKESTATIC java/lang/Math.max (JJ)J 00004 ? : INVOKEVIRTUAL java/io/PrintStream.println (J)V 00005 ? : RETURN
Aus Interesse können Sie CheckClassAdapter nicht verwenden. In diesem Fall wird der Bytecode erfolgreich generiert, aber beim Versuch, ihn auszuführen, tritt eine VerifyError Ausnahme auf, da die JVM eine Typinkompatibilität erkennt.
Kompilierung und Ausführung
Zur bequemen Kompilierung und Ausführung des kompilierten Codes wurde ein Utility JimpleCompilerCli geschrieben, das eine Datei mit Jimple-Code als Eingabe nimmt, sie in Java-Bytecode kompiliert und als ausführbare jar-Datei speichert. Dieses Utility kann mit den jimplec.sh/jimplec.bat Skripten verwendet werden. Zuerst müssen wir das Projekt mit Maven kompilieren:
mvn clean package
Dann, um example.jimple zu kompilieren, führen wir folgenden Befehl aus:
./jimplec.sh example.jimple # oder jimplec.bat example.jimple für Windows
Dieser Befehl erstellt eine target/example.jar Datei, die mit der JVM ausgeführt werden kann:
java -jar target/example.jar
Leistungsvergleich
Zum Vergleich der Leistung des Jimple-Interpreters und des kompilierten Bytecodes können wir folgendes Testbeispiel verwenden:
var counter = 0
var lastFactorial = 0
var startTime = now()
while(counter < 1000000) {
lastFactorial = factorial(counter % 20 + lastFactorial % 5)
counter = counter + 1
}
var time = now() - startTime
println "lastFactorial: " + lastFactorial
println "counter: " + counter
println "time: " + time
fun factorial(n) {
if (n == 0) {
return 1
}
return n * factorial(n-1)
}
Dieser Code berechnet die Fakultät einer Zahl in einer Schleife eine Million Mal mit einer rekursiven factorial Funktion, wobei das Argument der factorial Funktion vom vorherigen Ergebnis abhängt. Das wird gemacht, um die JIT-Compiler-Optimierung der JVM zu vermeiden, die bemerken könnte, dass die Funktion immer mit denselben Argumenten aufgerufen wird und das Ergebnis cachen könnte. Im Interpreter haben wir keine solche Optimierung, daher wird der Vergleich fair sein. Dieser Code wurde jeweils 100 Mal im Interpreter und in kompilierter Form ausgeführt. Die durchschnittliche Ausführungszeit sieht folgendermaßen aus:
|
Jimple-Interpreter |
Kompiliertes Jimple |
Unterschied |
|
3466 ms |
9 ms |
~385x |
Wie aus der Tabelle ersichtlich ist, wird kompilierter Code 385 Mal schneller ausgeführt als interpretierter Code. Die Tests wurden auf einer Maschine mit Intel Core i9-13900K 3.00 GHz Prozessor und mit JVM-Flag -Xmx500M durchgeführt.
Bonus: AOT-Kompilierung zu nativem Code
Ab JDK 9 stellt Oracle GraalVM JDK bereit, das das Native Image Tool enthält. Dieses Tool ermöglicht es, JVM-Bytecode zu Maschinencode zu kompilieren. Die Verwendung dieses Tools kann die Anwendungsstartzeit erheblich verbessern und ihre Größe reduzieren, da nativer Code
keine JVM zur Ausführung benötigt. Um Jimple zu nativem Code zu kompilieren, müssen wir es zuerst zu Bytecode (jar-Datei) kompilieren und dann Native Image verwenden, um eine native ausführbare Datei zu erstellen. Beispiel-Kompilierungsbefehl:
native-image -jar example.jar
Dieser Befehl erstellt unter Linux eine ausführbare Datei namens example (oder example.exe unter Windows), die direkt von der Kommandozeile ausgeführt werden kann (ohne java). Übrigens wird die Fakultät aus dem vorherigen Beispiel auf nativem Code im Durchschnitt etwas schneller ausgeführt als kompilierter Bytecode – etwa 7 ms.
Zusammenfassung
In diesem Artikel haben wir betrachtet, was Java-Bytecode ist, seine Stack-Architektur und wie man mit der ASM-Bibliothek Bytecode aus Code in unserer Sprache generiert. Am Ende des Artikels verglichen wir die Leistung von interpretiertem Code und kompiliertem Bytecode und betrachteten auch die Möglichkeit der Kompilierung zu nativem Code mit GraalVM.
Der gesamte Quellcode des Compilers kann unter diesem Link eingesehen werden.
Referenzen
- https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html
- https://dzone.com/articles/introduction-to-java-bytecode
- https://asm.ow2.io/
- https://en.wikipedia.org/wiki/List_of_Java_bytecode_instructions
- https://www.youtube.com/watch?v=p7ipmAa9_9E
- https://openjdk.org/jeps/484
- https://martinfowler.com/bliki/TransparentCompilation.html
- https://www.graalvm.org/latest/reference-manual/native-image/