JVM-Leistungsoptimierung, Teil 1: Eine Einführung in die JVM-Technologie

Java-Anwendungen laufen auf der JVM, aber was wissen Sie über die JVM-Technologie? Dieser Artikel, der erste einer Reihe, bietet einen Überblick über die Funktionsweise einer klassischen virtuellen Java-Maschine, z. B. Vor- und Nachteile der Java-Engine zum einmaligen Schreiben, Ausführen an jedem Ort, Grundlagen zur Speicherbereinigung sowie eine Auswahl gängiger GC-Algorithmen und Compiler-Optimierungen . Spätere Artikel befassen sich mit der Optimierung der JVM-Leistung, einschließlich neuerer JVM-Designs, um die Leistung und Skalierbarkeit der heutigen hochkonkurrierenden Java-Anwendungen zu unterstützen.

Wenn Sie ein Programmierer sind, haben Sie zweifellos dieses besondere Gefühl erlebt, wenn ein Licht in Ihrem Denkprozess aufleuchtet, wenn diese Neuronen endlich eine Verbindung herstellen und Sie Ihr vorheriges Gedankenmuster für eine neue Perspektive öffnen. Ich persönlich liebe das Gefühl, etwas Neues zu lernen. Ich habe diese Momente viele Male in meiner Arbeit mit JVM-Technologien (Java Virtual Machine) erlebt, insbesondere im Zusammenhang mit der Speicherbereinigung und der Optimierung der JVM-Leistung. In dieser neuen JavaWorld-Serie hoffe ich, etwas von dieser Beleuchtung mit Ihnen zu teilen. Hoffentlich werden Sie genauso begeistert sein, etwas über die JVM-Leistung zu erfahren, wie ich darüber schreiben werde!

Diese Serie richtet sich an alle Java-Entwickler, die mehr über die zugrunde liegenden Ebenen der JVM und die tatsächlichen Funktionen einer JVM erfahren möchten. Auf hoher Ebene werde ich über die Speicherbereinigung und das unendliche Bestreben sprechen, Speicher sicher und schnell freizugeben, ohne die laufenden Anwendungen zu beeinträchtigen. Sie lernen die wichtigsten Komponenten einer JVM kennen: Garbage Collection- und GC-Algorithmen, Compiler-Varianten und einige gängige Optimierungen. Ich werde auch diskutieren, warum Java-Benchmarking so schwierig ist, und Tipps geben, die bei der Messung der Leistung zu berücksichtigen sind. Abschließend möchte ich auf einige der neueren Innovationen in der JVM- und GC-Technologie eingehen, darunter Highlights aus Azul's Zing JVM, IBM JVM und Oracle Garbage First (G1) Garbage Collector.

Ich hoffe, Sie verlassen diese Serie mit einem besseren Verständnis der Faktoren, die die Java-Skalierbarkeit heute einschränken, und wie diese Einschränkungen uns dazu zwingen, unsere Java-Bereitstellungen nicht optimal zu gestalten. Hoffentlich wirst du etwas Aha erleben ! Momente und lassen Sie sich inspirieren, etwas Gutes für Java zu tun: Akzeptieren Sie die Einschränkungen nicht mehr und arbeiten Sie für Veränderungen! Wenn Sie noch kein Open Source-Mitwirkender sind, wird Sie diese Serie möglicherweise in diese Richtung ermutigen.

JVM-Leistungsoptimierung: Lesen Sie die Serie

  • Teil 1: Übersicht
  • Teil 2: Compiler
  • Teil 3: Müllabfuhr
  • Teil 4: Gleichzeitiges Verdichten von GC
  • Teil 5: Skalierbarkeit

JVM-Leistung und die Herausforderung „Eins für alle“

Ich habe Neuigkeiten für Leute, die mit der Idee feststecken, dass die Java-Plattform von Natur aus langsam ist. Der Glaube, dass die JVM für die schlechte Java-Leistung verantwortlich ist, ist Jahrzehnte alt - sie begann, als Java zum ersten Mal für Unternehmensanwendungen verwendet wurde, und ist veraltet! Es istWenn Sie die Ergebnisse der Ausführung einfacher statischer und deterministischer Aufgaben auf verschiedenen Entwicklungsplattformen vergleichen, werden Sie höchstwahrscheinlich eine bessere Ausführung mit maschinenoptimiertem Code sehen als mit einer virtualisierten Umgebung, einschließlich einer JVM. Die Java-Leistung hat in den letzten 10 Jahren jedoch große Fortschritte gemacht. Die Marktnachfrage und das Wachstum in der Java-Branche haben zu einer Handvoll Speicherbereinigungsalgorithmen und neuen Kompilierungsinnovationen geführt. Mit fortschreitender JVM-Technologie sind zahlreiche Heuristiken und Optimierungen entstanden. Ich werde einige davon später in dieser Serie vorstellen.

Die Schönheit der JVM-Technologie ist auch ihre größte Herausforderung: Mit einer Anwendung "Einmal schreiben, überall ausführen" kann nichts angenommen werden. Anstatt für einen Anwendungsfall, eine Anwendung und eine bestimmte Benutzerlast zu optimieren, verfolgt die JVM ständig, was in einer Java-Anwendung vor sich geht, und optimiert entsprechend dynamisch. Diese dynamische Laufzeit führt zu einem dynamischen Problemsatz. Entwickler, die an der JVM arbeiten, können sich beim Entwerfen von Innovationen nicht auf statische Kompilierung und vorhersehbare Zuordnungsraten verlassen, zumindest nicht, wenn wir Leistung in Produktionsumgebungen wünschen!

Eine Karriere in der JVM-Leistung

Zu Beginn meiner Karriere wurde mir klar, dass die Speicherbereinigung schwer zu "lösen" ist, und seitdem bin ich fasziniert von JVMs und Middleware-Technologie. Meine Leidenschaft für JVMs begann, als ich im JRockit-Team arbeitete und einen neuartigen Ansatz für einen selbstlernenden, selbstoptimierenden Garbage Collection-Algorithmus programmierte (siehe Ressourcen). Dieses Projekt, das sich in ein experimentelles Feature von JRockit verwandelte und den Grundstein für den deterministischen Garbage Collection-Algorithmus legte, begann meine Reise durch die JVM-Technologie. Ich habe für BEA Systems gearbeitet, mit Intel und Sun zusammengearbeitet und war nach der Übernahme von BEA Systems für kurze Zeit bei Oracle beschäftigt. Später trat ich dem Team von Azul Systems bei, um die Zing JVM zu verwalten, und heute arbeite ich für Cloudera.

Maschinenoptimierter Code bietet möglicherweise eine bessere Leistung, geht jedoch zu Lasten der Inflexibilität, was für Unternehmensanwendungen mit dynamischen Lasten und schnellen Funktionsänderungen kein praktikabler Kompromiss ist. Die meisten Unternehmen sind bereit, die nahezu perfekte Leistung von maschinenoptimiertem Code für die Vorteile von Java zu opfern:

  • Einfache Codierung und Funktionsentwicklung (dh schnellere Markteinführung)
  • Zugang zu sachkundigen Programmierern
  • Schnelle Entwicklung mit Java-APIs und Standardbibliotheken
  • Portabilität - Sie müssen keine Java-Anwendung für jede neue Plattform neu schreiben

Vom Java-Code zum Bytecode

Als Java-Programmierer sind Sie wahrscheinlich mit dem Codieren, Kompilieren und Ausführen von Java-Anwendungen vertraut. Nehmen wir zum Beispiel an, Sie haben ein Programm MyApp.javaund möchten es ausführen. Um dieses Programm auszuführen, müssen Sie es zuerst mit javacdem im JDK integrierten statischen Java-Compiler für Sprache und Bytecode kompilieren. javacGeneriert basierend auf dem Java-Code den entsprechenden ausführbaren Bytecode und speichert ihn in einer gleichnamigen Klassendatei : MyApp.class. Nachdem Sie den Java-Code in Bytecode kompiliert haben, können Sie Ihre Anwendung ausführen, indem Sie die ausführbare Klassendatei mit dem javaBefehl über Ihre Befehlszeile oder Ihr Startskript mit oder ohne Startoptionen starten. Die Klasse wird in die Laufzeit geladen (dh die laufende virtuelle Java-Maschine) und Ihr Programm wird ausgeführt.

Das passiert auf der Oberfläche eines alltäglichen Anwendungsausführungsszenarios, aber jetzt wollen wir untersuchen, was wirklich passiert, wenn Sie diesen javaBefehl aufrufen . Wie heißt dieses Ding eine virtuelle Java-Maschine ? Die meisten Entwickler haben durch den kontinuierlichen Optimierungsprozess mit einer JVM interagiert - auch bekannt als Auswahl und Zuweisung von Startoptionen, um die Ausführung Ihres Java-Programms zu beschleunigen und gleichzeitig den berüchtigten JVM-Fehler "Nicht genügend Speicher" zu vermeiden. Aber haben Sie sich jemals gefragt, warum wir überhaupt eine JVM benötigen, um Java-Anwendungen auszuführen?

Was ist eine Java Virtual Machine?

Einfach ausgedrückt ist eine JVM das Softwaremodul, das den Java-Anwendungsbytecode ausführt und den Bytecode in hardware- und betriebssystemspezifische Anweisungen übersetzt. Auf diese Weise ermöglicht die JVM die Ausführung von Java-Programmen in verschiedenen Umgebungen, in denen sie zuerst geschrieben wurden, ohne dass Änderungen am ursprünglichen Anwendungscode erforderlich sind. Die Portabilität von Java ist der Schlüssel zu seiner Beliebtheit als Unternehmensanwendungssprache: Entwickler müssen den Anwendungscode nicht für jede Plattform neu schreiben, da die JVM die Übersetzung und Plattformoptimierung übernimmt.

Eine JVM ist im Grunde eine virtuelle Ausführungsumgebung, die als Maschine für Bytecode-Anweisungen fungiert, während Ausführungsaufgaben zugewiesen und Speicheroperationen durch Interaktion mit zugrunde liegenden Schichten ausgeführt werden.

Eine JVM kümmert sich auch um die dynamische Ressourcenverwaltung zum Ausführen von Java-Anwendungen. Dies bedeutet, dass das Zuweisen und Aufheben der Zuweisung von Speicher, die Aufrechterhaltung eines konsistenten Thread-Modells auf jeder Plattform und die Organisation der ausführbaren Anweisungen auf eine Weise erfolgt, die für die CPU-Architektur geeignet ist, in der die Anwendung ausgeführt wird. Die JVM befreit den Programmierer davon, Referenzen zwischen Objekten zu verfolgen und zu wissen, wie lange sie im System aufbewahrt werden sollen. Es befreit uns auch davon, genau entscheiden zu müssen, wann explizite Anweisungen zur Speicherfreigabe erteilt werden sollen - ein anerkannter Schmerzpunkt nicht dynamischer Programmiersprachen wie C.

Sie können sich die JVM als ein spezialisiertes Betriebssystem für Java vorstellen. Seine Aufgabe ist es, die Laufzeitumgebung für Java-Anwendungen zu verwalten. Eine JVM ist im Grunde eine virtuelle Ausführungsumgebung, die als Maschine für Bytecode-Anweisungen fungiert, während Ausführungsaufgaben zugewiesen und Speicheroperationen durch Interaktion mit zugrunde liegenden Schichten ausgeführt werden.

Übersicht über JVM-Komponenten

Es gibt noch viel mehr über JVM-Interna und Leistungsoptimierung zu schreiben. Als Grundlage für kommende Artikel in dieser Reihe werde ich mit einem Überblick über JVM-Komponenten schließen. Diese kurze Tour ist besonders hilfreich für Entwickler, die neu in der JVM sind, und sollte Ihren Appetit auf ausführlichere Diskussionen später in der Serie anregen.

Von einer Sprache zur anderen - über Java-Compiler

Ein Compiler verwendet eine Sprache als Eingabe und erzeugt eine ausführbare Sprache als Ausgabe. Ein Java-Compiler hat zwei Hauptaufgaben:

  1. Ermöglichen Sie, dass die Java-Sprache portabler ist und beim ersten Schreiben nicht an eine bestimmte Plattform gebunden ist
  2. Stellen Sie sicher, dass das Ergebnis effizienter Ausführungscode für die beabsichtigte Zielausführungsplattform ist

Compiler sind entweder statisch oder dynamisch. Ein Beispiel für einen statischen Compiler ist javac. Es verwendet Java-Code als Eingabe und übersetzt ihn in Bytecode - eine Sprache, die von der virtuellen Java-Maschine ausgeführt werden kann. Statische Compiler interpretieren den Eingabecode einmal und die ausführbare Ausgabedatei hat die Form, die bei der Ausführung des Programms verwendet wird. Da die Eingabe statisch ist, sehen Sie immer das gleiche Ergebnis. Nur wenn Sie Änderungen an Ihrer ursprünglichen Quelle vornehmen und neu kompilieren, wird ein anderes Ergebnis angezeigt.

Dynamische Compiler wie JIT-Compiler (Just-In-Time) führen die Übersetzung von einer Sprache in eine andere dynamisch durch, dh sie führen sie aus, während der Code ausgeführt wird. Mit einem JIT-Compiler können Sie Laufzeitprofildaten (durch Einfügen von Leistungsindikatoren) erfassen oder erstellen und mithilfe der vorliegenden Umgebungsdaten im Handumdrehen Compilerentscheidungen treffen. Die dynamische Kompilierung ermöglicht es, Anweisungen in der kompilierten Sprache besser zu sequenzieren, einen Befehlssatz durch effizientere Sätze zu ersetzen oder sogar redundante Operationen zu eliminieren. Mit der Zeit können Sie mehr Codeprofildaten sammeln und zusätzliche und bessere Kompilierungsentscheidungen treffen. Insgesamt wird dies normalerweise als Codeoptimierung und Neukompilierung bezeichnet.

Die dynamische Kompilierung bietet Ihnen den Vorteil, dass Sie sich im Laufe der Zeit an dynamische Änderungen des Verhaltens oder der Anwendungslast anpassen können, die neue Optimierungen erforderlich machen. Aus diesem Grund eignen sich dynamische Compiler sehr gut für Java-Laufzeiten. Der Haken ist, dass dynamische Compiler zusätzliche Datenstrukturen, Thread-Ressourcen und CPU-Zyklen für die Profilerstellung und Optimierung benötigen können. Für erweiterte Optimierungen benötigen Sie noch mehr Ressourcen. In den meisten Umgebungen ist der Overhead für die Verbesserung der Ausführungsleistung jedoch sehr gering - fünf- bis zehnmal bessere Leistung als bei reiner Interpretation (dh Ausführung des Bytecodes unverändert ohne Änderung).

Die Zuordnung führt zur Speicherbereinigung

Die Zuweisung erfolgt auf Thread-Basis in jedem "Java-Prozess-dedizierten Speicheradressraum", der auch als Java-Heap oder kurz Heap bezeichnet wird. Single-Threaded-Zuweisung ist in der clientseitigen Anwendungswelt von Java üblich. Die Zuweisung mit einem Thread wird jedoch auf der Seite der Unternehmensanwendungen und der Workload-Serving-Funktion schnell nicht optimal, da die Parallelität in modernen Multicore-Umgebungen nicht genutzt wird.

Das Parallell-Anwendungsdesign zwingt die JVM außerdem, sicherzustellen, dass nicht mehrere Threads gleichzeitig denselben Adressraum zuweisen. Sie können dies steuern, indem Sie den gesamten Zuordnungsbereich sperren. Diese Technik (eine sogenannte Heap-Sperre ) ist jedoch mit Kosten verbunden, da das Halten oder Einreihen von Threads die Leistung der Ressourcen und die Anwendungsleistung beeinträchtigen kann. Ein Pluspunkt von Multicore-Systemen ist, dass sie eine Nachfrage nach verschiedenen neuen Ansätzen für die Ressourcenzuweisung geschaffen haben, um den Engpass bei der serialisierten Zuweisung mit einem Thread zu verhindern.

Ein üblicher Ansatz besteht darin, den Heap in mehrere Partitionen zu unterteilen, wobei jede Partition eine "anständige Größe" für die Anwendung hat - offensichtlich etwas, das optimiert werden müsste, da die Zuordnungsrate und die Objektgrößen für verschiedene Anwendungen sowie nach erheblich variieren Anzahl der Themen. Ein Thread Local Allocation Buffer (TLAB) oder manchmal ein Thread Local Area(TLA) ist eine dedizierte Partition, die ein Thread frei zuweist, ohne eine vollständige Heap-Sperre beanspruchen zu müssen. Sobald der Bereich voll ist, wird dem Thread ein neuer Bereich zugewiesen, bis dem Heap die zu dedizierenden Bereiche ausgehen. Wenn nicht mehr genügend Speicherplatz zum Zuweisen des Heapspeichers vorhanden ist, ist dieser "voll", was bedeutet, dass der leere Speicherplatz auf dem Heap nicht groß genug für das Objekt ist, das zugewiesen werden muss. Wenn der Haufen voll ist, wird die Speicherbereinigung aktiviert.

Zersplitterung

Ein Haken bei der Verwendung von TLABs ist das Risiko, durch Fragmentierung des Heaps eine Ineffizienz des Speichers hervorzurufen. Wenn eine Anwendung Objektgrößen zuweist, die nicht zu einer TLAB-Größe passen oder diese vollständig zuweisen, besteht das Risiko, dass ein winziger leerer Bereich übrig bleibt, der zu klein ist, um ein neues Objekt zu hosten. Dieser übrig gebliebene Raum wird als "Fragment" bezeichnet. Wenn die Anwendung auch Verweise auf Objekte behält, die neben diesen verbleibenden Bereichen zugewiesen sind, kann der Bereich für lange Zeit nicht verwendet werden.