Java HotSpot JIT Compiler - Gerät, Überwachung und Optimierung (Teil 1)

Der JIT-Compiler (Just-in-Time) hat einen großen Einfluss auf die Anwendungsleistung. Für jeden Java-Programmierer ist es wichtig zu verstehen, wie es funktioniert, wie es überwacht und konfiguriert wird. In dieser zweiteiligen Artikelserie werden wir uns den JIT-Compiler in der HotSpot-JVM ansehen, wie er seinen Betrieb überwacht und wie er konfiguriert wird. In diesem ersten Teil werden wir uns den JIT-Compiler und seine Überwachung ansehen.



AOT- und JIT-Compiler



Prozessoren können nur einen begrenzten Satz von Anweisungen ausführen - Maschinencode. Damit ein Programm von einem Prozessor ausgeführt werden kann, muss es als Maschinencode dargestellt werden.



Es gibt kompilierte Programmiersprachen wie C und C ++. In diesen Sprachen geschriebene Programme werden als Maschinencode verteilt. Nachdem das Programm geschrieben wurde, übersetzt ein spezieller Prozess - der AOT-Compiler (Ahead-of-Time), der normalerweise einfach als Compiler bezeichnet wird, den Quellcode in Maschinencode. Der Maschinencode kann auf einem bestimmten Prozessormodell ausgeführt werden. Prozessoren mit einer gemeinsamen Architektur können denselben Code ausführen. Spätere Prozessormodelle unterstützen im Allgemeinen Anweisungen von früheren Modellen, jedoch nicht umgekehrt. Beispielsweise kann Maschinencode, der AVX-Anweisungen für die Intel Sandy Bridge-Prozessoren verwendet, nicht auf älteren Intel-Prozessoren ausgeführt werden. Es gibt verschiedene Möglichkeiten, um dieses Problem zu lösen, z. B. das Übertragen kritischer Teile des Programms in eine Bibliothek mit Versionen für die Hauptprozessormodelle.Oft werden Programme jedoch einfach für relativ alte Prozessormodelle kompiliert und nutzen die neuen Befehlssätze nicht.



Im Gegensatz zu kompilierten Programmiersprachen gibt es interpretierte Sprachen wie Perl und PHP. Mit diesem Ansatz kann derselbe Quellcode auf jeder Plattform ausgeführt werden, für die ein Interpreter vorhanden ist. Der Nachteil dieses Ansatzes ist, dass interpretierter Code langsamer ist als Maschinencode, der dasselbe tut.



Die Java-Sprache bietet einen anderen Ansatz, eine Kreuzung zwischen kompilierten und interpretierten Sprachen. Java-Anwendungen werden zu einem Zwischencode auf niedriger Ebene kompiliert - Bytecode.

Der Name Bytecode wurde gewählt, da genau ein Byte zum Codieren jeder Operation verwendet wird. In Java 10 gibt es ungefähr 200 Operationen.



Der Bytecode wird dann von der JVM sowie einem interpretierten Sprachprogramm ausgeführt. Da der Bytecode jedoch ein genau definiertes Format hat, kann die JVM ihn zur Laufzeit zu Maschinencode kompilieren. Natürlich können ältere Versionen der JVM keinen Maschinencode mit den nachfolgenden neuen Prozessoranweisungen generieren. Um ein Java-Programm zu beschleunigen, muss es nicht einmal neu kompiliert werden. Es reicht aus, es auf einer neueren JVM auszuführen.



HotSpot JIT-Compiler



Verschiedene JVM-JIT-Implementierungen können den Compiler auf unterschiedliche Weise implementieren. In diesem Artikel befassen wir uns mit Oracle HotSpot JVM und seiner JIT-Compiler-Implementierung. Der Name HotSpot stammt von dem Ansatz, den die JVM zum Kompilieren von Bytecode verwendet. In der Regel werden in einer Anwendung nur kleine Teile des Codes häufig genug ausgeführt, und die Leistung der Anwendung hängt hauptsächlich von der Ausführungsgeschwindigkeit dieser bestimmten Teile ab. Diese Teile des Codes werden als Hot Spots bezeichnet und vom JIT-Compiler kompiliert. Diesem Ansatz liegen mehrere Urteile zugrunde. Wenn der Code nur einmal ausgeführt wird, ist das Kompilieren dieses Codes Zeitverschwendung. Ein weiterer Grund sind Optimierungen. Je öfter die JVM Code ausführt, desto mehr Statistiken werden gesammelt, mit denen Sie optimierten Code generieren können.Darüber hinaus teilt der Compiler die Ressourcen der virtuellen Maschine mit der Anwendung selbst, sodass die für die Profilerstellung und Optimierung aufgewendeten Ressourcen zur Ausführung der Anwendung selbst verwendet werden können, wodurch ein gewisses Gleichgewicht eingehalten werden muss. Die Arbeitseinheit für den HotSpot-Compiler ist eine Methode und eine Schleife.

Die Einheit des kompilierten Codes heißt nmethod (kurz für native Methode).



Abgestufte Zusammenstellung



Tatsächlich verfügt die HotSpot-JVM nicht über einen, sondern über zwei Compiler: C1 und C2. Ihre anderen Namen sind Client und Server. In der Vergangenheit wurde C1 in GUI-Anwendungen und C2 in Serveranwendungen verwendet. Compiler unterscheiden sich darin, wie schnell sie mit dem Kompilieren von Code beginnen. C1 beginnt, den Code schneller zu kompilieren, während C2 optimierten Code generieren kann.



In früheren Versionen der JVM mussten Sie einen Compiler mit den Flags -client für den Client und -server oder -d64 auswählenfür den Serverraum. JDK 6 führte den mehrstufigen Kompilierungsmodus ein. Grob gesagt liegt sein Wesen in einem sequentiellen Übergang vom interpretierten Code zum vom Compiler C1 und dann von C2 erzeugten Code. In JDK 8 werden die Flags -client, -server und -d64 ignoriert, und in JDK 11 wurde das Flag -d64 entfernt und führt zu einem Fehler. Sie können den gestuften Kompilierungsmodus mit dem Flag -XX: -TieredCompilation deaktivieren .



Es gibt 5 Kompilierungsstufen:



  • 0 - interpretierter Code
  • 1 - C1 vollständig optimiert (keine Profilerstellung)
  • 2 - C1 unter Berücksichtigung der Anzahl der Methodenaufrufe und Schleifeniterationen
  • 3 - C1 mit Profilerstellung
  • 4 - C2


Typische Abfolgen von Übergängen zwischen Ebenen sind in der Tabelle aufgeführt.

Reihenfolge

Beschreibung

0-3-4 Dolmetscher, Stufe 3, Stufe 4. Am häufigsten.
0-2-3-4 , 4 (C2) . 2. , 3 , , 4.
0-2-4 , 3 . 4 3. 2 4.
0-3-1 . 3, , 4 . 1.
0-4 .


Code cache



Der vom JIT-Compiler kompilierte Maschinencode wird in einem Speicherbereich gespeichert, der als Code-Cache bezeichnet wird. Außerdem wird der Maschinencode der virtuellen Maschine selbst gespeichert, z. B. der Interpretercode. Die Größe dieses Speicherbereichs ist begrenzt, und wenn er voll ist, wird die Kompilierung gestoppt. In diesem Fall werden einige der "heißen" Methoden weiterhin vom Interpreter ausgeführt. Im Falle eines Überlaufs zeigt die JVM die folgende Meldung an:



Java HotSpot(TM) 64-Bit Server VM warning: CodeCache is full.
         Compiler has been disabled.

      
      





Eine andere Möglichkeit, einen Überlauf dieses Speicherbereichs festzustellen, besteht darin, die Compiler-Protokollierung zu aktivieren (wie dies beschrieben wird, wird unten erläutert).

Der Code-Cache kann auf die gleiche Weise wie andere Speicherbereiche in der JVM konfiguriert werden. Die Anfangsgröße wird durch den Parameter -XX: InitialCodeCacheSize angegeben . Die maximale Größe wird durch den Parameter -XX: ReservedCodeCacheSize angegeben . Standardmäßig beträgt die Anfangsgröße 2496 KB. Die maximale Größe beträgt 48 MB, wenn die gestufte Kompilierung deaktiviert ist, und 240 MB, wenn sie aktiviert ist.



Seit Java 9 ist der Code-Cache in drei Segmente unterteilt (die Gesamtgröße ist immer noch durch die oben beschriebenen Grenzwerte begrenzt):



  • JVM internal (non-method code). , JVM, , . . 5.5 MB. -XX:NonNMethodCodeHeapSize.
  • Profiled code. . non-method code . 21.2 MB 117.2 MB . -XX:ProfiledCodeHeapSize.
  • Non-profiled code. . non-method code . 21.2 MB 117.2 MB . -XX: NonProfiledCodeHeapSize.




Sie können die Protokollierung des Kompilierungsprozesses mit dem Flag -XX: + PrintCompilation aktivieren (standardmäßig deaktiviert). Wenn dieses Flag gesetzt ist, schreibt die JVM jedes Mal, wenn eine Methode oder Schleife kompiliert wird, eine Nachricht in den Standardausgabestream (STDOUT). Die meisten Nachrichten haben das folgende Format: Zeitstempel compilation_id-Attribute tiered_level method_name size deopt.



Das Zeitstempelfeld ist die Zeit seit dem Start der JVM.



Das Feld compilation_id ist die interne ID des Problems. Es wächst normalerweise mit jeder Nachricht nacheinander, aber manchmal kann die Reihenfolge nicht in Ordnung sein. Dies kann passieren, wenn mehrere Kompilierungsthreads parallel ausgeführt werden.



Das Attributfeld besteht aus fünf Zeichen, die zusätzliche Informationen zum kompilierten Code enthalten. Wenn eines der Attribute nicht zutreffend ist, wird stattdessen ein Leerzeichen angezeigt. Folgende Attribute existieren:



  • % - OSR (On-Stack-Ersatz);
  • s - die Methode ist synchronisiert;
  • ! - Die Methode enthält einen Ausnahmebehandler.
  • b - Kompilierung im Blockierungsmodus;
  • n - Die kompilierte Methode ist ein Wrapper für die native Methode.


OSR steht für On-Stack-Austausch. Die Kompilierung ist ein asynchroner Prozess. Wenn die JVM entscheidet, dass eine Methode kompiliert werden muss, wird sie in die Warteschlange gestellt. Während die Methode kompiliert wird, führt die JVM sie weiterhin vom Interpreter aus. Beim nächsten erneuten Aufruf der Methode wird ihre kompilierte Version ausgeführt. Im Falle eines langen Zyklus ist es unpraktisch, auf den Abschluss der Methode zu warten - sie wird möglicherweise überhaupt nicht abgeschlossen. Die JVM kompiliert den Hauptteil der Schleife und sollte mit der Ausführung der kompilierten Version beginnen. Die JVM speichert den Status von Threads auf einem Stapel. Für jede aufgerufene Methode wird auf dem Stapel ein neues Stapelrahmenobjekt erstellt, in dem die Methodenparameter, lokalen Variablen, der Rückgabewert und andere Werte gespeichert sind. Während der OSR wird ein neuer Stapelrahmen erstellt, der den vorherigen ersetzt.







Quelle: Der Java HotSpotTM-Clientcompiler für virtuelle Maschinen: Technologie und Anwendung

Die Attribute "s" und "!" Ich denke, sie brauchen keine Erklärung.



Das Attribut "b" bedeutet, dass die Kompilierung nicht im Hintergrund stattgefunden hat und in modernen Versionen der JVM nicht zu finden ist.



Das Attribut "n" bedeutet, dass die kompilierte Methode ein Wrapper um eine native Methode ist.

Das Feld tiered_level enthält die Ebenennummer, auf der der Code kompiliert wurde, oder kann leer sein, wenn die gestufte Kompilierung deaktiviert ist.



Das Feld Methodenname enthält den Namen der kompilierten Methode oder den Namen der Methode, die die kompilierte Schleife enthält.



Das Größenfeld enthält die Größe des kompilierten Bytecodes und nicht die Größe des resultierenden Maschinencodes. Die Größe ist in Bytes.



Das Deopt-Feld wird nicht in jeder Nachricht angezeigt, es enthält den Namen der durchgeführten Deoptimierung und kann Nachrichten wie "Nicht betreten" und "Zombie gemacht" enthalten.

Manchmal erscheinen die folgenden Einträge im Protokoll: Zeitstempel compile_id COMPILE SKIPPED: Grund. Sie bedeuten, dass beim Kompilieren der Methode ein Fehler aufgetreten ist. Es gibt Zeiten, in denen dies erwartet wird:



  • Code-Cache gefüllt - Der Code-Cache-Speicherbereich muss vergrößert werden.
  • Gleichzeitiges Laden von Klassen - Die Klasse wurde zur Kompilierungszeit geändert.


In allen Fällen, mit Ausnahme eines Code-Cache-Überlaufs, versucht die JVM erneut zu kompilieren. Wenn dies nicht der Fall ist, können Sie versuchen, den Code zu vereinfachen.



Falls der Prozess ohne das Flag -XX: + PrintCompilation gestartet wurde, können Sie den Kompilierungsprozess mit dem Dienstprogramm jstat anzeigen . Jstat bietet zwei Optionen zum Anzeigen von Kompilierungsinformationen.



Der Parameter -compiler zeigt eine Zusammenfassung der Compileroperation an (5003 ist die Prozess-ID):



% jstat -compiler 5003
Compiled Failed Invalid   Time   FailedType FailedMethod
     206         0          0    1.97                0

      
      





Dieser Befehl zeigt auch die Anzahl der Methoden an, die nicht kompiliert werden konnten, und den Namen der letzten Methode.



Der Parameter -printcompilation gibt Informationen zur zuletzt kompilierten Methode aus. In Kombination mit dem zweiten Parameter, der Wiederholungsperiode der Operation, können Sie den Kompilierungsprozess über die Zeit beobachten. Im folgenden Beispiel wird der Befehl -printcompilation jede Sekunde (1000 ms) ausgeführt:



% jstat -printcompilation 5003 1000
Compiled  Size  Type Method
     207     64    1 java/lang/CharacterDataLatin1 toUpperCase
     208      5    1 java/math/BigDecimal$StringBuilderHelper getCharArray

      
      





Pläne für den zweiten Teil



Im nächsten Teil werden wir uns die Zählerschwellen ansehen, bei denen die JVM mit dem Kompilieren beginnt, und wie Sie sie ändern können. Wir werden auch untersuchen, wie die JVM die Anzahl der Compiler-Threads auswählt, wie Sie sie ändern können und wann Sie dies tun sollten. Lassen Sie uns zum Schluss einen kurzen Blick auf einige der vom JIT-Compiler durchgeführten Optimierungen werfen.



Referenzen und Links






All Articles