© F. J. CARNEROS/Shutterstock.com
Eine ausführliche Einführung in die GraalVM – Teil 2

Low-Level-Zeug: Compiler und Co.


Ihr schreibt ein Programm, und euer Computer führt es aus. Aber habt ihr euch schon mal gefragt, wie das genau geht? Macht euch auf eine spannende Reise gefasst. Java führt euren Quelltext nicht einfach nur aus. Es beobachtet ihn die ganze Zeit, analysiert, optimiert, kompiliert, verwirft das Ergebnis, kompiliert noch mal – kurz, es schraubt während der ganzen Zeit an eurem Code. Was mit Eurem Quelltext passiert, wenn ihr gerade nicht hinschaut, ist diesmal Thema unserer Reihe.

Starten wir mit einer ganz einfachen Frage: Was macht eigentlich ein Compiler? Im Java Magazin konzentrieren wir uns natürlich auf den Java Compiler. Aber die Frage können wir auch weiter fassen. Wenn ihr euch den V8-Compiler – der JavaScript in Google Chrome pfeilschnell macht – genauer anschaut, werdet ihr verblüffende Ähnlichkeiten zum Innenleben des Java Compilers finden. V8 lässt nur den Bytecode weg. Ansonsten gibt es viele Ähnlichkeiten: V8 hat einen schnellen Compiler und einen zweiten Compiler, der schnellen Code erzeugt.

Moment mal – V8 lässt nur den Bytecode weg? Aber das ist doch genau das, was der Java Compiler macht! Er erzeugt Bytecode! Wenn euch das spontan durch den Kopf geht, ist dieser Artikel für euch. Der Bytecode ist nur der Anfang einer langen und spannenden Reise, auf die wir euch gerne mitnehmen wollen. Wir wollen unsere Faszination über die Optimierungsstrategien des Java-Kernteams mit euch teilen. Das ist ein Feuerwerk an Ideen, das auch nach 25 Jahren stürmischer Weiterentwicklung noch lange nicht endet.

Anatomie einer Java-Datei

Als Java-Entwickler seid ihr bestens mit euren Quelltexten vertraut. Euer Maven- oder Gradle-Build macht daraus eine oder mehrere JAR-Dateien. Vielleicht auch eine WAR-Datei oder – wenn euer Projekt schon ein paar Jahre auf dem Buckel hat – eine EAR-Datei. Wenn ihr davon sprecht, euer Projekt zu kompilieren, denkt ihr wahrscheinlich an diesen Build-Prozess. In diesem Artikel geht es uns um etwas anderes. Egal ob JAR-, WAR- oder EAR-Datei, es handelt sich im Prinzip einfach nur um ZIP-Dateien. Aber schaut mal rein. Dort findet ihr die *.class-Dateien. Und da wird es spannend. Habt ihr euch schon mal eine *.class-Datei genauer angeschaut?

Das ist gar nicht so einfach. Ihr braucht dafür einen Hex-Editor. In Notepad seht ihr nur wirres Zeug. Aber nicht ganz wirr: Eure Klassennamen werdet ihr dort erkennen, genauso wie die Variablennamen oder die Strings, die ihr verwendet.

Im Hex-Editor (Abb. 1) erkennt ihr viel mehr Strukturen. Die ersten vier Byte zum Beispiel: Sie bilden das Wort „Café Babe“. Das ist das Wahrzeichen einer Java-Datei. Die virtuelle Maschine von Java akzeptiert nur Dateien mit dieser Signatur, und alle anderen Programme auf eurem Rechner erkennen an dieser Signatur, dass sie nicht für diese Datei zuständig sind.

rauh_graalvm2_1.tif_fmt1.jpgAbb. 1: Anatomie einer *.class-Datei

Die nächsten vier Byte sind fast noch wichtiger. Vor allem die beiden gelb markierten Bytes. Wahrscheinlich hattet ihr mit diesen beiden Bytes sogar schon Stress. Sie geben die Versionsnummer des Java-Compilers an, mit dem die Class-Datei erzeugt wurde. In unserem Beispiel bedeutet die 0037, dass die Datei mit Java 11 kompiliert wurde. Falls ihr sie mit Java 11 ausführen wollt, ist alles gut. Mit Java 15 auch. Aber mit Java 8 wird es nicht funktionieren. Die JVM erkennt, dass sie mit dieser neumodischen Datei wahrscheinlich nichts anfangen kann und weigert sich, sie auszuführen. Die Fehlermeldung ist leider sehr kryptisch – wer sie zum ersten Mal sieht, steht vor einem Rätsel.

Weiter unten im Screenshot erkennt ihr einigermaßen lesbaren Text. Das ist die Tabelle der Variablen eurer Java-Datei. Solltet ihr euch jemals gewundert haben, wie Spring eine Bean anhand des Variablennamens injizieren kann – hier ist die Antwort. Die Class-Dateien enthalten die Variablennamen im Klartext. Das ist eine Besonderheit von Java: Die meisten Compiler eliminieren den Variablennamen beim Kompilieren. Der Klartext ist nur für uns Menschen wichtig. Aus Sicht der CPU reicht es, die Variablen durchzunummerieren. Das spart Platz und geht schneller.

Und tatsächlich ist das eine der Optimierungen, die sich performancehungrige Java-Entwickler ausgedacht haben. Es gibt eine Gruppe von Werkzeugen, die Obfuskatoren, die jede Variable durch einen kürzeren Variablennamen ersetzen. Der Hauptgrund ist der Schutz vor Reverse Engineering: Mit Hilfe eines Decompilers könnt ihr jede Class-Datei nahezu ohne Verluste in die originale Java-Datei zurückverwandeln. Wenn ihr einen Obfuskator verwendet habt, nützt das eurer neugierigen Konkurrenz wenig, weil aus den ehemals sprechenden Variablennamen nur noch Namen wie a1, a2 und so weiter geworden sind. Das erschwert das Reverse Engineering eures geistigen Eigentums erheblich. Als Nebeneffekt wird die Class-Datei kleiner und kann von der JVM schneller gelesen und analysiert werden.

In den letzten Jahren ist es still um die Obfuskatoren geworden. Das Performanceargument zieht nicht mehr. Die Erfindung der Just-in-Time-Compiler hat diese Technik weitgehend obsolet gemacht. Und Frameworks wie Spring, Hibernate oder CDI haben ihre liebe Not mit der Obfuskation: sie verlassen sich ja darauf, dass sie die Klassen anhand ihres Namens finden können.

Eine Java-Class-Datei im Disassembler

Wir könnten noch viel mehr über das Format der Class-Dateien erzählen – aber das könnt ihr genauso gut bei Wikipedia nachlesen [1]. Gehen wir also einen Schritt weiter. Schauen wir uns die vielen Punkte im Hex-Editor genauer an. Die Punkte zeigen, dass das jeweilige Byte keinem Buchstaben im ASCII-Code entspricht. Das heißt aber nicht, dass sie irrelevant wären. Welche Bedeutung diese Bytes haben, können wir mit Hilfe des Programms javap erkennen, das jedes JDK mitliefert. Könnt ihr erkennen, was unser Beispielprogramm in Abbildung 2 macht?

rauh_graalvm2_2.tif_fmt1.jpgAbb. 2: Was sehen wir hier?

Es scheint um einen Counter zu gehen und public int inc() könnte die Signatur einer Methode sein. Das meiste andere wird euch vermutlich ziemlich fremd vorkommen. Wir lösen das Rätsel also gleich auf. Es handelt sich um den Bytecode, der aus dieser Java-Datei generiert wird:

class Counter1 implements Counter { private int x; public int inc() { return x++; } }

Was verbirgt sich hinter dem Java-Bytecode?

Gehen wir an den Anfang zurück. In unserer Begeisterung haben wir ziemlich genau in der Mitte angefangen. Die Geschichte sollte mit dem anfangen, was ihr den ganzen Tag macht. Als Leser des Java Magazins schreibt ihr vermutlich häufig Quelltexte in eurer IDE.

Das ist zunächst einmal einfach nur Text. Wir Menschen verbinden mit diesem Text eine Bedeutung, aber aus Sicht einer CPU ist das nur eine sinnlose Anreihung wahlloser Zeichen. CPUs kennen keine Variablen und keine while-Schleife. Für CPUs gibt es auch Programmiersprachen, die sich aber radikal von Java unterscheiden. Diese Assembler-Sprachen sind auch schon eine Abstraktionsebene von der CPU entfernt, nehmen aber wenig Rücksicht darauf, wie ein Mensch einen Algorithmus formulieren würde. Wenn ihr eine Assembler-Sprache lernt, müsst ihr in vielerlei Hinsicht umdenken. Das macht Spaß, bremst aber eure Produktivität enorm. Der Lohn für eure Mühe ist ein extrem schnelles Programm. Wie schnell genau, ist angesichts der imposanten Fortschritte der Compilertechnik schwer zu sagen. In den 80er Jahren war ein Assembler-Programm rund tausendmal schneller als ein BASIC-Programm. BASIC war seinerzeit eine populäre Programmiersprache, die fast immer als Interpreter implementiert wurde. Interpreter spielen später in diesem Artikel noch eine Rolle, also sollten wir uns die Zahl 1 000 merken. Ein guter Compiler (auch das ein Begriff, den wir später erklären) liefert Programme, die rund hundert Mal schneller sind. Was immer noch zehn Mal langsamer als das handgeschriebene Assembler-Programm ist. Wohlgemerkt: das sind alles keine exakten Zahlen, sondern eher Schätzungen. Die exakten Zahlen ändern sich mit den Fortschritten der Technik von Jahr zu Jahr. Zumal jedes Programm etwas anders ist, von exakten Zahlen kann also keine Rede sein. Was wir aber sicher sagen können, ist, dass ein guter, sorgfältig von Hand optimierter Assembler-Code immer schneller sein wird als der Assembler-Code, den ein Compiler generiert.

Das Problem ist nur, dass die Assembler-Sprachen für CPUs gemacht sind, nicht für Menschen. Unsereins braucht locker zehnmal so lang, um ein Programm in Assembler-Code zu schreiben, als dasselbe Programm in Java zu schreiben. Deswegen verwenden wir lieber Überse...

Neugierig geworden? Wir haben diese Angebote für dich:

Angebote für Gewinner-Teams

Wir bieten Lizenz-Lösungen für Teams jeder Größe: Finden Sie heraus, welche Lösung am besten zu Ihnen passt.

Das Library-Modell:
IP-Zugang

Das Company-Modell:
Domain-Zugang