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

Der Optimizer von GraalVM


Heute konzentrieren wir uns auf das Herzstück der GraalVM. Wie gut kommt sie mit euren Java-Programmen zurecht? Lohnt es sich, eine Oracle JVM oder ein OpenJDK durch GraalVM zu ersetzen? In der IT läuft diese Frage meistens darauf hinaus, wie schnell eure Programme ausgeführt werden. Und ganz besonders interessiert uns heute der Blick hinter die Kulissen. Warum werden sie so schnell ausgeführt?

Streng genommen ist der Name „GraalVM“ irreführend. GraalVM ist gar keine vollständige virtuelle Maschine für Java. Besser gesagt, GraalVM ist nicht vollständig neu. Wenn ihr GraalVM installiert, installiert ihr im Großen und Ganzen die JVM, die ihr schon seit jeher kennt. Lediglich der C2-Compiler wurde durch einen neuen Compiler ersetzt.

Der Interpreter, der C1-Compiler und die ganze übrige Infrastruktur bleiben. Ihr erinnert euch vielleicht noch an unseren letzten Artikel: Der Interpreter sorgt dafür, dass Java überhaupt läuft, der C1-Compiler macht es schnell, und wenn es wirklich darauf ankommt, bringt der C2-Compiler Java zum Galoppieren. Hier ist der Wirkungsgrad von Optimierungen am größten. Also konzentriert sich GraalVM auf den C2-Compiler.

GraalVM macht euren Programmen also Beine. Das hat unsere Neugier geweckt. Und wir sind vor allem auf den Optimizer neugierig geworden. Compiler sind eine gut erforschte Technologie, daher glauben wir, dass dort kein Blumentopf zu gewinnen ist. Für echte Performancegewinne ist der Optimizer zuständig. Er schraubt permanent an euren Quelltexten herum, ändert sie, verwirft die Änderungen – immer auf der Suche nach Verbesserungsmöglichkeiten. Und das alles, ohne dass ihr es merkt. Ein echtes Refactoring nach der Definition von Martin Fowler also.

Ein scheinbar ganz einfacher Benchmark

Wir starten gleich mit einem Quelltext (Listing 1). Und verletzen dabei alle Regeln der Kunst. Ein ordentlicher Micro-Benchmark muss einen bestimmten Aufbau erhalten, weil ansonsten falsche Zahlen herauskommen, wie Angelika Langer und Klaus Kreft auf ihrem Blog berichten [1]. Erfahrene Benchmarker legen viel Wert darauf, dem JIT-Compiler genügend Aufwärmzeit zu geben. Aber die Zahlen sind auf eine interessante Art und Weise falsch: Sie zeigen die Funktionsweise des Java-Compilers und des Optimizers. Die Quelltexte findet ihr auch auf GitHub, zusammen mit unseren Messergebnissen [2].

Listing 1

interface Counter { int inc(); } class Counter1 implements Counter { private int x; public int inc() { return x++; } } class Counter2 implements Counter { private int x; public int inc() { return x++; } } class Counter3 implements Counter { private int x; public int inc() { return x++; } } public class Measure { public static void main(String[] args) { measure(new Counter1()); measure(new Counter2()); measure(new Counter3()); } public static void measure(...) {...} }

Die Methode measure ruft die Methode inc() des Counters eine Million Mal auf und misst die Zeit, die dafür benötigt wird. Dies wiederholen wir fünf Mal, um Messungenauigkeiten zu erkennen. Wir können es auch öfter wiederholen – und haben es gemacht, weil unser hemdsärmeliger Ansatz bei den Experten, die wir während der Recherche gefragt haben, für viel Stirnrunzeln gesorgt hat. Aber dadurch ändert sich nicht viel: Die Messwerte veränderten sich auch nach einer Stunde Laufzeit nicht mehr.

Das ist insofern überraschend, als unser Benchmark zuerst vom langsamen Interpreter ausgeführt wird, dann vom C1-Compiler, und zum Schluss läuft der schnelle Code, der vom C2-Compiler erzeugt wird. Und auch das ist nur ein Teil der Wahrheit, wie ihr gleich sehen werdet. Der Programmcode wird permanent optimiert und verbessert. Das alles kostet Zeit, und daher ist die übliche Vereinbarung, der JVM eine gewisse Aufwärmzeit zu geben, bevor die Messungen beginnen. Ein Framework wie JMH macht das sehr einfach.

Zu unserer Überraschung sind die beiden Compiler schneller als gedacht. Wir haben das überprüft, indem wir beim Start des Benchmarks noch die VM-Parameter -XX:-LogCompilation, -XX:+PrintInlining und – bei den GraalVM-Tests – -Dgraal.PrintCompilation=true mitgegeben haben. Es scheint tatsächlich so zu sein, dass alle Optimierungen innerhalb der ersten hundert Millisekunden stattfinden. Sehr beeindruckend! Natürlich müsst ihr dabei bedenken, dass es sich um einen sehr einfachen Test handelt. Mit euren Programmen haben die beiden Compiler wahrscheinlich mehr Arbeit.

Zurück zum Benchmark. Nachdem wir den Benchmark für Counter 1 durchgeführt haben, wiederholen wir dasselbe für die beiden anderen Implementierungen Counter 2 und Counter 3. Das sorgt dafür, dass die Tabelle spaltenweise zu lesen ist. Die erste Messung von Counter 2 erfolgt erst nach der letzten Messung von Counter 1.

Das Ergebnis ist leicht vorherzusagen: die Algorithmen sind identisch, also dauert alles gleich lang. Oder? Schaut Euch Tabelle 1 an, die das Verhalten von AdoptOpenJDK 15 zeigt.

Iteration

Counter 1

Counter 2

Counter 3

1

48 ms

185 ms

288 ms

2

45 ms

172 ms

290 ms

5

33 ms

177 ms

286 ms

Tabelle 1: Messwerte mit OpenJDK 64-Bit Server VM AdoptOpenJDK 15.0.1+9

Die erste Spalte ist leicht erklärt. Counter 1 braucht jedes Mal ungefähr gleich lang. Er wird im Laufe der Zeit etwas schneller, weil der Compiler im Laufe der Zeit noch die eine oder andere Optimierung entdeckt. Aber bei der ersten Verwendung von Counter 2 bricht die Performance heftig ein. Was ist da los? Das Geheimnis liegt im Schlüsselwort implements. Wir könnten alternativ drei verschiedene measure()-Methoden bauen, die jeweils direkt Counter 1, Counter 2 bzw. Counter 3 aufrufen. Probiert das mal aus: Ihr bekommt dann jedes Mal die schnellen Messergebnisse aus der ersten Spalte.

Klassen anstelle von Interfaces zu verwenden, gilt in der objektorientierten Welt aber als Tabu. Der allgemeine Konsens ist, dass es besser ist, das Interface anstelle der implementierenden Klasse aufzurufen. Ungefähr zu der Zeit, als CDI veröffentlicht wurde, ist dieses Dogma etwas aufgeweicht worden, es ist als Best Practice aber immer noch sehr populär.

Spekulative Optimierung

Für die Verwendung von Interfaces gibt es eine hervorragende theoretische Begründung, aber in der Praxis bringt das so gut wie nie Vorteile. Unser Benchmark ist eine Ausnahme: Wir sparen uns dadurch drei identische Implementierungen. Aber meistens könnt ihr ruhig das neue Schlüsselwort var verwenden – auch wenn das bedeutet, dass der Datentyp ArrayList statt List verwendet wird. Ihr handelt euch damit fast nie Nachteile ein. Seid ehrlich: Wie oft verwendet ihr ein Interface mit zwei unterschiedlichen Implementierungen? Wir haben diese Frage sehr oft gestellt. Die reflexartige Reaktion ist fast immer: „Wir haben das im Programm XY verwendet“. Oder: „Wir brauchen das in unseren Tests“.

Das ist aus Sicht der JVM-Entwickler der springende Punkt. Tests werden nicht in Produktion verwendet, sind also definitiv nichts, für das die virtuelle Maschine optimiert werden sollte. Und die Aussage „Wir verwenden es in Programm XY“ sagt alles. Sie zeigt, dass ihr euch noch genau erinnern könnt, wann ihr zum letzten Mal Polymorphismus verwendet habt. Versteht uns nicht falsch: Auch wir, die Autoren, lieben unseren Polymorphismus! Polymorphismus ist ein wertvolles Werkzeug, auf das wir keineswegs verzichten wollen. Aber wenn ihr genau hinschaut, stellt ihr fest, dass er im produktiven Code so gut wie nie verwendet wird. Der Regelfall ist, dass jedes Interface im Produktivcode genau eine Implementation hat.

Das wissen natürlich auch die JVM-Entwickler und optimieren ihren Compiler entsprechend. Sie spekulieren darauf, dass ihr nur so tut, als wäre euch Polymorphismus wichtig. Der Optimizer des JDK geht davon aus, dass ihr die einmal gewählte Implementierung immer verwenden wollt. Das hat große Auswirkungen. Wenn Java bei jedem Methodenaufruf nachschauen muss, welche Implementation gemeint ist, dauert das seine Zeit. Also generiert der Compiler kurzerhand den Maschinencode, um eine genau bekannte Funktion aufzurufen. Und bei jed...

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

Angebote für Teams

Für Firmen haben wir individuelle Teamlizenzen. Wir erstellen Ihnen gerne ein passendes Angebot.

Das Library-Modell:
IP-Zugang

Das Company-Modell:
Domain-Zugang