Seitdem alle sechs Monate ein neues JDK erscheint, ist die Menge der fertiggestellten Java Enhancement Proposals (JEPs) ziemlich groß. Auf den ersten Blick sieht es also so aus, als müssten wir regelmäßig eine Menge neuer Dinge lernen, um auf dem aktuellen Stand zu bleiben.
Durch das Konzept der Incubator Modules und der Preview-Features bleibt diese Menge allerdings gut überschaubar und oft kann man die Zwischenstadien ignorieren und es reicht aus, sich mit einem Feature zu beschäftigen, wenn es fertig ist. So wurden zwischen JDK 17 und 21 zwar in Summe 38 JEPs fertiggestellt, allerdings waren 23 davon eben Incubators oder Previews und mit JDK 21 gab es insgesamt 15 fertige JEPs.
Außerdem gibt es immer auch eine Reihe von JEPs, die zwar für das JDK und dessen Ökosystem wichtig sind, aber für Anwendungsentwickelnde weniger Relevanz haben.
Und trotzdem ist es meiner Meinung nach immer wieder sinnvoll, sich auch vorab mit unfertigen Features oder solchen, die keinen direkten Einfluss auf die Entwicklung von Anwendungen haben, zu beschäftigen. Daher werden wir uns in diesem Artikel das Class-File API anschauen, das mit JEP 466 im JDK 23 eine zweite Preview-Version erhält.
Ein bisschen Geschichte
Java wird, wie vermutlich bekannt, in Bytecode übersetzt, der zur Laufzeit von der JVM ausgeführt wird. Deshalb gibt es im JVM-Ökosystem immer wieder die Anforderung, Bytecode zu lesen, zu generieren oder zu transformieren. Dabei reicht die Spanne vom JDK selbst über Bibliotheken oder Frameworks und Tools zur Codeanalyse bis zu anderen auf der JVM implementierten Sprachen. Aktuell gibt es für diese Aufgaben diverse Bibliotheken wie ASM, cglib oder ByteBuddy. Und selbst das JDK enthält bereits drei Implementierungen dafür.
Wegen dieses Wildwuchses gibt es bereits seit Längerem Bedarf für ein offizielles API für diese Aufgaben. Und der verkürzte Releasezyklus des JDK hat diesen Bedarf noch erhöht, denn dadurch entsteht nach jedem Release der Druck auf die bestehenden Bibliotheken, ihrerseits möglichst schnell ein neues Release zu erstellen, um die neue Version zu unterstützen. Das ist notwendig, damit die neue Bytecodeversion korrekt ausgelesen und unterstützt wird, und um neue Bytecodeinstruktionen zu unterstützen.
Deswegen ist ein offizielles API innerhalb des JDK sinnvoll. Es kann schon während der JDK-Entwicklung erweitert werden und eine neue JDK-Version von Anfang an unterstützen. Außerdem kann diese auch sofort innerhalb des JDK verwendet werden.
Eine Möglichkeit wäre es gewesen, eine der existierenden Bibliotheken in das JDK zu integrieren. Hier hätte sich ASM angeboten, da das JDK bereits einen Fork in seiner Codebasis hat und einige der anderen Bibliotheken ebenfalls auf ASM basieren. Da ASM allerdings eine alte Codebasis ist, entspricht dessen API nicht mehr den modernen Anforderungen.
Deswegen wurde mit JEP 457 in JDK 22 eine erste Preview für das komplett neue Class-File API ausgeliefert, das nun in JDK 23 mit JEP 466 eine zweite Runde als Preview-Feature dreht, bevor es dann vermutlich in JDK 24 mit JEP 484 seinen finalen Einzug ins JDK erreichen wird.
Basics des API
Das neue Class-File API lebt im Paket java.lang.classfile
und stützt sich im Kern auf drei Abstraktionen. Die erste ist das Element. Ein solches Element kann dabei sowohl eine einzelne Bytecodeinstruktion als auch eine ganze Klasse sein. Jedes Element ist dabei unveränderlich und manche, wie Klassen oder Methoden, können weitere Elemente als Kinder beinhalten.
Um die Elemente zu erzeugen, werden Builder genutzt. Sie erlauben es, zur Erzeugung eine Reihe von Methoden zu nutzen, um die Eigenschaften des zu erzeugenden Elementes zu definieren.
Als dritte und letzte Abstraktion gibt es Transformationen. Das sind in der Regel Methoden, die ein Element und einen Builder akzeptieren.
Das API setzt dabei auf die neuen Features des JDK wie Lambdas, Records, Sealed-Klassen und Pattern Matching und wirkt dadurch, wie im Folgenden zu sehen sein wird, sehr modern. Als Haupteinstiegspunkt in das Class-File API dient dabei stets die Klasse java.lang.classfile.ClassFile
und deren statische Methoden.
Bytecode lesen
Wollen wir Bytecode mit dem API lesen, können wir die Methode parse
entweder mit einem Bytearray oder einem java.nio.file.Path
aufrufen und erhalten als Ergebnis ein java.lang.classfile.ClassModel
zurück. Darüber können wir den Bytecode analysieren. Listing 1 liest beispielsweise alle Felder und Methoden von java.lang.String
aus und gibt diese aus.
try (var in = String.class.getResourceAsStream("/java/lang/String.class")) {
var classModel = ClassFile.of().parse(in.readAllBytes());
System.out.println("## Methods");
classModel.methods().stream()
.map(method -> method.methodName().stringValue())
.map(methodName -> " * " + methodName)
.forEach(System.out::println);
System.out.println("## Fields");
classModel.fields().stream()
.map(field -> field.fieldName().stringValue())
.map(methodName -> " * " + methodName)
.forEach(System.out::println);
}
Anstatt sich direkt die Methoden oder Felder geben zu lassen, ist es in vielen Fällen sinnvoller und bequemer, über alle Kindelemente zu iterieren und Pattern Matching dazu zu nutzen, die korrekten Elemente auszuwählen. Listing 2 zeigt, wie dieses Vorgehen dazu genutzt werden kann, um die Abhängigkeiten einer Klasse zu sammeln.
try (var in = String.class.getResourceAsStream("/java/lang/String.class")) {
var classModel = ClassFile.of().parse(in.readAllBytes());
var deps = classModel.elementStream()
.flatMap(ce -> ce instanceof MethodModel mm
? mm.elementStream() : Stream.empty())
.flatMap(me -> me instanceof CodeModel com
? com.elementStream() : Stream.empty())
.<ClassDesc>mapMulti((element, c) -> {
switch (element) {
case InvokeInstruction i -> c.accept(i.owner().asSymbol());
case FieldInstruction i -> c.accept(i.owner().asSymbol());
default -> {}
}
})
.collect(Collectors.toSet());
deps.forEach(System.out::println);
}
Hierbei iterieren wir über alle Elemente der Klasse java.lang.String
und behalten nur die Elemente vom Typ java.lang.classfile.MethodModel
, also Methoden. Auch über deren Kinder iterieren wir und behalten nur die java.lang.classfile.CodeModel
-Elemente. Von diesen merken wir uns dann wiederum die java.lang.classfile.InvokeInstruction
- und FieldInstruction
-Elemente beziehungsweise den Namen des Besitzers. Diese werden schließlich als java.util.Set
gesammelt, wodurch Dopplungen entfallen, und dann auf der Standardausgabe ausgegeben.
Bytecode generieren
Analog zum Lesen starten wir auch zum Generieren von Bytecode mit ClassFile.of
, nutzen dann aber eine der build
-Methoden, beispielsweise buildTo
(Listing 3).
var system = of("java.lang", "System");
var printStream = of("java.io", "PrintStream");
ClassFile.of().buildTo(
Path.of("Echo.class"),
of("Echo"),
classBuilder -> classBuilder
.withMethodBody(
"main",
MethodTypeDesc.of(CD_void, CD_String.arrayType()),
ACC_PUBLIC | ACC_STATIC,
codeBuilder -> codeBuilder
.getstatic(system, "out", printStream)
.aload(codeBuilder.parameterSlot(0))
.iconst_0()
.aaload()
.invokevirtual(printStream, "println", MethodTypeDesc.of(CD_void, CD_String))
.return_()));
Hier wird in die Datei Echo.class eine Klasse Echo
generiert. Ihr wird die Methode main
hinzugefügt. Diese Methode hat als Rückgabetyp void
und einen Parameter vom Typ String[]
und besitzt die Modifier public
und static
. Der Body der Methode packt zuerst die statische Variable out
der Klasse java.lang.System
auf den Stack. Danach fügen wir das Array aus dem ersten Methodenparameter und die Konstante 0
hinzu. Mittels aaload
wird nun der Wert aus dem 0ten Slot des Arrays geladen. Anschließend wird die Methode println
mit diesem Wert auf der vorher geladenen statischen Variable aufgerufen. Zuletzt muss die Methode noch return
aufrufen, um beendet zu werden.
Führen wir diese Klasse aus, gibt sie das erste Argument wieder auf der Standardausgabe aus:
$ java -cp . Echo Hallo Welt
Hallo
Neben den bisher genutzten Low-Level-Bytecodeinstruktionen enthält das Class-File API auch höherwertige Methoden, die uns Arbeit abnehmen und unter Umständen mehrere Low-Level-Instruktionen generieren. So erzeugt Listing 4 den in Listing 5 zu sehenden Bytecode und erspart uns somit die Arbeit, ein if-else zu definieren.
var system = of("java.lang", "System");
var printStream = of("java.io", "PrintStream");
ClassFile.of().buildTo(
Path.of("Foo.class"),
of("Foo"),
classBuilder -> classBuilder
.withMethodBody(
"main",
MethodTypeDesc.of(CD_void, CD_String.arrayType()),
ACC_PUBLIC | ACC_STATIC,
codeBuilder -> codeBuilder
.iconst_1()
.ifThenElse(
t -> t
.getstatic(system, "out", printStream)
.loadConstant("True")
.invokevirtual(printStream, "println", MethodTypeDesc.of(CD_void, CD_String)),
f -> f
.getstatic(system, "out", printStream)
.loadConstant("False")
.invokevirtual(printStream, "println", MethodTypeDesc.of(CD_void, CD_String)))
.return_()));
$ javap -c Foo.class
public class Foo {
public static void main(java.lang.String[]);
Code:
0: iconst_1
1: ifeq 15
4: getstatic #10 // Field ...
7: ldc #12 // String True
9: invokevirtual #18 // Method ...
12: goto 23
15: getstatic #10 // Field ...
18: ldc #20 // String False
20: invokevirtual #18 // Method ...
23: return
}
Bytecode transformieren
Der letzte Bereich, den das Class-File API unterstützt, ist das Transformieren von Bytecode. Natürlich ließe sich das auch über Lesen und anschließendes Schreiben lösen, aber durch den direkten Support in Form von transform*
-Methoden wird es durch das API noch einmal bequemer.
So lassen sich beispielsweise mit dem Code aus Listing 6 alle statischen Aufrufe der debug
-Methode einer Klasse Logger
entfernen.
var classModel = ClassFile.of().parse(Path.of(".../Application.class"));
var removeDebugInvocations = MethodTransform.transformingCode(
(builder, element) -> {
switch (element) {
case InvokeInstruction i when
i.opcode() == Opcode.INVOKESTATIC
&& i.owner().asInternalName().equals(".../Logger")
&& i.method().name().equalsString("debug") ->
builder.pop();
default ->
builder.accept(element);
}
});
var newClassBytes = ClassFile.of().transformClass(
classModel,
ClassTransform.transformingMethods(removeDebugInvocations));
try (var out = new FileOutputStream(".../Application.class")) {
out.write(newClassBytes);
}
Transformieren wir hiermit die in Listing 7 zu sehende Klasse Application
, entsteht der in Listing 8 dargestellte Bytecode.
public class Application {
public static void main(String[] args) {
Logger.debug("Starting");
Logger.info("Greeting");
System.out.println("Hallo Welt!");
Logger.debug("Finished");
}
}
$ javap -c .../Application.class
Compiled from "Application.java"
public class ....Application {
public ....Application();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: ldc #7 // String Starting
2: pop
3: ldc #15 // String Greeting
5: invokestatic #17 // Method .../Logger.info:(Ljava/lang/String;)V
8: getstatic #20 // Field ...
11: ldc #26 // String Hallo Welt!
13: invokevirtual #28 // Method ...
16: ldc #33 // String Finished
18: pop
19: return
}
In diesem Beispiel entfernen wir zwar die Aufrufe der debug
-Methode, allerdings bleibt das Laden der Argumente erhalten. Diese werden zwar anschließend – durch pop
– sofort wieder vom Stack genommen, trotzdem sollte hier für einen realen Einsatz noch etwas optimiert werden.
Einsatz
Der Einsatz des Class-File API in der Anwendungsentwicklung wird vermutlich eher gering sein. Schließlich werden die wenigsten Anwendungen, um ihr Ziel zu erreichen, Bytecode lesen, schreiben oder transformieren müssen.
Die Integration eines solchen API ins JDK kann jedoch diversen Tools helfen, in Zukunft mit einer Abhängigkeit weniger auszukommen und gleichzeitig davon zu profitieren, dass diese nicht nach jedem neuen JDK so schnell wie möglich ein neues Release herausbringen muss.
Die typischen Anwendungsfälle sind hierbei wohl die statische Analyse von Code und die dynamische Generierung von Code zur Laufzeit. Hier sind Aspekte wie die Transaktionssteuerung oder auch die Instrumentierung für Observability Kandidaten, die von diesem API profitieren können.
Aber auch um andere oder neue Sprachen in Bytecode zu kompilieren und diese damit auf der JVM ausführen zu können, ist das API durchaus angenehm und brauchbar. So zeigt der Blogpost „Build A Compiler With The JEP 457 Class-File API“ von Dr. James Hamilton sehr schön, wie mit dem API ein Compiler für die Sprache Brainf**k geschrieben werden kann. Listing 9 zeigt eine verkürzte Version des dort entstandenen Codes, um einen Eindruck zu geben.
public class BrainFckCompiler {
static final ClassDesc SYSTEM = ClassDesc.of("java.lang", "System");
static final ClassDesc PRINT_STREAM = ClassDesc.of("java.io", "PrintStream");
static final int DATA_POINTER = 0;
static final int MEMORY = 1;
public static void main(String[] args) throws IOException {
var input = Files.readString(Path.of(args[0]));
var bytes = ClassFile.of()
.build(
ClassDesc.of("BrainFckProgram"),
classBuilder -> classBuilder
.withMethodBody(
"main",
MethodTypeDesc.of(CD_void, CD_String.arrayType()),
ACC_PUBLIC | ACC_STATIC,
codeBuilder -> {
codeBuilder
.sipush(30_000)
.newarray(ByteType)
.astore(MEMORY);
codeBuilder
.iconst_0()
.istore(DATA_POINTER);
generateInstructions(input, codeBuilder);
codeBuilder.return_();
}));
generateJarFile(args[1], bytes);
}
static void generateInstructions(String input, CodeBuilder codeBuilder) {
input.chars().forEach(c -> {
switch (c) {
case '>' -> move(codeBuilder, 1);
case '<' -> move(codeBuilder, -1);
case '+' -> increment(codeBuilder, 1);
case '-' -> increment(codeBuilder, -1);
case '.' -> printChar(codeBuilder);
default -> {}
}
});
}
static void move(CodeBuilder codeBuilder, int amount) {
codeBuilder.iinc(DATA_POINTER, amount);
}
static void increment(CodeBuilder codeBuilder, int amount) {
codeBuilder
.aload(MEMORY)
.iload(DATA_POINTER)
.dup2()
.baload()
.loadConstant(amount)
.iadd()
.bastore();
}
static void printChar(CodeBuilder codeBuilder) {
codeBuilder
.getstatic(SYSTEM, "out", PRINT_STREAM)
.aload(MEMORY)
.iload(DATA_POINTER)
.baload()
.i2c()
.invokevirtual(PRINT_STREAM, "print", MethodTypeDesc.of(CD_void, CD_char));
}
static void generateJarFile(String jarFileName, byte[] classContent) throws IOException {
var manifest = new Manifest();
manifest.getMainAttributes().put(MANIFEST_VERSION, "1.0");
manifest.getMainAttributes().put(MAIN_CLASS, "BrainFckProgram");
try (var os = new JarOutputStream(
new BufferedOutputStream(new FileOutputStream(jarFileName)), manifest)) {
os.putNextEntry(new JarEntry("BrainFckProgram.class"));
os.write(classContent);
os.closeEntry();
}
}
}
Dabei wird eine JAR-Datei generiert, die eine Klasse BrainFckProgram
enthält und diese als Main-Class
definiert. Um diese Klasse zu generieren, wird das Class-File API verwendet. Dabei werden zwei Variablen, ein Byte Array mit 30 000 Slots und ein Integer als Pointer, definiert. Anschließend wird der Quellcode Zeichen für Zeichen durchgegangen.
Taucht dabei ein <
oder >
auf wird der Pointer mittels iinc
um 1 erhöht oder verringert. Wird ein +
oder -
gelesen, muss der Wert im Bytearray an der aktuellen Position des Pointers erhöht oder verringert werden. Hierzu wird erst das Bytearray mittels aload
und dann die Position des Pointers mittels iload
auf den Stack gelegt.
Anschließend werden mittels dup2
diese beiden Werte dupliziert. Die Instruktion baload
sorgt nun dafür, dass der Wert aus dem Bytearray auch wirklich gelesen wird. Dabei werden die letzten beiden Werte auf dem Stack entfernt und durch den gelesenen Wert ersetzt. Anschließend legen wir den Wert, um den erhöht oder verringert werden soll, mittels loadConstant
auf den Stack. Hierbei handelt es sich wiederum um eine High-Level-Methode, die je nach Datentyp anderen Bytecode generiert. Nun können wir über iadd
die beiden letzten Werte addieren und mittels bastore
speichern. Das funktioniert dadurch, dass wir vorher mittels dup2
das Bytearray und den Pointer dupliziert haben und deswegen bastore
die drei letzten Frames nutzen kann. Zuletzt wird noch der .
interpretiert und sorgt dafür, dass der Wert zum aktuellen Pointer ausgegeben wird. Die Nutzung dieses Compilers und die anschließende Ausführung sind in Listing 10 zu sehen.
$ cat hallo.bf
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
+++++++.
-------.
+++++++++++.
.
+++.
$ java --enable-preview -cp . BrainFckCompiler hallo.{bf,jar}
$ java -jar hallo.jar
HALLO
Es gibt also Anwendungsfälle für dieses API, auch wenn wir in der Anwendungsentwicklung vermutlich selten direkt damit in Berührung kommen. Für diese stellt das API eine gute Basis dar, auch wenn weiterhin Kenntnisse über Bytecode und dessen Instruktion benötigt werden.