JVM-Leistungsoptimierung, Teil 2: Compiler

Java-Compiler stehen in diesem zweiten Artikel der JVM-Reihe zur Leistungsoptimierung im Mittelpunkt. Eva Andreasson stellt die verschiedenen Arten von Compilern vor und vergleicht die Leistungsergebnisse von Client-, Server- und Tiered-Kompilierung. Sie schließt mit einem Überblick über gängige JVM-Optimierungen wie Dead-Code-Eliminierung, Inlining und Loop-Optimierung.

Ein Java-Compiler ist die Quelle der berühmten Plattformunabhängigkeit von Java. Ein Softwareentwickler schreibt die beste Java-Anwendung, die er oder sie kann, und dann arbeitet der Compiler hinter den Kulissen, um effizienten und leistungsfähigen Ausführungscode für die beabsichtigte Zielplattform zu erstellen. Verschiedene Arten von Compilern erfüllen unterschiedliche Anwendungsanforderungen und liefern so spezifische gewünschte Leistungsergebnisse. Je mehr Sie über Compiler wissen, wie sie funktionieren und welche Arten verfügbar sind, desto besser können Sie die Leistung von Java-Anwendungen optimieren.

In diesem zweiten Artikel der JVM- Reihe zur Leistungsoptimierung werden die Unterschiede zwischen verschiedenen Compilern für Java Virtual Machine hervorgehoben und erläutert. Ich werde auch einige allgemeine Optimierungen diskutieren, die von Just-In-Time-Compilern (JIT) für Java verwendet werden. (Eine Übersicht über die JVM und eine Einführung in die Serie finden Sie unter "JVM-Leistungsoptimierung, Teil 1".)

Was ist ein Compiler?

Ein Compiler spricht einfach eine Programmiersprache als Eingabe und erzeugt eine ausführbare Sprache als Ausgabe. Ein allgemein bekannter Compiler ist der javac, der in allen Standard-Java-Entwicklungskits (JDKs) enthalten ist. javacNimmt Java-Code als Eingabe und übersetzt ihn in Bytecode - die ausführbare Sprache für eine JVM. Der Bytecode wird in .class-Dateien gespeichert, die beim Starten des Java-Prozesses in die Java-Laufzeit geladen werden.

Bytecode kann von Standard-CPUs nicht gelesen werden und muss in eine Befehlssprache übersetzt werden, die die zugrunde liegende Ausführungsplattform verstehen kann. Die Komponente in der JVM, die für die Übersetzung von Bytecode in ausführbare Plattformanweisungen verantwortlich ist, ist ein weiterer Compiler. Einige JVM-Compiler verarbeiten mehrere Übersetzungsebenen. Beispielsweise kann ein Compiler verschiedene Ebenen der Zwischendarstellung des Bytecodes erstellen, bevor er in tatsächliche Maschinenanweisungen umgewandelt wird, dem letzten Schritt der Übersetzung.

Bytecode und die JVM

Weitere Informationen zu Bytecode und JVM finden Sie unter "Grundlagen des Bytecodes" (Bill Venners, JavaWorld).

Aus plattformunabhängiger Sicht möchten wir den Code so weit wie möglich plattformunabhängig halten, damit die letzte Übersetzungsebene - von der niedrigsten Darstellung bis zum tatsächlichen Maschinencode - der Schritt ist, der die Ausführung an die Prozessorarchitektur einer bestimmten Plattform bindet . Die höchste Trennstufe besteht zwischen statischen und dynamischen Compilern. Von dort aus haben wir Optionen, abhängig davon, auf welche Ausführungsumgebung wir abzielen, welche Leistungsergebnisse wir wünschen und welche Ressourcenbeschränkungen wir erfüllen müssen. Ich habe in Teil 1 dieser Serie kurz auf statische und dynamische Compiler eingegangen. In den folgenden Abschnitten werde ich etwas mehr erklären.

Statische vs dynamische Kompilierung

Ein Beispiel für einen statischen Compiler ist der zuvor erwähnte javac. Bei statischen Compilern wird der Eingabecode einmal interpretiert und die ausführbare Ausgabedatei hat die Form, die bei der Ausführung des Programms verwendet wird. Wenn Sie keine Änderungen an Ihrer ursprünglichen Quelle vornehmen und den Code (mit dem Compiler) neu kompilieren, führt die Ausgabe immer zum gleichen Ergebnis. Dies liegt daran, dass die Eingabe eine statische Eingabe und der Compiler ein statischer Compiler ist.

In einer statischen Kompilierung wird der folgende Java-Code verwendet

static int add7( int x ) { return x+7; }

würde zu etwas ähnlichem wie diesem Bytecode führen:

iload0 bipush 7 iadd ireturn

Ein dynamischer Compiler übersetzt dynamisch von einer Sprache in eine andere, was bedeutet, dass der Code ausgeführt wird - zur Laufzeit! Die dynamische Kompilierung und Optimierung bietet Laufzeiten den Vorteil, dass sie sich an Änderungen der Anwendungslast anpassen können. Dynamische Compiler eignen sich sehr gut für Java-Laufzeiten, die normalerweise in unvorhersehbaren und sich ständig ändernden Umgebungen ausgeführt werden. Die meisten JVMs verwenden einen dynamischen Compiler wie einen Just-In-Time-Compiler (JIT). Der Haken ist, dass dynamische Compiler und Codeoptimierung manchmal zusätzliche Datenstrukturen, Thread- und CPU-Ressourcen benötigen. Je weiter die Optimierung oder die Analyse des Bytecode-Kontexts fortgeschritten ist, desto mehr Ressourcen werden für die Kompilierung verbraucht. In den meisten Umgebungen ist der Overhead im Vergleich zum signifikanten Leistungsgewinn des Ausgabecodes immer noch sehr gering.

JVM-Sorten und Unabhängigkeit der Java-Plattform

Alle JVM-Implementierungen haben eines gemeinsam: Sie versuchen, den Anwendungsbytecode in Maschinenanweisungen zu übersetzen. Einige JVMs interpretieren den Anwendungscode beim Laden und verwenden Leistungsindikatoren, um sich auf "heißen" Code zu konzentrieren. Einige JVMs überspringen die Interpretation und verlassen sich nur auf die Kompilierung. Die Ressourcenintensität der Kompilierung kann (insbesondere für clientseitige Anwendungen) ein größerer Erfolg sein, ermöglicht jedoch auch erweiterte Optimierungen. Weitere Informationen finden Sie unter Ressourcen.

Wenn Sie ein Anfänger in Java sind, werden die Feinheiten von JVMs eine Menge sein, um Ihren Kopf herumzuwickeln. Die gute Nachricht ist, dass Sie das nicht wirklich brauchen! Die JVM verwaltet die Code-Kompilierung und -Optimierung, sodass Sie sich nicht um Maschinenanweisungen und die optimale Art des Schreibens von Anwendungscode für eine zugrunde liegende Plattformarchitektur kümmern müssen.

Vom Java-Bytecode bis zur Ausführung

Sobald Sie Ihren Java-Code in Bytecode kompiliert haben, müssen Sie als Nächstes die Bytecode-Anweisungen in Maschinencode übersetzen. Dies kann entweder von einem Interpreter oder einem Compiler durchgeführt werden.

Interpretation

Die einfachste Form der Bytecode-Kompilierung heißt Interpretation. Ein Interpreter schlägt einfach die Hardwareanweisungen für jede Bytecode-Anweisung nach und sendet sie zur Ausführung durch die CPU ab.

Sie können sich eine Interpretation vorstellen, die der Verwendung eines Wörterbuchs ähnelt: Für ein bestimmtes Wort (Bytecode-Anweisung) gibt es eine genaue Übersetzung (Maschinencode-Anweisung). Da der Interpreter jeweils einen Bytecode-Befehl liest und sofort ausführt, besteht keine Möglichkeit, einen Befehlssatz zu optimieren. Ein Interpreter muss die Interpretation auch jedes Mal durchführen, wenn ein Bytecode aufgerufen wird, was ihn ziemlich langsam macht. Die Interpretation ist eine genaue Methode zum Ausführen von Code, aber der nicht optimierte Ausgabeanweisungssatz ist wahrscheinlich nicht die leistungsstärkste Sequenz für den Prozessor der Zielplattform.

Zusammenstellung

Ein Compiler hingegen lädt den gesamten auszuführenden Code in die Laufzeit. Bei der Übersetzung von Bytecode kann der gesamte oder teilweise Laufzeitkontext angezeigt und Entscheidungen darüber getroffen werden, wie der Code tatsächlich übersetzt werden soll. Seine Entscheidungen basieren auf der Analyse von Codediagrammen wie verschiedenen Ausführungszweigen von Anweisungen und Laufzeitkontextdaten.

Wenn eine Bytecode-Sequenz in einen Maschinencode-Befehlssatz übersetzt wird und Optimierungen an diesem Befehlssatz vorgenommen werden können, wird der ersetzende Befehlssatz (z. B. die optimierte Sequenz) in einer Struktur gespeichert, die als Code-Cache bezeichnet wird . Bei der nächsten Ausführung dieses Bytecodes kann der zuvor optimierte Code sofort im Code-Cache gefunden und zur Ausführung verwendet werden. In einigen Fällen kann ein Leistungsindikator die vorherige Optimierung aktivieren und überschreiben. In diesem Fall führt der Compiler eine neue Optimierungssequenz aus. Der Vorteil eines Code-Cache besteht darin, dass der resultierende Befehlssatz sofort ausgeführt werden kann - keine interpretativen Suchvorgänge oder Kompilierungen erforderlich! Dies beschleunigt die Ausführungszeit, insbesondere für Java-Anwendungen, bei denen dieselben Methoden mehrmals aufgerufen werden.

Optimierung

Zusammen mit der dynamischen Kompilierung bietet sich die Möglichkeit, Leistungsindikatoren einzufügen. Der Compiler kann beispielsweise einen Leistungsindikator einfügenjedes Mal zu zählen, wenn ein Bytecode-Block (z. B. entsprechend einer bestimmten Methode) aufgerufen wurde. Compiler verwenden Daten darüber, wie "heiß" ein bestimmter Bytecode ist, um zu bestimmen, wo sich die Codeoptimierungen am besten auf die ausgeführte Anwendung auswirken. Mithilfe von Laufzeitprofildaten kann der Compiler im laufenden Betrieb eine Vielzahl von Entscheidungen zur Codeoptimierung treffen, wodurch die Leistung bei der Codeausführung weiter verbessert wird. Sobald verfeinerte Code-Profiling-Daten verfügbar sind, können zusätzliche und bessere Optimierungsentscheidungen getroffen werden, z. B.: Wie Anweisungen in der kompilierten Sprache besser sequenziert werden können, ob ein Befehlssatz durch effizientere Sätze ersetzt werden soll oder sogar ob redundante Operationen beseitigt werden sollen.

Beispiel

Betrachten Sie den Java-Code:

static int add7( int x ) { return x+7; }

Dies könnte statisch durch javacden Bytecode kompiliert werden :

iload0 bipush 7 iadd ireturn

Wenn die Methode aufgerufen wird, wird der Bytecode-Block dynamisch zu Maschinenanweisungen kompiliert. Wenn ein Leistungsindikator (falls für den Codeblock vorhanden) einen Schwellenwert erreicht, wird er möglicherweise auch optimiert. Das Endergebnis könnte wie der folgende Maschinenbefehlssatz für eine bestimmte Ausführungsplattform aussehen:

lea rax,[rdx+7] ret

Unterschiedliche Compiler für unterschiedliche Anwendungen

Unterschiedliche Java-Anwendungen haben unterschiedliche Anforderungen. Langfristige serverseitige Unternehmensanwendungen können weitere Optimierungen ermöglichen, während kleinere clientseitige Anwendungen möglicherweise eine schnelle Ausführung bei minimalem Ressourcenverbrauch erfordern. Betrachten wir drei verschiedene Compilereinstellungen und ihre jeweiligen Vor- und Nachteile.

Clientseitige Compiler

Ein bekannter Optimierungs-Compiler ist C1, der Compiler, der über die -clientJVM-Startoption aktiviert wird . Wie der Startname andeutet, ist C1 ein clientseitiger Compiler. Es wurde für clientseitige Anwendungen entwickelt, für die weniger Ressourcen verfügbar sind und die in vielen Fällen empfindlich auf die Startzeit der Anwendung reagieren. C1 verwendet Leistungsindikatoren für die Codeprofilerstellung, um einfache, relativ unaufdringliche Optimierungen zu ermöglichen.

Serverseitige Compiler

Für Anwendungen mit langer Laufzeit, wie z. B. serverseitige Java-Unternehmensanwendungen, reicht ein clientseitiger Compiler möglicherweise nicht aus. Stattdessen könnte ein serverseitiger Compiler wie C2 verwendet werden. C2 wird normalerweise aktiviert, indem Sie die Startoption JVM -serverzu Ihrer Startbefehlszeile hinzufügen . Da die meisten serverseitigen Programme voraussichtlich lange ausgeführt werden, bedeutet die Aktivierung von C2, dass Sie mehr Profildaten erfassen können als mit einer kurz laufenden, leichtgewichtigen Clientanwendung. So können Sie fortschrittlichere Optimierungstechniken und -algorithmen anwenden.

Tipp: Erwärmen Sie Ihren serverseitigen Compiler

Bei serverseitigen Bereitstellungen kann es einige Zeit dauern, bis der Compiler die anfänglichen "heißen" Teile des Codes optimiert hat. Daher erfordern serverseitige Bereitstellungen häufig eine "Aufwärmphase". Stellen Sie vor jeder Leistungsmessung bei einer serverseitigen Bereitstellung sicher, dass Ihre Anwendung den stabilen Zustand erreicht hat! Wenn Sie dem Compiler genügend Zeit lassen, um ordnungsgemäß zu kompilieren, wird dies zu Ihrem Vorteil funktionieren! (Weitere Informationen zum Aufwärmen Ihres Compilers und zu den Mechanismen der Profilerstellung finden Sie im JavaWorld-Artikel "Beobachten Sie Ihren HotSpot-Compiler".)

Ein Server-Compiler berücksichtigt mehr Profildaten als ein clientseitiger Compiler und ermöglicht eine komplexere Zweiganalyse, sodass berücksichtigt wird, welcher Optimierungspfad vorteilhafter wäre. Wenn mehr Profildaten verfügbar sind, erzielen Sie bessere Anwendungsergebnisse. Für eine umfassendere Profilerstellung und Analyse müssen natürlich mehr Ressourcen für den Compiler aufgewendet werden. Eine JVM mit aktiviertem C2 verwendet mehr Threads und mehr CPU-Zyklen, erfordert einen größeren Code-Cache usw.

Abgestufte Zusammenstellung

Abgestufte Zusammenstellungkombiniert clientseitige und serverseitige Kompilierung. Azul stellte erstmals eine gestufte Zusammenstellung in seiner Zing JVM zur Verfügung. In jüngerer Zeit (ab Java SE 7) wurde es von Oracle Java Hotspot JVM übernommen. Die abgestufte Kompilierung nutzt die Vorteile des Client- und Server-Compilers in Ihrer JVM. Der Client-Compiler ist während des Anwendungsstarts am aktivsten und verarbeitet Optimierungen, die durch niedrigere Schwellenwerte für den Leistungsindikator ausgelöst werden. Der clientseitige Compiler fügt auch Leistungsindikatoren ein und bereitet Befehlssätze für erweiterte Optimierungen vor, die zu einem späteren Zeitpunkt vom serverseitigen Compiler behandelt werden. Die gestufte Kompilierung ist eine sehr ressourceneffiziente Methode zur Profilerstellung, da der Compiler während der Compileraktivität mit geringen Auswirkungen Daten erfassen kann, die später für erweiterte Optimierungen verwendet werden können.Dieser Ansatz liefert auch mehr Informationen, als Sie allein durch die Verwendung interpretierter Codeprofilzähler erhalten.

Das Diagrammschema in Abbildung 1 zeigt die Leistungsunterschiede zwischen reiner Interpretation, clientseitiger, serverseitiger und gestufter Kompilierung. Die X-Achse zeigt die Ausführungszeit (Zeiteinheit) und die Leistung der Y-Achse (Operationen / Zeiteinheit).

Abbildung 1. Leistungsunterschiede zwischen Compilern (zum Vergrößern anklicken)

Im Vergleich zu rein interpretiertem Code führt die Verwendung eines clientseitigen Compilers zu einer etwa 5- bis 10-mal besseren Ausführungsleistung (in ops / s), wodurch die Anwendungsleistung verbessert wird. Die Variation der Verstärkung hängt natürlich davon ab, wie effizient der Compiler ist, welche Optimierungen aktiviert oder implementiert sind und (in geringerem Maße) wie gut die Anwendung in Bezug auf die Zielausführungsplattform gestaltet ist. Letzteres ist wirklich etwas, worüber sich ein Java-Entwickler niemals Sorgen machen sollte.

Im Vergleich zu einem clientseitigen Compiler erhöht ein serverseitiger Compiler normalerweise die Codeleistung um messbare 30 bis 50 Prozent. In den meisten Fällen gleicht diese Leistungsverbesserung die zusätzlichen Ressourcenkosten aus.