Kolumne: Java-Trickkiste

Java-Trickkiste: Der JIT-Compiler von Hotspot

Arno Haase
java-trickkiste

Die Hotspot-JVM liest Class-Dateien ein und führt das entsprechende Java-Programm aus. An irgendeiner Stelle „kompiliert“ sie das Programm noch einmal und wendet dabei ausgefeilte Verfahren an, sodass Java-Programme bemerkenswert schnell laufen. Das ist in etwa das, was man beim Entwickeln von Geschäftsanwendungen über den JIT-Compiler wissen muss. Wenn man aber z. B. performancekritischen Code schreibt oder einfach nur neugierig ist, lohnt sich ein Blick hinter die Kulissen.

Aber der Reihe nach: Wieso überhaupt Compiler? Der Java-Compiler kompiliert den Quellcode doch schon in Bytecode – wozu noch ein Compiler?

Der Grund ist die Performance. Eine JVM kann direkt Bytecode ausführen, aber das ist nicht besonders schnell. Sie muss sich dann jeden einzelnen Programmschritt ansehen und entscheiden, was sie damit tun soll, und ein solcher Interpreter erzeugt eben Overhead. Ein JIT-Compiler (JIT steht für „Just in Time“, also in etwa „erst dann, wenn man es braucht“) wird aktiv, während das Programm schon läuft, und übersetzt einzelne Methoden in Maschinencode. Diese Methoden laufen dann direkt auf dem Prozessor, ohne dass ein Interpreter aktiv werden muss, und sind sehr viel schneller.

Hotspot bei der Arbeit zusehen

Wenn man wissen will, welche Methoden Hotspot wann kompiliert, kann man das entsprechende Logging mit dem Schalter -XX:+PrintCompilation aktivieren. Dieser Schalter erzeugt jedes Mal eine Zeile, wenn eine Methode JIT-kompiliert wird.

Die am häufigsten aufgerufenen Methoden werden zuerst kompiliert. Welche das sind, hängt natürlich vom jeweiligen Programm ab, aber z. B. String.hashCode() oder der Konstruktor von Object sind häufig früh dabei. In der Liste tauchen dieselben Methoden meist mehrmals auf. Das liegt daran, dass Hotspot mehrstufig arbeitet. Zunächst übersetzt er eine Methode in einfachen, wenig optimierten Maschinencode. Wenn sie besonders häufig verwendet wird, kompiliert er sie ein zweites (oder auch drittes) Mal und investiert dabei Zeit und Speicher für Optimierungen wie Inlining (s. u.).

Der Schalter -XX:+PrintAssembly veranlasst Hotspot dazu, den jeweils tatsächlich erzeugten Assembler-Code auszugeben. Man muss dabei zusätzlich den Schalter –XX:+UnlockDiagnosticVMOptions angeben. Außerdem muss man die Bibliothek hsdis-amd64.dll (bzw. hsdis-amd64.so) in das lib-Verzeichnis der JRE legen. Sie dient dazu, den Maschinencode menschenlesbar zu formatieren. Auch wenn man den Assembler-Output nicht im Detail untersucht (oder versteht), kann man recht leicht erkennen, welche Optimierungen Hotspot bei welchen Methoden anwendet.

Wenn man das Compilerverhalten von Hotspot ernsthafter untersuchen will, bietet sich das Open-Source-Tool JITWatch an. Es bereitet die Logs übersichtlich auf und liefert eine Reihe nützlicher Analysen.

Tuning

Hotspot hat auch eine Vielzahl von Schaltern, die das Compilerverhalten beeinflussen. Nicht, dass man das im Allgemeinen für produktive Systeme tun sollte – aber durch solches Tuning kann man Hotspot besser verstehen, und es ist intellektuell befriedigend.

Zunächst einmal hat Hotspot intern zwei komplett getrennte Compiler, die „C1“ und „C2“ heißen und unterschiedliche Trade-offs bei der Optimierung anwenden. Man kann sie mit den Schaltern -client bzw. -server auswählen. Seit mindestens Java 7 enthalten 64-Bit-JREs allerdings nur noch C2, sodass dieser Schalter keine Funktion mehr hat.

Der Schalter -XX:CompileThreshold=… legt fest, wie oft eine Methode aufgerufen werden muss, bevor Hotspot sie kompiliert; der Default ist 10 000. Diesem selektiven Optimieren häufig aufgerufener Methoden hat Hotspot übrigens seinen Namen zu verdanken.

Hotspot kompiliert dabei per Default keine großen („huge“) Methoden, was man mit dem Schalter -XX:-DontCompileHugeMethods ändern kann. Die Grenze liegt bei 8 000 Bytecodes in einer Methode und ist einer von vielen Gründen, Methoden auf eine übersichtliche Größe zu beschränken.

Wenn eine Schleife innerhalb einer Methode sehr häufig durchlaufen wird, kann es sich lohnen, die Methode zu kompilieren, auch wenn sie nur selten aufgerufen wird. Im extremsten Fall wird eine Methode mit einer Endlosschleife – z. B. einem Event-Loop – nur einmal aufgerufen, kann aber trotzdem wichtig für die Gesamtperformance sein.

Für solche Fälle zählt Hotspot die Anzahl der Schleifendurchläufe (genauer gesagt, die Anzahl der rückwärts gerichteten Sprünge) innerhalb einer Methode und kompiliert sie, wenn die Zahl einen Schwellwert überschreitet. Der Wert liegt per Default bei 10 000 und lässt sich mit -XX:BackEdgeThreshold=… konfigurieren.

Normalerweise ersetzt Hotspot die unkompilierte Methode schrittweise durch den kompilierten Code. Neue Aufrufer landen im kompilierten Code, aber wenn ein Thread die unkompilierte Methode gerade ausführt, führt er weiter den unkompilierten Code aus. Für Endlosschleifen wäre das wenig hilfreich. Deshalb schaltet Hotspot in diesen Fällen den Code um, während er gerade ausgeführt wird („On-Stack Replacement“).

Inlining

Hotspot kann Aufrufe von kleinen Methoden wegoptimieren, indem er den Aufruf durch den aufgerufenen Code ersetzt („Inlining“). Wenn man z. B. eine einfache Getter-Methode aufruft, ersetzt Hotspot den Aufruf durch den Variablenzugriff, sodass Getter-Methoden zur Laufzeit im Allgemeinen nichts kosten. Der Schalter -XX:+PrintInlining bringt Hotspot dazu, das zu protokollieren; er erfordert zusätzlich wieder -XX:+UnlockDiagnosticVMOptions.

Entgegen hartnäckiger Gerüchte hat das Schlüsselwort final seit mindestens Java 5 keinen Einfluss auf das Inlining von Methoden. Wenn Subklassen eine Methode unterschiedlich implementieren und Hotspot das erst merkt, nachdem es sich beim Inlining für eine der Implementierungen entschieden hat, macht es das Inlining nachträglich rückgängig („speculative inlining“).

Inlining beschleunigt die Programmausführung, verbraucht dafür aber mehr Speicher. Deshalb sind die Kriterien wichtig, nach denen Hotspot Inlining vornimmt oder eben auch nicht. Dort kommen sehr ausgefeilte Algorithmen zum Einsatz, die auch mit jeder Hotspot-Version weiter entwickelt werden.

Einige Eckpunkte lassen sich aber einfach verstehen und konfigurieren. So gibt es eine Obergrenze für die Größe von Methoden, die für Inlining in Frage kommen. Sie liegt per Default bei 35 Bytecodes und lässt sich über -XX:MaxInlineSize=… konfigurieren. Mit dem Schalter -XX:MaxInlineLevel=… kann man die maximale Anzahl verschachtelter Aufrufe konfigurieren, die Hotspot flachklopft; per Default sind es neun.

Kleine Methoden verursachen also typischerweise keinen Overhead zur Laufzeit, und man kann mit gutem Gewissen den Quelltext möglichst lesbar gestalten.

Escape Analysis

Wenn Objekte nur innerhalb einer Methode verwendet werden und garantiert nicht „entkommen“ können, kann Hotspot die Objekterzeugung komplett wegoptimieren und dadurch die Garbage Collection entlasten („Escape Analysis“).

Listing 1 illustriert das. Dort wird eine Klasse SmartInteger definiert, die einen int-Wert enthält, der im Konstruktor initialisiert wird. Sie definiert außerdem eine Methode isEven(), die überprüft, ob die Zahl gerade ist.

Listing 1

public class SmartInteger {
  private final int i;
  public SmartInteger (int i) {
    this.i = i;
  }
  public boolean isEven() {
    return i%2 == 0;
  }
}

Listing 2 

int n=7;
// scheinbar teuer:
System.out.println (new SmartInteger (n).isEven());
// scheinbar billiger:
System.out.println (n%2 == 0);

Zum Überprüfen, ob eine Zahl gerade ist, muss man dann jeweils eine temporäre Instanz von SmartInteger erzeugen (s. erster Aufruf in Listing 2). Das sieht nach unnötigem Overhead aus, weil ein überflüssiges Objekt erzeugt wird und eine Methode aufgerufen wird. Hotspot erkennt aber, dass das erzeugte Objekt nur lokal verwendet wird, und optimiert die Objekterzeugung komplett weg. Außerdem verschwindet der Methodenaufruf durch Inlining, sodass die Verwendung von SmartInteger genauso effizient ist wie die explizite Auswertung des Ausdrucks (s. zweiter Aufruf in Listing 2).

Die Analysen des Bytecodes, die für diese Optimierung nötig sind, kosten Zeit beim Kompilieren, weshalb Hotspot sie begrenzt. Es gibt eine Obergrenze für die Größe der untersuchten Methoden (-XX:MaxBCEAEstimateSize=…), die per Default bei 150 liegt.

Hotspot führt Escape Analysis über mehrere verschachtelte Methodenaufrufe hinweg durch, was recht bemerkenswert ist. Die Anzahl der Aufrufebenen ist allerdings begrenzt (-XX:MaxBCEAEstimateLevel=…) und liegt per Default bei fünf.

Fazit

Hotspot ist ziemlich gut darin, Code zur Laufzeit zu kompilieren und zu optimieren. Kleine Methoden werden dabei im Allgemeinen besser optimiert als große Methoden. Es spricht also meist auch unter Performancegesichtspunkten nichts gegen lesbaren, übersichtlichen Code.

Aber Hotspot optimiert nicht automatisch immer genau so, wie man es erwartet und gerne hätte. Wenn man also das letzte Quäntchen Performance aus einer Anwendung herausholen möchte (oder einfach neugierig ist), kann man Hotspot seine Aktivitäten protokollieren lassen, auf Wunsch sogar auf Assembler-Ebene. Und dann kann man sich für mehrere Alternativen ansehen, wie Hotspot sie verarbeitet. Auf dieser Ebene mit Hotspot zu interagieren, ist interessant und hat definitiv einen Geek-Faktor; bei ernsthaften Analysen hilft das Tool JITWatch.

Geschrieben von
Arno Haase
Arno Haase
Arno Haase ist freiberuflicher Softwareentwickler. Er programmiert Java aus Leidenschaft, arbeitet aber auch als Architekt, Coach und Berater. Seine Schwerpunkte sind modellgetriebene Softwareentwicklung, Persistenzlösungen mit oder ohne relationaler Datenbank und nebenläufige und verteilte Systeme. Arno spricht regelmäßig auf Konferenzen und ist Autor von Fachartikeln und Büchern. Er lebt mit seiner Frau und seinen drei Kindern in Braunschweig.
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
4000
  Subscribe  
Benachrichtige mich zu: