Kapitel 4. Arbeiten mit dem JIT-Compiler

Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com

Der Just-in-Time-Compiler (JIT) ist das Herzstück der Java Virtual Machine; nichts kontrolliert die Leistung deiner Anwendung mehr als der JIT-Compiler.

Dieses Kapitel befasst sich eingehend mit dem Compiler. Es beginnt mit Informationen über die Funktionsweise des Compilers und bespricht die Vor- und Nachteile der Verwendung eines JIT-Compilers. Bis das JDK 8 aufkam, musstest du dich zwischen zwei Java-Compilern entscheiden. Heute gibt es diese beiden Compiler immer noch, aber sie arbeiten zusammen, obwohl es in seltenen Fällen notwendig ist, sich für einen zu entscheiden. Zum Schluss schauen wir uns einige mittlere und fortgeschrittene Optimierungen des Compilers an. Wenn eine Anwendung ohne ersichtlichen Grund langsam läuft, können diese Abschnitte dir helfen herauszufinden, ob der Compiler schuld ist.

Just-in-Time Compiler: Ein Überblick

Wir beginnen mit einigen einführenden Informationen; wenn du die Grundlagen der Just-in-Time-Kompilierung verstehst, kannst du sie gerne überspringen.

Computer - genauer gesagt CPUs - können nur relativ wenige, spezifische Anweisungen ausführen, die als Maschinencode bezeichnet werden. Alle Programme, die die CPU ausführt, müssen daher in diese Befehle übersetzt werden.

Sprachen wie C++ und Fortran werden als kompilierte Sprachen bezeichnet, weil ihre Programme als Binärcode (kompiliert) geliefert werden: Das Programm wird geschrieben, und ein statischer Compiler erzeugt dann eine Binärdatei. Komplementäre CPUs können dieselbe Binärdatei ausführen: AMD- und Intel-CPUs haben z. B. einen gemeinsamen Satz von Assembler-Befehlen, und spätere Versionen von CPUs können fast immer dieselben Befehle ausführen wie frühere Versionen dieser CPU. Das Gegenteil ist nicht immer der Fall: Neue Versionen von CPUs führen oft Befehle ein, die auf älteren Versionen von CPUs nicht ausgeführt werden können.

Sprachen wie PHP und Perl hingegen werden interpretiert. Derselbe Programmquellcode kann auf jeder CPU ausgeführt werden, solange die Maschine über den richtigen Interpreter verfügt (das heißt, das Programm namens php oder perl). Der Interpreter übersetzt jede Zeile des Programms in Binärcode, wenn diese Zeile ausgeführt wird.

Jedes System hat Vor- und Nachteile. Programme, die in interpretierten Sprachen geschrieben wurden, sind portabel: Du kannst denselben Code auf jedem Rechner mit dem entsprechenden Interpreter ausführen. Allerdings kann er langsam laufen. Ein einfaches Beispiel dafür ist eine Schleife: Der Interpreter übersetzt jede Codezeile neu, wenn sie in der Schleife ausgeführt wird. Der kompilierte Code muss diese Übersetzung nicht wiederholt vornehmen.

Ein guter Compiler berücksichtigt mehrere Faktoren, wenn er eine Binärdatei erstellt. Ein einfaches Beispiel ist die Reihenfolge der Binäranweisungen: Nicht alle Assembler-Anweisungen benötigen gleich viel Zeit für die Ausführung. Eine Anweisung, die die in zwei Registern gespeicherten Werte addiert, kann in einem Zyklus ausgeführt werden, aber das Abrufen der für die Addition benötigten Werte (aus dem Hauptspeicher) kann mehrere Zyklen dauern.

Ein guter Compiler erzeugt daher einen Binärcode, der die Anweisung zum Laden der Daten ausführt, andere Anweisungen ausführt und dann - wenn die Daten verfügbar sind - die Addition ausführt. Ein Interpreter, der jeweils nur eine Codezeile betrachtet, hat nicht genug Informationen, um diese Art von Code zu erzeugen; er wird die Daten aus dem Speicher anfordern, warten, bis sie verfügbar sind, und dann die Addition ausführen. Schlechte Compiler machen übrigens das Gleiche, und selbst der beste Compiler kann nicht unbedingt verhindern, dass gelegentlich auf eine Anweisung gewartet wird, um sie auszuführen.

Aus diesen (und anderen) Gründen wird interpretierter Code fast immer messbar langsamer sein als kompilierter Code: Compiler haben genug Informationen über das Programm, um Optimierungen am Binärcode vorzunehmen, die ein Interpreter einfach nicht durchführen kann .

Interpretierter Code hat den Vorteil der Portabilität. Eine für eine ARM-CPU kompilierte Binärdatei kann natürlich nicht auf einer Intel-CPU laufen. Aber eine Binärdatei, die die neuesten AVX-Befehle der Sandy Bridge-Prozessoren von Intel verwendet, kann auch nicht auf älteren Intel-Prozessoren ausgeführt werden. Daher wird kommerzielle Software häufig für eine relativ alte Version eines Prozessors kompiliert und nutzt nicht die neuesten verfügbaren Befehle. Es gibt verschiedene Tricks, um dieses Problem zu umgehen, z. B. die Auslieferung einer Binärdatei mit mehreren gemeinsam genutzten Bibliotheken, die leistungsabhängigen Code ausführen und für verschiedene CPU-Versionen verfügbar sind.

Java versucht, hier einen Mittelweg zu finden. Java-Anwendungen werden kompiliert - aber nicht in eine bestimmte Binärdatei für eine bestimmte CPU, sondern in eine dazwischenliegende Low-Level-Sprache.Diese Sprache (bekannt als Java-Bytecode) wird dann von der javaBinärdatei ausgeführt (so wie ein interpretiertes PHP-Skript von der phpBinärdatei ausgeführt wird). Dadurch ist Java so plattformunabhängig wie eine interpretierteSprache. Da es einen idealisierten Binärcode ausführt, kann das Programmjava den Code während der Ausführung in die Plattformbinärdatei kompilieren. Diese Kompilierung findet statt, während das Programm ausgeführt wird: Sie geschieht "just in time".

Diese Kompilierung unterliegt immer noch der Plattformabhängigkeit. JDK 8 kann zum Beispiel keinen Code für den neuesten Befehlssatz der Skylake-Prozessoren von Intel erzeugen, JDK 11 hingegen schon. Mehr dazu erfährst du unter "Erweiterte Compiler-Flags".

Die Art und Weise, wie die Java Virtual Machine diesen Code während der Ausführung kompiliert, steht im Mittelpunkt dieses Kapitels.

HotSpot-Zusammenstellung

Wie in Kapitel 1 beschrieben, handelt es sich bei der in diesem Buch behandelten Java-Implementierung um die HotSpot JVM von Oracle. Der Name (HotSpot) kommt von dem Ansatz, den sie bei der Kompilierung des Codes verfolgt. In einem typischen Programm wird nur eine kleine Teilmenge des Codes häufig ausgeführt, und die Leistung einer Anwendung hängt in erster Linie davon ab, wie schnell diese Codeabschnitte ausgeführt werden. Diese kritischen Abschnitte werden als HotSpots der Anwendung bezeichnet; je häufiger der Codeabschnitt ausgeführt wird, desto heißer ist er.

Wenn die JVM also Code ausführt, beginnt sie nicht sofort mit der Kompilierung des Codes. Dafür gibt es zwei grundlegende Gründe. Erstens: Wenn der Code nur einmal ausgeführt werden soll, ist die Kompilierung im Grunde genommen vergebliche Mühe; es ist schneller, die Java-Bytecodes zu interpretieren als sie zu kompilieren und den kompilierten Code (nur einmal) auszuführen.

Wenn es sich bei dem fraglichen Code jedoch um eine häufig aufgerufene Methode oder eine Schleife handelt, die viele Iterationen durchläuft, lohnt sich das Kompilieren: Die Zyklen, die zum Kompilieren des Codes benötigt werden, werden durch die Einsparungen bei der mehrfachen Ausführung des schnelleren kompilierten Codes aufgewogen. Dieser Kompromiss ist ein Grund dafür, dass der Compiler den interpretierten Code zuerst ausführt - er kann herausfinden, welche Methoden häufig genug aufgerufen werden, um ihre Kompilierung zu rechtfertigen.

Der zweite Grund ist ein Optimierungsgrund: Je öfter die JVM eine bestimmte Methode oder Schleife ausführt, desto mehr Informationen hat sie über diesen Code. Dadurch kann die JVM zahlreiche Optimierungen vornehmen, wenn sie den Code kompiliert.

Diese Optimierungen (und die Möglichkeiten, sie zu beeinflussen) werden später in diesem Kapitel besprochen, aber für ein einfaches Beispiel, betrachte die Methodeequals(). Diese Methode ist in jedem Java-Objekt vorhanden (weil sie von der KlasseObjectgeerbt wird) und wird oftüberschrieben. Wenn der Interpreter auf die Anweisungb = obj1.equals(obj2) stößt, muss er den Typ (die Klasse) vonobj1nachschlagen, um zu wissen, welche Methodeequals()ausgeführt werden soll. Diese dynamische Suche kann ziemlich zeitaufwändig sein.

Mit der Zeit stellt die JVM fest, dass jedes Mal, wenn diese Anweisung ausgeführt wird,obj1vom Typjava.lang.String ist. Dann kann die JVM kompilierten Code erzeugen, der die MethodeString.equals()direkt aufruft. Jetzt ist der Code nicht nur schneller, weil er kompiliert ist, sondern auch, weil er die Suche nach der aufzurufenden Methode überspringen kann.

Ganz so einfach ist es nicht; es ist möglich, dassobj1bei der nächsten Ausführung des Codes auf etwas anderes alsString verweist. Die JVM wird kompilierten Code erstellen, der diese Möglichkeit berücksichtigt, was eine Deoptimierung und eine erneute Optimierung des betreffenden Codes erfordert (ein Beispiel findest du unter "Deoptimierung"). Nichtsdestotrotz wird der kompilierte Code insgesamt schneller sein (zumindest solangeobj1aufString verweist), weil er die Suche nach der auszuführenden Methode überspringt. Diese Art der Optimierung kann erst vorgenommen werden, nachdem der Code eine Weile gelaufen ist und beobachtet wurde, was er tut: Das ist der zweite Grund, warum JIT-Compiler mit der Kompilierung von Codeabschnitten warten.

Kurze Zusammenfassung

  • Java wurde entwickelt, um die Vorteile der Plattformunabhängigkeit von Skriptsprachen und der nativen Leistung von kompilierten Sprachen zu nutzen.

  • Eine Java-Klassendatei wird in eine Zwischensprache (Java-Bytecodes) kompiliert, die dann von der JVM weiter in Assemblersprache übersetzt wird.

  • Bei der Kompilierung des Bytecodes in Assemblersprache werden Optimierungen vorgenommen, die die Leistung erheblich verbessern.

Mehrstufige Zusammenstellung

Früher gab es zwei Arten von JIT-Compilern, und du musstest verschiedene Versionen des JDK installieren, je nachdem, welchen Compiler du verwenden wolltest. Diese Compiler sind bekannt alsclientundserverCompiler. Im Jahr 1996 war dies ein wichtiger Unterschied, im Jahr 2020 nicht mehr so sehr. Heute enthalten alle handelsüblichen JVMs beide Compiler (obwohl sie im allgemeinen Sprachgebrauch meist als server JVMs bezeichnet werden).

Obwohl sie als Server-JVMs bezeichnet werden, bleibt der Unterschied zwischen Client- und Server-Compilern bestehen; beide Compiler stehen der JVM zur Verfügung und werden von ihr verwendet. Diesen Unterschied zu kennen ist also wichtig, um zu verstehen, wie der Compiler funktioniert.

In der Vergangenheit haben die JVM-Entwickler (und sogar einige Tools) die Compiler manchmal mit C1 (Compiler 1, Client-Compiler) und C2 (Compiler 2, Server-Compiler) bezeichnet . Da die Unterscheidung zwischen einem Client- und einem Server-Computer längst überholt ist, werden wir diese Bezeichnungen beibehalten.

Der Hauptunterschied zwischen den beiden Compilern ist ihre Aggressivität beim Kompilieren von Code. Der C1-Compiler beginnt früher mit der Kompilierung als der C2-Compiler. Das bedeutet, dass der C1-Compiler zu Beginn der Codeausführung schneller ist, weil er entsprechend mehr Code kompiliert hat als der C2-Compiler.

Der technische Kompromiss ist das Wissen, das der C2-Compiler während seiner Wartezeit gewinnt: Dieses Wissen ermöglicht es dem C2-Compiler, bessere Optimierungen im kompilierten Code vorzunehmen. Letztendlich wird der vom C2-Compiler erzeugte Code schneller sein als der vom C1-Compiler erzeugte. Aus Sicht des Nutzers hängt der Nutzen dieses Kompromisses davon ab, wie lange das Programm laufen wird und wie wichtig die Startzeit des Programms ist.

Als diese Compiler noch getrennt waren, stellte sich die Frage, warum es überhaupt eine Wahlmöglichkeit geben musste: Konnte die JVM nicht mit dem C1-Compiler beginnen und dann den C2-Compiler verwenden, wenn der Code heißer wird? Diese Technik ist als Tiered Compilation bekannt und wird heute von allen JVMs verwendet.Sie kann explizit mit dem-XX:-TieredCompilation Flag explizit deaktiviert werden (der Standardwert ist true); im Abschnitt "Erweiterte Compiler-Flags" werden wir die Auswirkungen dieser Vorgehensweise besprechen.

Gemeinsame Compiler-Flags

Zwei häufig verwendete Flags beeinflussen den JIT-Compiler; wir werden sie in diesem Abschnitt betrachten.

Abstimmung des Code-Caches

Wenn die JVM Code kompiliert, speichert sie die Anweisungen in Assemblersprache im Code-Cache. Der Code-Cache hat eine feste Größe, und wenn er voll ist, kann die JVM keinen weiteren Code mehr kompilieren.

Es ist leicht zu erkennen, was passiert, wenn der Code-Cache zu klein ist: Einige heiße Methoden werden kompiliert, andere aber nicht: Die Anwendung wird am Ende eine Menge (sehr langsamen) interpretierten Code ausführen.

Wenn der Code-Cache voll ist, gibt die JVM diese Warnung aus:

Java HotSpot(TM) 64-Bit Server VM warning: CodeCache is full.
         Compiler has been disabled.
Java HotSpot(TM) 64-Bit Server VM warning: Try increasing the
         code cache size using -XX:ReservedCodeCacheSize=

Diese Meldung kann leicht übersehen werden. Eine andere Möglichkeit, um festzustellen, ob der Compiler aufgehört hat, Code zu kompilieren, ist, die Ausgabe des Kompilierungsprotokolls zu verfolgen, das weiter unten in diesem Abschnitt beschrieben wird.

Es gibt eigentlich keinen guten Mechanismus, um herauszufinden, wie viel Code-Cache eine bestimmte Anwendung braucht. Wenn du also die Größe des Code-Caches erhöhen musst, ist das eine Art "hit-and-miss"-Operation; eine typische Option ist es, den Standardwert einfach zu verdoppeln oder zu vervierfachen.

Die maximale Größe des Code-Caches wird über das-XX:ReservedCodeCacheSize=N Flag festgelegt (wobei N die gerade erwähnte Voreinstellung für den jeweiligen Compiler ist). Der Code-Cache wird wie der meiste Speicher in der JVM verwaltet: Es gibt eine Anfangsgröße (festgelegt durch -XX:InitialCodeCacheSize=N ). Die Zuweisung der Code-Cache-Größe beginnt mit der Anfangsgröße und erhöht sich, wenn sich der Cache füllt. Die anfängliche Größe des Code-Caches beträgt 2.496 KB und die maximale Größe ist standardmäßig 240 MB. Die Größenänderung des Caches erfolgt im Hintergrund und hat keinen Einfluss auf die Leistung, so dass in der Regel nur die ReservedCodeCacheSize Größe (d. h. die maximale Größe des Code-Caches) eingestellt werden muss.

Gibt es einen Nachteil, wenn man einen wirklich großen Wert für die maximale Größe des Code-Caches angibt, damit der Platz nie knapp wird? Das hängt von den Ressourcen ab, die auf dem Zielcomputer verfügbar sind. Wenn eine Code-Cache-Größe von 1 GB angegeben wird, reserviert die JVM 1 GB nativen Speicher. Dieser Speicher wird erst bei Bedarf zugewiesen, aber er ist trotzdem reserviert. Das bedeutet, dass auf deinem Rechner genügend virtueller Speicher vorhanden sein muss, um die Reservierung zu erfüllen.

Wenn du außerdem noch einen alten Windows-Rechner mit einer 32-Bit-JVM hast, darf die Gesamtgröße des Prozesses 4 GB nicht überschreiten. Dazu gehören der Java-Heap, der Platz für den gesamten Code der JVM selbst (einschließlich der nativen Bibliotheken und Thread-Stacks), der native Speicher, den die Anwendung zuweist (entweder direkt oder über die New I/O [NIO]-Bibliotheken), und natürlich der Code-Cache.

Das sind die Gründe, warum der Code-Cache nicht unbegrenzt ist und bei großen Anwendungen manchmal angepasst werden muss. Auf 64-Bit-Maschinen mit ausreichend Speicher hat ein zu hoher Wert wahrscheinlich keine praktische Auswirkung auf die Anwendung: Der Anwendung geht der Prozessspeicher nicht aus und die zusätzliche Speicherreservierung wird in der Regel vom Betriebssystem akzeptiert.

In Java 11 ist der Code-Cache in drei Teile unterteilt:

  • Nicht-Methoden-Code

  • Profilierter Code

  • Nicht profilierter Code

Standardmäßig ist der Code-Cache genauso groß (bis zu 240 MB), und du kannst die Gesamtgröße des Code-Caches mit dem FlagReservedCodeCacheSizeanpassen. In diesem Fall wird dem Nicht-Methoden-Code-Segment entsprechend der Anzahl der Compiler-Threads (siehe "Compilier-Threads") Platz zugewiesen; auf einer Maschine mit vier CPUs sind das etwa 5,5 MB. Die beiden anderen Segmente teilen sich dann gleichmäßig den verbleibenden gesamten Code-Cache - zum Beispiel jeweils 117,2 MB auf einem Rechner mit vier CPUs (insgesamt also 240 MB).

In den seltensten Fällen musst du diese Segmente einzeln abstimmen, aber wenn doch, sind die Flaggen wie folgt :

  • -XX:NonNMethodCodeHeapSize=Nfür den Nicht-Methoden-Code

  • -XX:ProfiledCodeHapSize=N für den profilierten Code

  • -XX:NonProfiledCodeHapSize=N für den nicht profilierten Code

Die Größe des Code-Caches (und der JDK 11-Segmente) kann in Echtzeit überwacht werden, indem du jconsole verwendest und das Diagramm "Memory Pool Code Cache" im Fenster "Speicher" auswählst. Du kannst auch das Native Memory Tracking von Java aktivieren, wie in Kapitel 8 beschrieben.

Kurze Zusammenfassung

  • Der Code-Cache ist eine Ressource mit einer bestimmten Maximalgröße, die sich auf die Gesamtmenge des kompilierten Codes auswirkt, den die JVM ausführen kann.

  • Sehr große Anwendungen können in der Standardkonfiguration den gesamten Code-Cache verbrauchen; überwache den Code-Cache und vergrößere ihn bei Bedarf.

Überprüfung des Kompilierungsprozesses

Das zweite Flag ist kein Tuning im eigentlichen Sinne: Es wird die Leistung einer Anwendung nicht verbessern. Vielmehr gibt uns das -XX:+PrintCompilationFlag (das standardmäßig false lautet) Einblick in die Arbeitsweise des Compilers (wir werden uns aber auch Tools ansehen, die ähnliche Informationen liefern).

WennPrintCompilationaktiviert ist, gibt die JVM jedes Mal, wenn eine Methode (oder Schleife) kompiliert wird, eine Zeile mit Informationen darüber aus, was gerade kompiliert wurde.

Die meisten Zeilen des Kompilierungsprotokolls haben das folgende Format:

timestamp compilation_id attributes (tiered_level) method_name size deopt

Der Zeitstempel hier ist die Zeit, nachdem die Kompilierung beendet wurde (relativ zu 0, dem Zeitpunkt, an dem die JVM gestartet wurde).

Die compilation_id ist eine interne Task-ID. Normalerweise steigt diese Zahl einfach monoton an, aber manchmal kann es vorkommen, dass die Kompilierungs-ID nicht in der richtigen Reihenfolge ist. Das passiert am häufigsten, wenn es mehrere Kompilierungs-Threads gibt, und zeigt an, dass die Kompilierungs-Threads schneller oder langsamer als die anderen laufen. Daraus darfst du aber nicht schließen, dass eine bestimmte Kompilierungsaufgabe übermäßig langsam war: Das liegt in der Regel nur an der Thread-Planung.

Das Feld attributes besteht aus einer Reihe von fünf Zeichen, die den Zustand des kompilierten Codes angeben. Wenn ein bestimmtes Attribut auf die jeweilige Kompilierung zutrifft, wird das in der folgenden Liste aufgeführte Zeichen gedruckt; andernfalls wird ein Leerzeichen für dieses Attribut gedruckt. Die fünfstellige Attributzeichenkette kann also aus zwei oder mehr Elementen bestehen, die durch Leerzeichen getrennt sind. Die verschiedenen Attribute lauten wie folgt:

%

Die Zusammenstellung ist OSR.

s

Die Methode ist synchronisiert.

!

Die Methode hat einen Exception-Handler.

b

Die Kompilierung erfolgte im blockierenden Modus.

n

Die Kompilierung eines Wrappers für eine native Methode wurde durchgeführt.

Das erste dieser Attribute bezieht sich auf die On-Stack-Ersetzung (OSR). Die JIT-Kompilierung ist ein asynchroner Prozess: Wenn die JVM entscheidet, dass eine bestimmte Methode kompiliert werden soll, wird diese Methode in eine Warteschlange gestellt. Anstatt auf die Kompilierung zu warten, fährt die JVM dann mit der Interpretation der Methode fort. Beim nächsten Aufruf der Methode führt die JVM die kompilierte Version der Methode aus (vorausgesetzt natürlich, die Kompilierung ist abgeschlossen).

Nehmen wir aber eine lang laufende Schleife. Die JVM merkt, dass die Schleife selbst kompiliert werden sollte und stellt den Code in die Warteschlange für die Kompilierung. Aber das reicht nicht aus: Die JVM muss die Möglichkeit haben, die kompilierte Version der Schleife auszuführen, während die Schleife noch läuft - es wäre ineffizient zu warten, bis die Schleife und die sie einschließende Methode beendet sind (was vielleicht gar nicht passiert). Wenn der Code für die Schleife also fertig kompiliert ist, ersetzt die JVM den Code (auf dem Stack) und die nächste Iteration der Schleife führt die viel schnellere kompilierte Version des Codes aus. Das ist OSR.

Die nächsten beiden Attribute sollten selbsterklärend sein. Das Blocking-Flag wird in den aktuellen Versionen von Java standardmäßig nie ausgegeben; es zeigt an, dass die Kompilierung nicht im Hintergrund stattgefunden hat (siehe "Kompilierungsthreads" für weitere Informationen). Das Attribut native schließlich zeigt an, dass die JVM kompilierten Code erzeugt hat, um den Aufruf einer nativen Methode zu erleichtern.

Wenn die stufenweise Kompilierung deaktiviert wurde, ist das nächste Feld (tiered_level) leer. Andernfalls ist es eine Zahl, die angibt, welche Stufe die Kompilierung abgeschlossen hat.

Als Nächstes kommt der Name der Methode, die kompiliert wird (oder der Methode, die die Schleife enthält, die für OSR kompiliert wird), der als ClassName::method ausgegeben wird.

Als Nächstes folgt die size (in Bytes) des zu kompilierenden Codes. Dabei handelt es sich um die Größe des Java-Bytecodes, nicht um die Größe des kompilierten Codes (daher kann man damit leider nicht vorhersagen, wie groß der Code-Cache sein muss).

In einigen Fällen weist eine Meldung am Ende der Kompilierzeile darauf hin, dass eine Art von Deoptimierung stattgefunden hat; dies sind in der Regel die Ausdrücke made not entrant oder made zombie. Siehe"Deoptimierung" für weitere Informationen.

Das Kompilierungsprotokoll kann auch eine Zeile enthalten, die wie folgt aussieht:

timestamp compile_id COMPILE SKIPPED: reason

Diese Zeile (mit dem literalen Text COMPILE SKIPPED) zeigt an, dass bei der Kompilierung der angegebenen Methode etwas schief gelaufen ist. In zwei Fällen wird dies erwartet, je nachdem, welcher Grund angegeben wurde:

Code-Cache gefüllt

Die Größe des Code-Caches muss mit demReservedCodeCacheFlag erhöht werden.

Gleichzeitiges Laden von Klassen

Die Klasse wurde während des Kompilierens geändert. Die JVM wird sie später erneut kompilieren; du solltest erwarten, dass die Methode später im Protokoll neu kompiliert wird.

In allen Fällen (außer wenn der Cache gefüllt ist) sollte die Kompilierung erneut versucht werden. Wenn dies nicht der Fall ist, verhindert ein Fehler die Kompilierung des Codes. Dies ist oft ein Fehler im Compiler, aber die übliche Abhilfe besteht in allen Fällen darin, den Code in etwas Einfacheres umzuwandeln, das der Compiler verarbeiten kann.

Hier sind ein paar Zeilen der Ausgabe, die durch die Aktivierung von PrintCompilation in der REST-Anwendung entstanden sind:

  28015  850       4     net.sdo.StockPrice::getClosingPrice (5 bytes)
  28179  905  s    3     net.sdo.StockPriceHistoryImpl::process (248 bytes)
  28226   25 %     3     net.sdo.StockPriceHistoryImpl::<init> @ 48 (156 bytes)
  28244  935       3     net.sdo.MockStockPriceEntityManagerFactory$\
                             MockStockPriceEntityManager::find (507 bytes)
  29929  939       3     net.sdo.StockPriceHistoryImpl::<init> (156 bytes)
 106805 1568   !   4     net.sdo.StockServlet::processRequest (197 bytes)

Diese Ausgabe enthält nur ein paar der bestandsbezogenen Methoden (und nicht unbedingt alle Zeilen, die sich auf eine bestimmte Methode beziehen). Ein paar interessante Dinge sind zu beachten: Die erste dieser Methoden wurde erst 28 Sekunden nach dem Start des Servers kompiliert, und 849 Methoden wurden davor kompiliert. In diesem Fall waren alle anderen Methoden Methoden des Servers oder des JDK (aus dieser Ausgabe herausgefiltert). Der Server brauchte etwa 2 Sekunden, um zu starten; die restlichen 26 Sekunden, bevor irgendetwas anderes kompiliert wurde, waren im Wesentlichen Leerlauf, da der Anwendungsserver auf Anfragen wartete.

Die restlichen Zeilen werden eingefügt, um auf interessante Merkmale hinzuweisen. Die Methode process()ist synchronisiert, daher enthalten die Attribute ein s. Innere Klassen werden wie jede andere Klasse kompiliert und erscheinen in der Ausgabe mit der üblichen Java-Nomenklatur: outer-classname$inner-classname. Die Methode processRequest()erscheint wie erwartet mit dem Exception-Handler.

Erinnere dich zum Schluss an die Implementierung des StockPriceHistoryImplKonstruktors, der eine große Schleife enthält:

public StockPriceHistoryImpl(String s, Date startDate, Date endDate) {
    EntityManager em = emf.createEntityManager();
    Date curDate = new Date(startDate.getTime());
    symbol = s;
    while (!curDate.after(endDate)) {
         StockPrice sp = em.find(StockPrice.class, new StockPricePK(s, curDate));
         if (sp != null) {
            if (firstDate == null) {
                firstDate = (Date) curDate.clone();
            }
            prices.put((Date) curDate.clone(), sp);
            lastDate = (Date) curDate.clone();
        }
        curDate.setTime(curDate.getTime() + msPerDay);
    }
}

Die Schleife wird häufiger ausgeführt als der Konstruktor selbst, daher unterliegt die Schleife der OSR-Kompilierung. Beachte, dass es eine Weile gedauert hat, bis diese Methode kompiliert wurde; ihre Kompilierungs-ID ist 25, aber sie erscheint erst, wenn andere Methoden im Bereich 900 kompiliert werden. (Es ist leicht, OSR-Zeilen wie dieses Beispiel als 25 % zu lesen und sich über die anderen 75 % zu wundern, aber erinnere dich daran, dass die Zahl die Kompilierungs-ID ist und das % nur für die OSR-Kompilierung steht.) Das ist typisch für die OSR-Kompilierung; die Stack-Ersetzung ist schwieriger einzurichten, aber andere Kompilierungen können in der Zwischenzeit weitergehen.

Abgestufte Kompilierungsebenen

Das Kompilierungsprotokoll für ein Programm, das die stufenweise Kompilierung verwendet, gibt die Stufe aus, auf der jede Methode kompiliert wird. In der Beispielausgabe wurde der Code entweder auf Stufe 3 oder 4 kompiliert, obwohl wir bisher nur zwei Compiler (plus den Interpreter) besprochen haben. Es stellt sich heraus, dass es fünf Kompilierungsebenen gibt, weil der C1-Compiler drei Ebenen hat. Die Kompilierungsstufen sind also wie folgt:

0

Interpretierter Code

1

Einfacher C1 kompilierter Code

2

Begrenzter C1 kompilierter Code

3

Vollständiger C1 kompilierter Code

4

C2 kompilierter Code

Ein typisches Kompilierungsprotokoll zeigt, dass die meisten Methoden zuerst auf Stufe 3 kompiliert werden: vollständige C1-Kompilierung. (Alle Methoden beginnen natürlich auf Level 0, aber das wird im Protokoll nicht angezeigt.) Wenn eine Methode oft genug ausgeführt wird, wird sie auf Level 4 kompiliert (und der Code von Level 3 wird nicht mehr eingegeben). Dies ist der häufigste Weg: Der C1-Compiler wartet mit dem Kompilieren, bis er Informationen darüber hat, wie der Code verwendet wird, die er für Optimierungen nutzen kann.

Wenn die C2-Compiler-Warteschlange voll ist, werden die Methoden aus der C2-Warteschlange gezogen und auf Level 2 kompiliert. Das ist der Level, auf dem der C1-Compiler die Aufruf- und Rückkantenzähler verwendet (aber kein Profil-Feedback benötigt). Dadurch wird die Methode schneller kompiliert. Später wird die Methode auf Level 3 kompiliert, nachdem der C1-Compiler Profilinformationen gesammelt hat, und schließlich auf Level 4, wenn die C2-Compiler-Warteschlange weniger ausgelastet ist.

Wenn die Warteschlange des C1-Compilers voll ist, kann eine Methode, die für die Kompilierung auf Stufe 3 vorgesehen ist, für die Kompilierung auf Stufe 4 in Frage kommen, während sie noch darauf wartet, auf Stufe 3 kompiliert zu werden. In diesem Fall wird sie schnell auf Level 2 kompiliert und dann auf Level 4 überführt.

Trivialmethoden können entweder in Level 2 oder 3 beginnen, aber dann aufgrund ihrer Trivialität in Level 1 übergehen. Wenn der C2-Compiler den Code aus irgendeinem Grund nicht kompilieren kann, wird er ebenfalls in Level 1 eingestuft. Und wenn der Code deoptimiert wird, geht er natürlich auf Level 0.

Flags steuern einen Teil dieses Verhaltens, aber es ist optimistisch, Ergebnisse zu erwarten, wenn man auf dieser Ebene optimiert. Der beste Fall für die Leistung ist, wenn die Methoden wie erwartet kompiliert werden: Tier 0 → Tier 3 → Tier 4. Wenn die Methoden häufig in Tier 2 kompiliert werden und zusätzliche CPU-Zyklen verfügbar sind, solltest du die Anzahl der Compiler-Threads erhöhen; dadurch wird die Größe der C2-Compiler-Warteschlange verringert. Wenn keine zusätzlichen CPU-Zyklen verfügbar sind, kannst du nur versuchen, die Größe der Anwendung zu verringern.

De-Optimierung

In der Diskussion über die Ausgabe des FlagsPrintCompilationwurden zwei Fälle erwähnt, in denen der Compiler den Code deoptimiert hat. Deoptimierung bedeutet, dass der Compiler eine vorherige Kompilierung "rückgängig" machen muss. Das hat zur Folge, dass die Leistung der Anwendung verringert wird - zumindest bis der Compiler den betreffenden Code neu kompilieren kann.

Deoptimierung tritt in zwei Fällen auf: wenn der Code made not entrant ist und wenn der Code made zombie ist.

Nicht Teilnehmercode

Zwei Dinge führen dazu, dass der Code nicht eingegeben werden kann. Zum einen liegt es an der Art und Weise, wie Klassen und Schnittstellen funktionieren, und zum anderen ist es ein Implementierungsdetail der abgestuften Kompilierung.

Schauen wir uns den ersten Fall an. Wir erinnern uns, dass die Bestandsanwendung über eine Schnittstelle StockPriceHistory verfügt. Im Beispielcode gibt es zwei Implementierungen dieser Schnittstelle: eine einfache (StockPriceHistoryImpl ) und eine, die jede Operation mit einer Protokollierung versieht (Stock​PriceHistoryLogger). Im REST-Code richtet sich die verwendete Implementierung nach dem Parameter log der URL:

StockPriceHistory sph;
String log = request.getParameter("log");
if (log != null && log.equals("true")) {
    sph = new StockPriceHistoryLogger(...);
}
else {
    sph = new StockPriceHistoryImpl(...);
}
// Then the JSP makes calls to:
sph.getHighPrice();
sph.getStdDev();
// and so on

Wenn eine Reihe von Aufrufen an http://localhost:8080/StockServlet(d.h. ohne den Parameter log ) erfolgen, erkennt der Compiler, dass der tatsächliche Typ des sph ObjektsStockPriceHistoryImpl ist. Er wird dann Code einfügen und andere Optimierungen auf der Grundlage dieses Wissens durchführen.

Später, sagen wir, wirdhttp://localhost:8080/StockServlet?log=true aufgerufen. Jetzt ist die Annahme, die der Compiler bezüglich des Typs des sph Objekts gemacht hat, falsch; die vorherigen Optimierungen sind nicht mehr gültig. Dies führt zu einer Deoptimierungsfalle, und die vorherigen Optimierungen werden verworfen. Wenn viele zusätzliche Aufrufe mit aktivierter Protokollierung gemacht werden, wird die JVM diesen Code schnell kompilieren und neue Optimierungen vornehmen.

Das Kompilierungsprotokoll für dieses Szenario wird Zeilen wie die folgende enthalten:

 841113   25 %           net.sdo.StockPriceHistoryImpl::<init> @ -2 (156 bytes)
                                 made not entrant
 841113  937  s          net.sdo.StockPriceHistoryImpl::process (248 bytes)
                                 made not entrant
1322722   25 %           net.sdo.StockPriceHistoryImpl::<init> @ -2 (156 bytes)
                                 made zombie
1322722  937  s          net.sdo.StockPriceHistoryImpl::process (248 bytes)
                                 made zombie

Beachte, dass sowohl der OSR-kompilierte Konstruktor als auch die standardkompilierten Methoden als nicht eintrittspflichtig eingestuft wurden und einige Zeit später zu Zombies gemacht werden.

Deoptimierung klingt nach etwas Schlechtem, zumindest was die Leistung angeht, aber das ist nicht unbedingt der Fall. Tabelle 4-1 zeigt die Operationen pro Sekunde, die der REST-Server unter Deoptimierungsszenarien erreicht.

Tabelle 4-1. Durchsatz des Servers mit De-Optimierung
Szenario OPS

Standard Implementierung

24.4

Standardimplementierung nach Deopt

24.4

Implementierung der Protokollierung

24.1

Gemischte Implikation

24.3

Mit der Standardimplementierung erhalten wir 24,4 OPS. Nehmen wir an, dass unmittelbar nach diesem Test ein Test ausgeführt wird, der den PfadStockPriceHistoryLogger auslöst - das ist das Szenario, das die gerade aufgeführten Deoptimierungsbeispiele hervorgebracht hat. Die vollständige Ausgabe vonPrintCompilationzeigt, dass alle Methoden der KlasseStockPriceHistoryImpldeoptimiert werden, wenn die Anfragen für die Logging-Implementierung gestartet werden. Wenn nach der Deoptimierung der Pfad, der die Implementierung vonStockPriceHistoryImplverwendet, erneut ausgeführt wird, wird der Code neu kompiliert (mit leicht veränderten Annahmen) und wir erhalten immer noch etwa 24,4 OPS (nach einer weiteren Aufwärmphase).

Das ist natürlich der beste Fall. Was passiert, wenn die Aufrufe so vermischt sind, dass der Compiler nie wirklich wissen kann, welchen Weg der Code nehmen wird? Wegen der zusätzlichen Protokollierung erhält der Weg, der die Protokollierung einschließt, etwa 24,1 OPS durch den Server. Wenn die Operationen gemischt werden, erhalten wir etwa 24,3 OPS: genau das, was man von einem Durchschnitt erwarten würde. Abgesehen von einem kurzen Moment, in dem der Trap verarbeitet wird, hat die Deoptimierung also keine nennenswerten Auswirkungen auf die Leistung.

Die zweite Sache, die dazu führen kann, dass Code nicht in die Datenbank aufgenommen wird, ist die Art und Weise, wie die Tiered Compilation funktioniert. Wenn der Code vom C2-Compiler kompiliert wird, muss die JVM den Code ersetzen, der bereits vom C1-Compiler kompiliert wurde. Dazu markiert sie den alten Code als nicht teilnahmeberechtigt und verwendet denselben Deoptimierungsmechanismus, um den neu kompilierten (und effizienteren) Code zu ersetzen. Wenn ein Programm mit Tiered Compilation ausgeführt wird, zeigt das Kompilierungsprotokoll daher eine Reihe von Methoden an, die als nicht eintrittsberechtigt eingestuft wurden. Keine Panik: Diese "Deoptimierung" macht den Code in Wirklichkeit viel schneller.

Das kannst du feststellen, indem du auf die Stufe im Kompilierungsprotokoll achtest:

  40915   84 %     3       net.sdo.StockPriceHistoryImpl::<init> @ 48 (156 bytes)
  40923 3697       3       net.sdo.StockPriceHistoryImpl::<init> (156 bytes)
  41418   87 %     4       net.sdo.StockPriceHistoryImpl::<init> @ 48 (156 bytes)
  41434   84 %     3       net.sdo.StockPriceHistoryImpl::<init> @ -2 (156 bytes)
                                      made not entrant
  41458 3749       4       net.sdo.StockPriceHistoryImpl::<init> (156 bytes)
  41469 3697       3       net.sdo.StockPriceHistoryImpl::<init> (156 bytes)
                                      made not entrant
  42772 3697       3       net.sdo.StockPriceHistoryImpl::<init> (156 bytes)
                                      made zombie
  42861   84 %     3       net.sdo.StockPriceHistoryImpl::<init> @ -2 (156 bytes)
                                      made zombie

Hier wird der Konstruktor zunächst auf Stufe 3 OSR-kompiliert und dann ebenfalls auf Stufe 3 vollständig kompiliert. Eine Sekunde später kommt der OSR-Code für die Kompilierung auf Level 4 in Frage, also wird er auf Level 4 kompiliert und der OSR-Code von Level 3 wird zum Nichtteilnehmer. Der gleiche Prozess findet dann für die Standardkompilierung statt, und schließlich wird der Level-3-Code zum Zombie.

Zombie-Code deoptimieren

Wenn das Kompilierungsprotokoll meldet, dass es Zombie-Code gemacht hat, bedeutet das, dass es früheren Code, der nicht eingegeben wurde, wiederhergestellt hat. Im vorangegangenen Beispiel wurde nach einem Test mit der ImplementierungStockPriceHistoryLoggerder Code für die KlasseStockPriceHistoryImplnicht eingegeben. Die Objekte der KlasseStockPriceHistoryImplblieben jedoch erhalten. Schließlich wurden alle diese Objekte von GC zurückgefordert. Als dies geschah, bemerkte der Compiler, dass die Methoden dieser Klasse nun als Zombie-Code markiert werden konnten.

Für die Leistung ist das eine gute Sache. Der kompilierte Code wird in einem Code-Cache mit fester Größe gespeichert. Wenn Zombie-Methoden identifiziert werden, kann der betreffende Code aus dem Code-Cache entfernt werden, um Platz für andere zu kompilierende Klassen zu schaffen (oder um die Menge an Speicher zu begrenzen, die die JVM später zuweisen muss).

Der mögliche Nachteil ist, dass die JVM den Code neu kompilieren und optimieren muss, wenn der Code für die Klasse zum Zombie wird und später erneut geladen und stark genutzt wird. Doch genau das ist in dem vorherigen Szenario passiert, in dem der Test erst ohne, dann mit und dann ohne Protokollierung ausgeführt wurde; die Leistung wurde in diesem Fall nicht merklich beeinträchtigt. Im Allgemeinen haben die kleinen Neukompilierungen, die bei der Neukompilierung von Zombie-Code auftreten, keine messbaren Auswirkungen auf die meisten Anwendungen.

Kurze Zusammenfassung

  • Der beste Weg, um zu sehen, wie der Code kompiliert wird, ist die Aktivierung vonPrintCompilation.

  • Die Ausgabe von PrintCompilation kann verwendet werden, um sicherzustellen, dass die Kompilierung wie erwartet abläuft.

  • Die abgestufte Kompilierung kann auf fünf verschiedenen Ebenen zwischen den beiden Compilern arbeiten.

  • Deoptimierung ist der Prozess, durch den die JVM zuvor kompilierten Code ersetzt. Dies geschieht normalerweise im Zusammenhang mit C2-Code, der C1-Code ersetzt, kann aber auch aufgrund von Änderungen im Ausführungsprofil einer Anwendung geschehen.

Erweiterte Compiler-Flags

In diesem Abschnitt geht es um einige andere Flags, die den Compiler beeinflussen. Hauptsächlich geht es darum, dass du besser verstehst, wie der Compiler funktioniert; diese Flags sollten im Allgemeinen nicht verwendet werden. Andererseits sind sie hier auch deshalb aufgeführt, weil sie früher weit verbreitet waren. Wenn du also auf sie gestoßen bist und dich fragst, was sie bewirken, sollte dieser Abschnitt deine Fragen beantworten.

Schwellenwerte für die Kompilierung

In diesem Kapitel wurde nur vage definiert, was die Kompilierung des Codes auslöst. Der wichtigste Faktor ist, wie oft der Code ausgeführt wird. Wenn er eine bestimmte Anzahl von Malen ausgeführt wird, ist die Kompilierungsschwelle erreicht und der Compiler ist der Meinung, dass er genug Informationen hat, um den Code zu kompilieren.

Dieser Abschnitt soll dir einen besseren Einblick in die Funktionsweise des Compilers geben (und einige Begriffe einführen); in aktuellen JVMs macht das Tunen der Schwellenwerte nie wirklich Sinn.

Die Kompilierung basiert auf zwei Zählern in der JVM: der Anzahl der Methodenaufrufe, , und der Anzahl der Rückverzweigungen in den Schleifen der Methode. Die Rückverzweigung kann man sich als die Anzahl der Schleifen vorstellen, die ihre Ausführung beendet haben, entweder weil sie das Ende der Schleife erreicht haben oder weil sie eine Verzweigungsanweisung wie continue ausgeführt haben.

Wenn die JVM eine Java-Methode ausführt, prüft sie die Summe dieser beiden Zähler und entscheidet, ob die Methode für die Kompilierung geeignet ist. Wenn ja, wird die Methode in eine Warteschlange für die Kompilierung gestellt (siehe "Kompilierungsthreads" für weitere Informationen zur Warteschlangenbildung). Diese Art der Kompilierung hat keinen offiziellen Namen, wird aber oft als Standardkompilierung bezeichnet .

Ebenso wird jedes Mal, wenn eine Schleife ausgeführt wird, der Verzweigungszähler erhöht und überprüft. Wenn der Verzweigungszähler seinen individuellen Schwellenwert überschritten hat, wird die Schleife (und nicht die gesamte Methode) für die Kompilierung freigegeben.

Tunings wirken sich auf diese Schwellenwerte aus.Wenn die Tiered Compilation deaktiviert ist, wird die Standardkompilierung durch den Wert des-XX:CompileThreshold=N Flagge. Der Standardwert von N ist 10.000. Wenn du den Wert des Flags CompileThreshold änderst, wird der Compiler den Code früher (oder später) kompilieren, als er es normalerweise getan hätte. Beachte jedoch, dass es hier zwar ein Flag gibt, der Schwellenwert aber aus der Summe des Zählers für die Schleife am hinteren Rand und des Zählers für die Methodeneingabe berechnet wird.

Oft findet man Empfehlungen, dasCompileThresholdFlag zu ändern, und mehrere Veröffentlichungen von Java-Benchmarks verwenden dieses Flag (z. B. häufig nach 8.000 Iterationen). Einige Anwendungen werden immer noch mit diesem Flag ausgeliefert, das standardmäßig gesetzt ist.

Aber erinnere dich daran, dass ich gesagt habe, dass dieses Flag funktioniert, wenn die Tiered Compilation deaktiviert ist - das heißt, wenn die Tiered Compilation aktiviert ist (wie es normalerweise der Fall ist), bewirkt dieses Flag gar nichts. Die Verwendung dieses Flags ist eigentlich nur ein Überbleibsel aus JDK 7 und früheren Zeiten.

Dieses Flag wurde früher aus zwei Gründen empfohlen: Erstens würde das Absenken des Flags die Startzeit einer Anwendung, die den C2-Compiler verwendet, verbessern, da der Code schneller kompiliert wird (und normalerweise mit der gleichen Effektivität). Zweitens könnte es dazu führen, dass Methoden kompiliert werden, die sonst nie kompiliert worden wären.

Der letzte Punkt ist eine interessante Besonderheit: Wenn ein Programm ewig läuft, sollte man dann nicht erwarten, dass der gesamte Code irgendwann kompiliert wird? So funktioniert das nicht, denn die Zähler, die die Compiler verwenden, steigen mit der Ausführung von Methoden und Schleifen an, nehmen aber auch mit der Zeit ab. In regelmäßigen Abständen (insbesondere, wenn die JVM einen Sicherheitspunkt erreicht) wird der Wert jedes Zählers reduziert.

In der Praxis bedeutet das, dass die Zähler ein relatives Maß für die Aktualität der Methode oder Schleife sind. Ein Nebeneffekt ist, dass etwas häufig ausgeführter Code vom C2-Compiler möglicherweise nie kompiliert wird, selbst bei Programmen, die ewig laufen.Diese Methoden werden manchmal als lauwarm (im Gegensatz zu heiß) bezeichnet. Vor der abgestuften Kompilierung war dies ein Fall, in dem die Senkung der Kompilierungsschwelle von Vorteil war .

Heute werden jedoch auch die lauwarmen Methoden kompiliert, obwohl sie vielleicht noch ein bisschen besser werden könnten, wenn wir sie mit dem C2-Compiler statt mit dem C1-Compiler kompilieren könnten. Es gibt kaum praktische Vorteile, aber wenn du wirklich daran interessiert bist, kannst du die Flags-XX:Tier3InvocationThreshold=N (Standardwert 200), um C1 dazu zu bringen, eine Methode schneller zu kompilieren, und-XX:Tier4InvocationThreshold=N (Standardwert 5000), um C2 dazu zu bringen, eine Methode schneller zu kompilieren. Ähnliche Flags gibt es auch für den Schwellenwert für die hinteren Kanten.

Kurze Zusammenfassung

  • Die Schwellenwerte, bei denen Methoden (oder Schleifen) kompiliert werden, werden über abstimmbare Parameter festgelegt.

  • Ohne die stufenweise Zusammenstellung war es manchmal sinnvoll, diese Schwellenwerte anzupassen, aber mit der stufenweisen Zusammenstellung wird diese Anpassung nicht mehr empfohlen.

Kompilations-Themen

In "Schwellenwerte für die Kompilierung" wurde erwähnt, dass eine Methode (oder Schleife), die für die Kompilierung in Frage kommt, in eine Warteschlange gestellt wird. Diese Warteschlange wird von einem oder mehreren Hintergrundthreads abgearbeitet.

Diese Warteschlangen sind nicht strikt first in, first out; Methoden, deren Aufrufzähler höher ist, haben Priorität. Auch wenn ein Programm mit der Ausführung beginnt und viel Code zu kompilieren ist, hilft diese Prioritätsreihenfolge sicherzustellen, dass der wichtigste Code zuerst kompiliert wird. (Das ist ein weiterer Grund dafür, dass die Kompilierungs-ID in der Ausgabe von PrintCompilation nicht immer in der richtigen Reihenfolge erscheint).

Die Compiler C1 und C2 haben unterschiedliche Warteschlangen, die jeweils von (möglicherweise mehreren) verschiedenen Threads bearbeitet werden. Die Anzahl der Threads basiert auf einer komplexen Formel mit Logarithmen, aberin Tabelle 4-2 sind die Details aufgeführt.

Tabelle 4-2. Standardanzahl von C1- und C2-Compiler-Threads für die Tiered Compilation
CPUs C1 Gewinde C2 Gewinde

1

1

1

2

1

1

4

1

2

8

1

2

16

2

6

32

3

7

64

4

8

128

4

10

Die Anzahl der Compiler-Threads kann durch Setzen des-XX:CICompilerCount=N Flag setzen. Das ist die Gesamtzahl der Threads, die die JVM für die Verarbeitung der Warteschlange(n) verwendet; bei der Tiered Compilation wird ein Drittel (aber mindestens einer) für die Verarbeitung der C1-Compiler-Warteschlange verwendet, und die restlichen Threads (aber auch mindestens einer) werden für die Verarbeitung der C2-Compiler-Warteschlange verwendet. Der Standardwert für dieses Flag ist die Summe der beiden Spalten in der vorstehenden Tabelle.

Wenn die stufenweise Kompilierung deaktiviert ist, wird nur die angegebene Anzahl von C2-Compiler-Threads gestartet.

Wann solltest du diesen Wert anpassen? Da der Standardwert auf der Anzahl der CPUs basiert, ist dies ein Fall, in dem der Betrieb mit einer älteren Version von JDK 8 innerhalb eines Docker-Containers dazu führen kann, dass die automatische Abstimmung schief läuft. In diesem Fall musst du dieses Flag manuell auf den gewünschten Wert setzen (wobei du die Zielwerte in Tabelle 4-2 als Richtlinie für die Anzahl der dem Docker-Container zugewiesenen CPUs verwenden kannst).

Ähnlich verhält es sich, wenn ein Programm auf einer virtuellen Maschine mit nur einer CPU ausgeführt wird: Wenn nur ein Compiler-Thread zur Verfügung steht und weniger Threads um diese Ressource konkurrieren, kann dies in vielen Fällen die Leistung verbessern. Dieser Vorteil ist jedoch nur auf die anfängliche Aufwärmphase beschränkt; danach wird die Anzahl der in Frage kommenden Methoden, die kompiliert werden müssen, nicht mehr wirklich zu einem Wettbewerb um die CPU führen. Wenn die Stock Batching-Anwendung auf einer Single-CPU-Maschine ausgeführt wurde und die Anzahl der Compiler-Threads auf einen beschränkt war, waren die ersten Berechnungen etwa 10 % schneller (da sie nicht so oft um die CPU konkurrieren mussten). Je mehr Iterationen durchgeführt wurden, desto geringer wurde der Gesamteffekt dieses anfänglichen Vorteils, bis alle heißen Methoden kompiliert waren und der Vorteil wegfiel.

Andererseits kann die Anzahl der Threads das System leicht überfordern, vor allem, wenn mehrere JVMs gleichzeitig ausgeführt werden (von denen jede viele Kompilierungs-Threads startet). Eine Verringerung der Anzahl der Threads kann in diesem Fall den Gesamtdurchsatz erhöhen (allerdings auf Kosten einer möglicherweise längeren Aufwärmphase).

Wenn viele zusätzliche CPU-Zyklen zur Verfügung stehen, profitiert das Programm theoretisch davon - zumindest während der Aufwärmphase -, wenn die Anzahl der Compiler-Threads erhöht wird. In der Praxis ist dieser Vorteil nur sehr schwer zu erreichen. Außerdem ist es viel besser, wenn du etwas versuchst, das die verfügbaren CPU-Zyklen während der gesamten Ausführung der Anwendung nutzt (anstatt nur zu Beginn schneller zu kompilieren), wenn die überschüssige CPU zur Verfügung steht.

Eine weitere Einstellung, die sich auf die Kompilierungs-Threads auswirkt, ist der Wert des Flags-XX:+BackgroundCompilation, der standardmäßig auf true steht. Diese Einstellung bedeutet, dass die Warteschlange wie gerade beschrieben asynchron verarbeitet wird. Das Flag kann aber auch auf false gesetzt werden. Wenn eine Methode für die Kompilierung in Frage kommt, wartet der Code, der sie ausführen will, bis sie tatsächlich kompiliert ist (und wird nicht im Interpreter weiter ausgeführt). Die Kompilierung im Hintergrund ist auch deaktiviert, wenn-Xbatch angegeben ist.

Kurze Zusammenfassung

  • Die Kompilierung erfolgt asynchron für Methoden, die in die Kompilierungswarteschlange gestellt werden.

  • Die Warteschlange ist nicht streng geordnet; heiße Methoden werden vor anderen Methoden in der Warteschlange kompiliert. Das ist ein weiterer Grund dafür, dass die Kompilierungs-IDs im Kompilierungsprotokoll in ungeordneter Reihenfolge erscheinen können.

Inlining

Eine der wichtigsten Optimierungen, die der Compiler vornimmt, ist das Inlinen von Methoden. Code, der einem guten objektorientierten Design folgt, enthält oft Attribute, auf die über Getter (und vielleicht Setter) zugegriffen wird:

public class Point {
    private int x, y;

    public void getX() { return x; }
    public void setX(int i)  { x = i; }
}

Der Overhead für einen solchen Methodenaufruf ist ziemlich hoch, vor allem im Verhältnis zur Menge des Codes in der Methode. In den Anfängen von Java sprachen sich Leistungstipps oft gegen diese Art der Kapselung aus, eben wegen der Auswirkungen all dieser Methodenaufrufe auf die Leistung. Zum Glück führen die JVMs heute routinemäßig Code-Inlining für diese Art von Methoden durch. Daher kannst du diesen Code schreiben:

Point p = getPoint();
p.setX(p.getX() * 2);

Der kompilierte Code wird dies im Wesentlichen ausführen:

Point p = getPoint();
p.x = p.x * 2;

Das Inlining ist standardmäßig aktiviert. Es kann mit dem-XX:-InlineFlag deaktiviert werden, obwohl es so wichtig für die Leistung ist, dass du das nie tun würdest (zum Beispiel reduziert die Deaktivierung des Inlinings die Leistung des Stock-Batching-Tests um über 50%). Weil Inlining aber so wichtig ist und weil wir vielleicht noch an vielen anderen Stellschrauben drehen müssen, werden oft Empfehlungen zur Einstellung des Inlining-Verhaltens der JVM ausgesprochen.

Leider gibt es keinen grundlegenden Einblick in die Art und Weise, wie die JVM Code einfügt. Wenn du die JVM aus dem Quellcode kompilierst, kannst du eine Debug-Version erzeugen, die das Flag-XX:+PrintInliningDieses Flag liefert alle möglichen Informationen über die Inlining-Entscheidungen, die der Compiler trifft). Das Beste, was du tun kannst, ist, dir die Profile des Codes anzusehen und mit den Inlining-Flags zu experimentieren, wenn einfache Methoden am oberen Ende der Profile so aussehen, als ob sie inlined werden sollten.

Die grundsätzliche Entscheidung, ob eine Methode inline ist, hängt davon ab, wie heiß sie ist und wie groß sie ist. Die JVM bestimmt anhand einer internen Berechnung, ob eine Methode "heiß" ist (d.h. häufig aufgerufen wird); sie unterliegt keinen direkt einstellbaren Parametern. Wenn eine Methode für das Inlining in Frage kommt, weil sie häufig aufgerufen wird, wird sie nur dann inlined, wenn ihre Bytecodegrößeweniger als 325 Byte beträgt (oder was auch immer als-XX:MaxFreqInlineSize=N Flag). Andernfalls kommt sie nur für das Inlining in Frage, wenn sie kleiner als 35 Byte ist (oder was auch immer als Flag angegeben ist).-XX:MaxInlineSize=N Flag angegeben ist).

Manchmal wird empfohlen, den Wert des Flags zu erhöhen, damit mehr Methoden inlined werden.MaxInlineSize Ein oft übersehener Aspekt dieses Zusammenhangs ist, dass das Setzen desMaxInlineSize Werts auf mehr als 35 bedeutet, dass eine Methode möglicherweise schon beim ersten Aufruf inlined wird. Wenn die Methode jedoch häufig aufgerufen wird - in diesem Fall ist ihre Leistung viel wichtiger -, wurde sie irgendwann inlined (vorausgesetzt, ihre Größe ist kleiner als 325 Byte). Andernfalls besteht der Nettoeffekt der Einstellung desMaxInlineSize Flag zwar die Aufwärmzeit für einen Test verkürzen, aber es ist unwahrscheinlich, dass dies einen großen Einfluss auf eine lang laufende Anwendung hat.

Kurze Zusammenfassung

  • Inlining ist die vorteilhafteste Optimierung, die der Compiler vornehmen kann, insbesondere bei objektorientiertem Code, bei dem die Attribute gut gekapselt sind.

  • Eine Anpassung der Inlining-Flags ist nur selten nötig, und Empfehlungen dazu schlagen oft fehl, um das Verhältnis zwischen normalem Inlining und häufigem Inlining zu berücksichtigen. Achte darauf, dass du beide Fälle berücksichtigst, wenn du die Auswirkungen des Inlinings untersuchst.

Flucht-Analyse

Der C2-Compiler führt aggressive Optimierungen durch, wenn die Escape-Analyse aktiviert ist(-XX:+DoEscapeAnalysis, was standardmäßig true ist). Betrachte zum Beispiel diese Klasse, um mit Faktorzahlen zu arbeiten:

public class Factorial {
    private BigInteger factorial;
    private int n;
    public Factorial(int n) {
        this.n = n;
    }
    public synchronized BigInteger getFactorial() {
        if (factorial == null)
            factorial = ...;
        return factorial;
    }
}

Um die ersten 100 faktoriellen Werte in einem Array zu speichern, würde dieser Code verwendet werden:

ArrayList<BigInteger> list = new ArrayList<BigInteger>();
for (int i = 0; i < 100; i++) {
    Factorial factorial = new Factorial(i);
    list.add(factorial.getFactorial());
}

Das Objektfactorialwird nur innerhalb dieser Schleife referenziert; kein anderer Code kann jemals auf dieses Objekt zugreifen. Daher steht es der JVM frei, Optimierungen an diesem Objekt vorzunehmen:

  • Sie muss keine Synchronisationssperre erhalten, wenn sie die MethodegetFactorial()aufruft.

  • Das Feld n muss nicht im Speicher gespeichert werden; es kann diesen Wert in einem Register aufbewahren. Genauso kann er die Objektreferenzfactorialin einem Register speichern.

  • Tatsächlich muss es gar kein eigentliches Factorial-Objekt zuweisen; es kann einfach die einzelnen Felder des Objekts im Auge behalten.

Diese Art der Optimierung ist anspruchsvoll: Sie ist in diesem Beispiel einfach genug, aber diese Optimierungen sind auch bei komplexerem Code möglich. Je nachdem, wie der Code verwendet wird, sind nicht unbedingt alle Optimierungen anwendbar. Die Escape-Analyse kann jedoch feststellen, welche dieser Optimierungen möglich sind, und die notwendigen Änderungen im kompilierten Code vornehmen.

Die Escape-Analyse ist standardmäßig aktiviert. In seltenen Fällen kann es vorkommen, dass sie sich irrt. Das ist in der Regel unwahrscheinlich und in aktuellen JVMs tatsächlich selten. Da es früher einige bekannte Fehler gab, wird manchmal empfohlen, die Escape-Analyse zu deaktivieren. Diese Empfehlungen sind wahrscheinlich nicht mehr angebracht, aber wie bei allen aggressiven Compiler-Optimierungen ist es nicht ausgeschlossen, dass die Deaktivierung dieser Funktion zu stabilerem Code führt. Wenn du das feststellst, ist es am besten, den betreffenden Code zu vereinfachen: Einfacherer Code lässt sich besser kompilieren. (Es handelt sich jedoch um einen Fehler, der gemeldet werden sollte.)

Kurze Zusammenfassung

  • Die Escape-Analyse ist die anspruchsvollste der Optimierungen, die der Compiler durchführen kann. Diese Art der Optimierung führt häufig dazu, dass Mikrobenchmarks schief laufen.

CPU-spezifischer Code

Ich habe bereits erwähnt, dass ein Vorteil des JIT-Compilers darin besteht, dass er Code für verschiedene Prozessoren ausgeben kann, je nachdem, wo er gerade läuft. Das setzt natürlich voraus, dass die JVM mit dem Wissen über den neueren Prozessor gebaut wird.

Das ist genau das, was der Compiler für Intel-Chips macht. 2011 führte Intel Advanced Vector Extensions (AVX2) für die Sandy Bridge (und spätere) Chips ein. Die JVM-Unterstützung für diese Befehle folgte bald. Im Jahr 2016 erweiterte Intel die Unterstützung um AVX-512-Befehle, die in Knights Landing und späteren Chips enthalten sind. Diese Befehle werden im JDK 8 nicht unterstützt, aber im JDK 11 schon.

Normalerweise brauchst du dir um diese Funktion keine Sorgen zu machen; die JVM erkennt die CPU, auf der sie läuft, und wählt den passenden Befehlssatz aus. Aber wie bei allen neuen Funktionen geht manchmal etwas schief.

Die Unterstützung für AVX-512-Befehle wurde erstmals im JDK 9 eingeführt, allerdings war sie nicht standardmäßig aktiviert. Nach einigen Fehlstarts wurde sie standardmäßig aktiviert und dann wieder deaktiviert. In JDK 11 wurden diese Befehle standardmäßig aktiviert. Ab JDK 11.0.6 sind diese Befehle jedoch wieder standardmäßig deaktiviert. Auch in JDK 11 ist dies also noch nicht ganz abgeschlossen. (Das ist übrigens kein Einzelfall in Java; viele Programme haben damit zu kämpfen, die AVX-512-Befehle richtig zu unterstützen).

So kann es sein, dass du auf neuerer Intel-Hardware beim Ausführen einiger Programme feststellst, dass ein früherer Befehlssatz viel besser funktioniert. Die Anwendungen, die von dem neuen Befehlssatz profitieren, beinhalten in der Regel mehr wissenschaftliche Berechnungen als Java-Programme es oft tun.

Diese Befehlssätze werden mit dem-XX:UseAVX=N Argument ausgewählt, wobei N wie folgt lautet :

0

Verwende keine AVX-Anweisungen.

1

Verwende Intel AVX Level 1 Anweisungen (für Sandy Bridge und spätere Prozessoren).

2

Verwende Intel AVX Level 2 Anweisungen (für Haswell und spätere Prozessoren).

3

Verwende Intel AVX-512-Befehle (für Knights Landing und spätere Prozessoren).

Der Standardwert für dieses Flag hängt von dem Prozessor ab, auf dem die JVM läuft; die JVM erkennt die CPU und wählt den höchsten unterstützten Wert aus. Java 8 unterstützt keine Stufe 3, daher ist 2 der Wert, der auf den meisten Prozessoren verwendet wird. In Java 11 auf neueren Intel-Prozessoren wird in den Versionen bis 11.0.5 standardmäßig der Wert 3 und in späteren Versionen der Wert 2 verwendet.

Dies ist einer der Gründe, warum ich in Kapitel 1 erwähnt habe, dass es eine gute Idee ist, die neuesten Versionen von Java 8 oder Java 11 zu verwenden, da wichtige Fehlerbehebungen wie diese in diesen neuesten Versionen enthalten sind. Wenn du eine frühere Version von Java 11 auf den neuesten Intel-Prozessoren verwenden musst, solltest du das-XX:UseAVX=2 Flag zu setzen, was dir in vielen Fällen einen Leistungsschub bringt.

Apropos Code-Reife: Der Vollständigkeit halber möchte ich erwähnen, dass das -XX:UseSSE=N Flag die Intel Streaming SIMD Extensions (SSE) eins bis vier unterstützt. Diese Erweiterungen sind für die Pentium-Prozessoren gedacht. Das Tuning dieses Flags im Jahr 2010 war sinnvoll, da damals alle Möglichkeiten seiner Verwendung noch nicht ausgereift waren. Heute können wir uns im Allgemeinen auf die Robustheit dieses Flags verlassen.

Mehrstufige Zusammenstellung Kompromisse

Ich habe schon ein paar Mal erwähnt, dass die JVM anders arbeitet, wenn die Tiered Compilation deaktiviert ist. Gibt es angesichts der Leistungsvorteile, die sie bietet, überhaupt einen Grund, sie zu deaktivieren?

Ein Grund dafür könnte sein, dass du in einer Umgebung mit begrenztem Speicherplatz arbeitest. Sicher, dein 64-Bit-Rechner hat wahrscheinlich jede Menge Speicher, aber vielleicht läufst du in einem Docker-Container mit einem kleinen Speicherlimit oder in einer virtuellen Maschine in der Cloud, die einfach nicht genug Speicher hat. Oder du lässt Dutzende von JVMs auf deinem großen Rechner laufen. In diesen Fällen möchtest du vielleicht den Speicherbedarf deiner Anwendung reduzieren.

In Kapitel 8 findest du allgemeine Empfehlungen dazu, aber in diesem Abschnitt werden wir uns die Auswirkungen der Tiered Compilation auf den Code-Cache ansehen.

Tabelle 4-3 zeigt das Ergebnis des Starts von NetBeans auf meinem System, das ein paar Dutzend Projekte hat, die beim Start geöffnet werden.

Tabelle 4-3. Auswirkungen der Tiered Compilation auf den Code-Cache
Compiler-Modus Zusammengestellte Klassen Committed Code Cache Anfahrzeit

+TieredCompilation

22,733

46,5 MB

50,1 Sekunden

-TieredCompilation

5,609

10,7 MB

68,5 Sekunden

Der C1-Compiler kompilierte etwa viermal so viele Klassen und benötigte vorhersehbar etwa viermal so viel Speicher für den Code-Cache. In absoluten Zahlen macht die Einsparung von 34 MB in diesem Beispiel wahrscheinlich keinen großen Unterschied. 300 MB in einem Programm zu sparen, das 200.000 Klassen kompiliert, könnte auf manchen Plattformen eine andere Entscheidung sein.

Was verlieren wir, wenn wir die Tiered Compilation deaktivieren? Wie die Tabelle zeigt, benötigen wir mehr Zeit, um die Anwendung zu starten und alle Projektklassen zu laden. Aber was ist mit einem lang laufenden Programm, bei dem man erwarten würde, dass alle Hotspots kompiliert werden?

In diesem Fall sollte die Ausführung bei einer ausreichend langen Aufwärmphase ungefähr gleich sein, wenn die Tiered Compilation deaktiviert ist.Tabelle 4-4 zeigt die Leistung unseres Standard-REST-Servers nach Aufwärmphasen von 0, 60 und 300 Sekunden.

Tabelle 4-4. Durchsatz von Serveranwendungen mit Tiered Compilation
Aufwärmphase -XX:-TieredCompilation -XX:+TieredCompilation

0 Sekunden

23.72

24.23

60 Sekunden

23.73

24.26

300 Sekunden

24.42

24.43

Der Messzeitraum beträgt 60 Sekunden, d.h. auch ohne Aufwärmphase hatten die Compiler die Möglichkeit, genügend Informationen zu erhalten, um die Hot Spots zu kompilieren. (Außerdem wurde eine Menge Code während des Starts des Servers kompiliert.) Beachte, dass die Tiered Compilation am Ende immer noch in der Lage ist, einen kleinen Vorteil herauszuholen (wenn auch einen, der wahrscheinlich nicht spürbar ist). Den Grund dafür haben wir bereits bei der Diskussion über die Kompilierungsschwellen erörtert: Es wird immer eine kleine Anzahl von Methoden geben, die vom C1-Compiler kompiliert werden, wenn die abgestufte Kompilierung verwendet wird, und die vom C2-Compiler nicht kompiliert werden können.

Die GraalVM

Die GraalVM ist eine neue virtuelle Maschine. Sie bietet die Möglichkeit, Java-Code auszuführen, aber auch Code aus vielen anderen Sprachen. Diese universelle virtuelle Maschine kann auch JavaScript, Python, Ruby, R und traditionelle JVM-Bytecodes von Java und anderen Sprachen ausführen, die zu JVM-Bytecodes kompiliert werden können (z. B. Scala, Kotlin usw.). Graal gibt es in zwei Editionen: eine vollständige Open Source Community Edition (CE) und eine kommerzielle Enterprise Edition (EE). Beide Editionen enthalten Binärdateien, die entweder Java 8 oder Java 11 unterstützen.

Die GraalVM hat zwei wichtige Beiträge zur JVM-Leistung. Erstens ermöglicht eine Zusatztechnologie der GraalVM, vollständig native Binärdateien zu erzeugen; darauf gehen wir im nächsten Abschnitt ein.

Zweitens kann die GraalVM in einem Modus als reguläre JVM laufen, aber sie enthält eine neue Implementierung des C2-Compilers. Dieser Compiler ist in Java geschrieben (im Gegensatz zum traditionellen C2-Compiler, der in C++ geschrieben ist).

Die traditionelle JVM enthält eine Version des GraalVM JIT, je nachdem, wann die JVM gebaut wurde. Diese JIT-Versionen stammen aus der CE-Version von GraalVM, die langsamer ist als die EE-Version; außerdem sind sie in der Regel veraltet im Vergleich zu den Versionen von GraalVM, die du direkt herunterladen kannst.

Innerhalb der JVM wird die Verwendung des GraalVM-Compilers als experimentell angesehen, . Um ihn zu aktivieren, musst du diese Flags angeben:-XX:+UnlockExperimentalVMOptions,-XX:+EnableJVMCI, und-XX:+UseJVMCICompilerDie Standardeinstellung für alle diese Flags ist false.

Tabelle 4-5 zeigt die Leistung des Standard-Java-11-Compilers, des Graal-Compilers aus EE Version 19.2.1 und der in Java 11 und 13 eingebetteten GraalVM.

Tabelle 4-5. Leistung des Graal-Compilers
JVM/Compiler OPS

JDK 11/Standard C2

20.558

JDK 11/Graal JIT

14.733

Graal 1.0.0b16

16.3

Graal 19.2.1

26.7

JDK 13/Standard C2

21.9

JDK 13/Graal JIT

26.4

Das ist noch einmal die Leistung unseres REST-Servers (allerdings auf einer etwas anderen Hardware als zuvor, sodass der Basis-OPS nur 20,5 statt 24,4 beträgt).

Es ist interessant, hier die Entwicklung zu beobachten: Das JDK 11 wurde mit einer sehr frühen Version des Graal-Compilers erstellt, sodass die Leistung dieses Compilers hinter der des C2-Compilers zurückbleibt. Der Graal-Compiler wurde durch seine Early-Access-Builds verbessert, obwohl selbst die letzte Early-Access-Version (1.0) nicht so schnell war wie die Standard-VM. Die Graal-Versionen von Ende 2019 (veröffentlicht als Produktionsversion 19.2.1) wurden jedoch deutlich schneller. Die Early-Access-Version von JDK 13 hat einen dieser späteren Builds und erreicht mit dem Graal-Compiler fast die gleiche Leistung, auch wenn der C2-Compiler seit JDK 11 nur geringfügig verbessert wurde.

Vorkompilierung

Zu Beginn dieses Kapitels haben wir die Philosophie hinter einem Just-in-Time-Compiler besprochen. Obwohl er seine Vorteile hat, muss der Code immer noch eine Aufwärmphase durchlaufen, bevor er ausgeführt wird. Was wäre, wenn in unserer Umgebung ein traditionelles Kompiliermodell besser funktionieren würde: ein eingebettetes System ohne den zusätzlichen Speicher, den der JIT benötigt, oder ein Programm, das fertiggestellt wird, bevor es eine Chance zum Aufwärmen hat?

In diesem Abschnitt werden wir uns zwei experimentelle Funktionen ansehen, die dieses Szenario angehen. Die Ahead-of-Time-Kompilierung ist eine experimentelle Funktion des Standard-JDK 11, und die Möglichkeit, eine vollständig native Binärdatei zu erzeugen, ist eine Funktion der Graal VM.

Ahead-of-Time-Zusammenstellung

DieAOT-Kompilierung (Ahead-of-Time) war zuerst im JDK 9 nur für Linux verfügbar, aber im JDK 11 ist sie auf allen Plattformen verfügbar. Was die Leistung angeht, ist sie noch nicht ausgereift, aber dieser Abschnitt gibt dir einen kleinen Einblick.1

Mit der AOT-Kompilierung kannst du deine Anwendung teilweise (oder ganz) kompilieren, bevor sie ausgeführt wird. Dieser kompilierte Code wird zu einer gemeinsam genutzten Bibliothek, die die JVM beim Starten der Anwendung verwendet. Theoretisch bedeutet das, dass der JIT nicht involviert sein muss, zumindest beim Start deiner Anwendung: Dein Code sollte anfangs mindestens genauso gut laufen wie der C1-kompilierte Code, ohne dass du darauf warten musst, dass dieser Code kompiliert wird.

In der Praxis sieht das ein wenig anders aus: Die Startzeit der Anwendung wird stark von der Größe der Shared Library beeinflusst (und damit von der Zeit, die benötigt wird, um diese Shared Library in die JVM zu laden). Das bedeutet, dass eine einfache Anwendung wie eine "Hello, world"-Anwendung nicht schneller läuft, wenn du die AOT-Kompilierung verwendest (sie kann sogar langsamer laufen, je nachdem, wie du die Shared Library vorkompiliert hast). Die AOT-Kompilierung ist für einen REST-Server gedacht, der eine relativ lange Startzeit hat. Auf diese Weise wird die Zeit zum Laden der Shared Library durch die lange Startzeit kompensiert, und AOT bringt einen Vorteil. Denke aber auch daran, dass die AOT-Kompilierung eine experimentelle Funktion ist, von der kleinere Programme profitieren können, wenn sich die Technologie weiterentwickelt.

Um die AOT-Kompilierung zu nutzen, verwendest du das Tool jaotc, um eine Shared Library zu erstellen, die die von dir ausgewählten kompilierten Klassen enthält. Dann wird diese Shared Library über ein Laufzeitargument in die JVM geladen.

Das Tool jaotc hat mehrere Optionen, aber die beste Bibliothek erstellst du wie folgt:

$ jaotc --compile-commands=/tmp/methods.txt \
    --output JavaBaseFilteredMethods.so \
    --compile-for-tiered \
    --module java.base

Dieser Befehl verwendet eine Reihe von Kompilierbefehlen, um eine kompilierte Version des java.base-Moduls in der angegebenen Ausgabedatei zu erzeugen. Du hast die Möglichkeit, mit AOT ein Modul zu kompilieren, wie wir es hier getan haben, oder eine Gruppe von Klassen.

Die Zeit zum Laden der Shared Library hängt von ihrer Größe ab, die ein Faktor der Anzahl der Methoden in der Bibliothek ist. Du kannst auch mehrere Shared Libraries laden, die verschiedene Teile des Codes vorkompilieren. Das ist zwar einfacher zu handhaben, hat aber die gleiche Leistung, deshalb konzentrieren wir uns auf eine einzige Bibliothek.

Auch wenn du versucht sein könntest, alles vorzukompilieren, wirst du eine bessere Leistung erzielen, wenn du nur Teilbereiche des Codes vorkompilierst. Deshalb lautet die Empfehlung, nur das Modul java.basezu kompilieren.

Die Kompilierbefehle (in diesem Beispiel in der Datei /tmp/methods.txt ) dienen auch dazu, die Daten einzuschränken, die in die gemeinsame Bibliothek kompiliert werden. Diese Datei enthält Zeilen, die wie folgt aussehen:

compileOnly java.net.URI.getHost()Ljava/lang/String;

Diese Zeile teilt jaotc mit, dass es beim Kompilieren der Klasse java.net.URI nur die Methode getHost() einbeziehen soll. Wir können weitere Zeilen einfügen, die auf andere Methoden der Klasse verweisen, um auch deren Kompilierung einzubeziehen. Am Ende werden nur die in der Datei aufgeführten Methoden in die gemeinsame Bibliothek aufgenommen.

Um die Liste der Kompilierbefehle zu erstellen, brauchen wir eine Liste mit allen Methoden, die die Anwendung tatsächlich verwendet. Dazu führen wir die Anwendung wie folgt aus:

$ java -XX:+UnlockDiagnosticVMOptions -XX:+LogTouchedMethods \
      -XX:+PrintTouchedMethodsAtExit <other arguments>

Wenn das Programm beendet wird, gibt es die Zeilen jeder Methode, die das Programm verwendet hat, in einem Format wie diesem aus:

java/net/URI.getHost:()Ljava/lang/String;

Um die Datei methods.txt zu erstellen, speicherst du diese Zeilen, stellst jeder Zeile die Direktive compileOnly voran und entfernst den Doppelpunkt unmittelbar vor den Methodenargumenten.

Die Klassen, die von jaotc vorkompiliert werden, verwenden eine Form des C1-Compilers, so dass sie in einem langlaufenden Programm nicht optimal kompiliert werden. Die letzte Option, die wir brauchen, ist also --compile-for-tiered. Mit dieser Option wird die gemeinsam genutzte Bibliothek so eingerichtet, dass ihre Methoden noch für die Kompilierung durch den C2-Compiler geeignet sind.

Wenn du die AOT-Kompilierung für ein kurzlebiges Programm verwendest, ist es in Ordnung, dieses Argument wegzulassen, aber erinnere dich daran, dass die Zielmenge der Anwendungen ein Server ist. Wenn wir nicht zulassen, dass die vorkompilierten Methoden für die C2-Kompilierung in Frage kommen, wird die warme Leistung des Servers langsamer sein, als es letztlich möglich ist.

Wenn du deine Anwendung mit einer Bibliothek ausführst, die Tiered Compilation aktiviert hat und das-XX:+PrintCompilation Flag verwendest, siehst du dieselbe Technik zum Ersetzen von Code, die wir zuvor beobachtet haben: Die AOT-Kompilierung erscheint als eine weitere Ebene in der Ausgabe und du siehst, dass die AOT-Methoden bei der JIT-Kompilierung ersetzt werden und nicht eintreten.

Sobald die Bibliothek erstellt ist, kannst du sie wie folgt in deiner Anwendung verwenden:

$ java -XX:AOTLibrary=/path/to/JavaBaseFilteredMethods.so <other args>

Wenn du sichergehen willst, dass die Bibliothek verwendet wird, füge das Flag-XX:+PrintAOTin deine JVM-Argumente ein; dieses Flag ist standardmäßig false. Wie das-XX:+PrintCompilationFlag gibt auch das-XX:+PrintAOTFlag eine Ausgabe aus, wenn eine vorkompilierte Methode von der JVM verwendet wird. Eine typische Zeile sieht so aus:

    373  105     aot[ 1]   java.util.HashSet.<init>(I)V

Die erste Spalte gibt die Millisekunden seit Programmstart an. Es dauerte also 373 Millisekunden, bis der Konstruktor der Klasse HashSet aus der Shared Library geladen wurde und mit der Ausführung begann. Die zweite Spalte ist eine ID, die der Methode zugewiesen wurde, und die dritte Spalte sagt uns, aus welcher Bibliothek die Methode geladen wurde. Der Index (in diesem Beispiel 1) wird ebenfalls über dieses Flag ausgegeben:

18    1     loaded    /path/to/JavaBaseFilteredMethods.so  aot library

JavaBaseFilteredMethods.so ist die erste (und einzige) Bibliothek, die in diesem Beispiel geladen wird. Daher ist ihr Index 1 (die zweite Spalte) und die nachfolgenden Verweise auf aot mit diesem Index beziehen sich auf diese Bibliothek.

GraalVM Native Kompilierung

Die AOT-Kompilierung war für relativ große Programme von Vorteil, aber für kleine, schnell laufende Programme war sie nicht hilfreich (und konnte sie sogar behindern). Das liegt daran, dass es sich noch um eine experimentelle Funktion handelt und dass die Architektur der JVM die Shared Library laden muss.

Die GraalVM hingegen kann vollständige native ausführbare Dateien erzeugen, die ohne die JVM laufen. Diese ausführbaren Dateien sind ideal für kurzlebige Programme. Wenn du die Beispiele ausgeführt hast, hast du vielleicht in einigen Dingen (wie ignorierte Fehler) Verweise auf GraalVM-Klassen bemerkt: Die AOT-Kompilierung nutzt GraalVM als Grundlage. Dies ist eine Early Adopter-Funktion der GraalVM; sie kann mit der entsprechenden Lizenz in der Produktion eingesetzt werden, unterliegt aber nicht der Garantie.

Die GraalVM erzeugt Binärdateien, die recht schnell starten, vor allem wenn man sie mit den laufenden Programmen in der JVM vergleicht. In diesem Modus optimiert die GraalVM den Code jedoch nicht so aggressiv wie der C2-Compiler, sodass bei einer ausreichend lang laufenden Anwendung die traditionelle JVM am Ende die Nase vorn hat. Im Gegensatz zur AOT-Kompilierung kompiliert die native GraalVM-Binärdatei keine Klassen mit C2 während der Ausführung.

Auch der Speicherbedarf eines nativen Programms, das von der GraalVM erzeugt wird, ist anfangs deutlich kleiner als der einer herkömmlichen JVM. Wenn ein Programm jedoch läuft und den Heap vergrößert, schwindet dieser Speichervorteil.

Es gibt auch Einschränkungen, welche Java-Funktionen in einem Programm verwendet werden können, das in nativen Code kompiliert wurde. Zu diesen Einschränkungen gehören die folgenden:

  • Dynamisches Laden von Klassen (z. B. durch den Aufruf von Class.forName()).

  • Finalisten.

  • Der Java Security Manager.

  • JMX und JVMTI (einschließlich JVMTI-Profiling).

  • Die Verwendung von Reflexion erfordert oft eine spezielle Codierung oder Konfiguration.

  • Die Verwendung von dynamischen Proxys erfordert oft eine spezielle Konfiguration.

  • Die Verwendung von JNI erfordert eine spezielle Kodierung oder Konfiguration.

Wir können all dies in Aktion sehen, indem wir ein Demoprogramm aus dem GraalVM-Projekt verwenden, das rekursiv die Dateien in einem Verzeichnis zählt. Bei wenigen zu zählenden Dateien ist das von der GraalVM erzeugte native Programm recht klein und schnell, aber wenn mehr Arbeit anfällt und der JIT einsetzt, erzeugt der traditionelle JVM-Compiler bessere Code-Optimierungen und ist schneller, wie wir in Tabelle 4-6 sehen.

Tabelle 4-6. Zeit zum Zählen von Dateien mit nativem und JIT-kompiliertem Code
Anzahl der Dateien Java 11.0.5 Native Anwendung

7

217 ms (36K)

4 ms (3K)

271

279 ms (37K)

20 ms (6K)

169,000

2,3 s (171K)

2.1 s (249K)

1,3 Millionen

19,2 s (212K)

25,4 s (269K)

Die hier angegebenen Zeiten sind die Zeiten für das Zählen der Dateien; der gesamte Footprint des Laufs (gemessen am Ende) ist in Klammern angegeben.

Natürlich entwickelt sich die GraalVM selbst schnell weiter, und es ist zu erwarten, dass sich auch die Optimierungen innerhalb des nativen Codes mit der Zeit verbessern werden.

Zusammenfassung

Dieses Kapitel enthält eine Menge Hintergrundinformationen über die Funktionsweise des Compilers. So kannst du einige der allgemeinen Empfehlungen aus Kapitel 1 zu kleinen Methoden und einfachem Code sowie die Auswirkungen des Compilers auf Mikrobenchmarks, die in Kapitel 2 beschrieben wurden, verstehen. Im Besonderen:

  • Hab keine Angst vor kleinen Methoden - und insbesondere vor Gettern und Settern -, denn sie lassen sich leicht inlinen. Wenn du das Gefühl hast, dass der Methoden-Overhead teuer sein kann, hast du in der Theorie recht (wir haben gezeigt, dass das Entfernen des Inlinings die Leistung deutlich verschlechtert). In der Praxis ist das aber nicht der Fall, da der Compiler dieses Problem behebt.

  • Code, der kompiliert werden muss, befindet sich in einer Kompilierungswarteschlange. Je mehr Code sich in der Warteschlange befindet, desto länger braucht das Programm, um eine optimale Leistung zu erzielen.

  • Auch wenn du den Code-Cache vergrößern kannst (und solltest), ist er doch eine endliche Ressource.

  • Je einfacher der Code ist, desto mehr Optimierungen können an ihm vorgenommen werden. Profilfeedback und Escape-Analyse können zu viel schnellerem Code führen, aber komplexe Schleifenstrukturen und große Methoden schränken ihre Wirksamkeit ein.

Wenn du ein Profil deines Codes erstellst und einige überraschende Methoden am Anfang deines Profils findest - Methoden, von denen du erwartest, dass sie dort nicht vorkommen sollten -, kannst du die Informationen hier nutzen, um herauszufinden, was der Compiler tut und um sicherzustellen, dass er mit der Art und Weise, wie dein Code geschrieben ist, umgehen kann.

1 Ein Vorteil der AOC-Kompilierung ist der schnellere Start, aber die gemeinsame Nutzung von Anwendungsklassendaten bietet - zumindest im Moment - einen größeren Vorteil in Bezug auf die Startleistung und ist eine vollständig unterstützte Funktion; siehe "Gemeinsame Nutzung von Klassendaten" für weitere Informationen.

Get Java Performance, 2. Auflage now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.