Verbessern Sie die Anwendungsleistung mit diesen fortgeschrittenen GC-Techniken

Die Anwendungsleistung steht im Vordergrund, und die Optimierung der Garbage Collection ist ein guter Ort, um kleine, aber bedeutsame Fortschritte zu machen

Die automatisierte Garbage Collection (zusammen mit dem JIT-HotSpot-Compiler) ist eine der fortschrittlichsten und am meisten geschätzten Komponenten der JVM, aber viele Entwickler und Ingenieure sind weit weniger vertraut mit der Garbage Collection (GC), wie sie funktioniert und wie sie sich auf die Anwendungsleistung auswirkt.

Zunächst einmal: Wofür ist GC überhaupt da? Garbage Collection ist der Prozess der Speicherverwaltung für Objekte im Heap. Wenn Objekte dem Heap zugewiesen werden, durchlaufen sie einige Sammlungsphasen – in der Regel recht schnell, da die meisten Objekte im Heap eine kurze Lebensdauer haben.

Die Müllsammlungsereignisse umfassen drei Phasen – Markierung, Löschung und Kopieren/Kompaktierung. In der ersten Phase durchläuft der GC den Heap und markiert alles entweder als lebende (referenzierte) Objekte, nicht referenzierte Objekte oder freien Speicherplatz. Nicht referenzierte Objekte werden dann gelöscht, und die verbleibenden Objekte werden verdichtet. In generationalen Garbage Collections „altern“ Objekte und werden in ihrem Leben durch 3 Bereiche befördert – Eden, Survivor Space und Tenured (Old) Space. Diese Verschiebung findet auch als Teil der Verdichtungsphase statt.

Aber genug davon, kommen wir zum lustigen Teil!

Pst! Suchen Sie nach einer Lösung, um die Anwendungsleistung zu verbessern? OverOps hilft Unternehmen nicht nur herauszufinden, wann und wo Verlangsamungen auftreten, sondern auch warum und wie sie auftreten. Sehen Sie sich eine Live-Demo an, um zu erfahren, wie es funktioniert.

Kennenlernen der Garbage Collection (GC) in Java

Eine der großartigen Eigenschaften der automatisierten GC ist, dass Entwickler nicht wirklich verstehen müssen, wie sie funktioniert. Leider bedeutet das, dass viele Entwickler NICHT verstehen, wie sie funktioniert. Die Garbage Collection und die vielen verfügbaren GCs zu verstehen, ist in etwa so, wie Linux CLI-Befehle zu kennen. Technisch gesehen muss man sie nicht benutzen, aber sie zu kennen und sich mit ihnen vertraut zu machen, kann sich erheblich auf die Produktivität auswirken.

Genauso wie bei den CLI-Befehlen gibt es die absoluten Grundlagen. ls-Befehl, um eine Liste von Ordnern innerhalb eines übergeordneten Ordners anzuzeigen, mv, um eine Datei von einem Ort zu einem anderen zu verschieben, usw. Bei GC wären diese Befehle gleichbedeutend mit dem Wissen, dass es mehr als eine GC zur Auswahl gibt und dass GC Leistungsprobleme verursachen kann. Natürlich gibt es noch viel mehr zu lernen (über die Verwendung der Linux-CLI UND über die Garbage Collection).

Der Zweck, etwas über den Garbage-Collection-Prozess von Java zu lernen, besteht nicht nur darin, eine kostenlose (und langweilige) Unterhaltung zu beginnen, sondern zu lernen, wie man die richtige GC mit optimaler Leistung für die eigene Umgebung effektiv implementiert und pflegt. Das Wissen, dass die Garbage Collection die Anwendungsleistung beeinflusst, ist grundlegend, und es gibt viele fortgeschrittene Techniken zur Verbesserung der GC-Leistung und zur Verringerung ihrer Auswirkungen auf die Anwendungszuverlässigkeit.

GC Performance Concerns

Memory Leaks –

Wenn man die Heap-Struktur kennt und weiß, wie die Garbage Collection durchgeführt wird, weiß man, dass die Speichernutzung allmählich ansteigt, bis ein Garbage Collection-Ereignis eintritt und die Nutzung wieder zurückgeht. Die Heap-Auslastung für referenzierte Objekte bleibt in der Regel konstant, so dass der Rückgang mehr oder weniger gleich groß sein sollte.

Bei einem Speicherleck wird mit jedem GC-Ereignis ein kleinerer Teil der Heap-Objekte gelöscht (obwohl viele der zurückbleibenden Objekte nicht verwendet werden), so dass die Heap-Auslastung weiter ansteigt, bis der Heap-Speicher voll ist und eine OutOfMemoryError-Ausnahme ausgelöst wird. Die Ursache hierfür ist, dass der GC nur nicht referenzierte Objekte zum Löschen markiert. Selbst wenn ein referenziertes Objekt nicht mehr verwendet wird, wird es also nicht aus dem Heap gelöscht. Es gibt einige hilfreiche Codierungstricks, um dies zu verhindern, auf die wir später noch eingehen werden.

Kontinuierliche „Stop the World“-Ereignisse –

In einigen Szenarien kann die Garbage Collection als „Stop the World“-Ereignis bezeichnet werden, da bei ihrem Auftreten alle Threads in der JVM (und damit auch die darauf laufende Anwendung) angehalten werden, um die GC ausführen zu können. In gesunden Anwendungen ist die GC-Ausführungszeit relativ gering und hat keine großen Auswirkungen auf die Anwendungsleistung.

In suboptimalen Situationen können Stop the World-Ereignisse jedoch die Leistung und Zuverlässigkeit einer Anwendung stark beeinträchtigen. Wenn ein GC-Ereignis eine Stop-the-World-Pause erfordert und die Ausführung 2 Sekunden dauert, erfährt der Endbenutzer dieser Anwendung eine Verzögerung von 2 Sekunden, da die Threads, die die Anwendung ausführen, angehalten werden, um GC zu ermöglichen.

Wenn Speicherlecks auftreten, sind kontinuierliche Stop-the-World-Ereignisse ebenfalls problematisch. Da bei jeder Ausführung der GC weniger Heap-Speicherplatz geleert wird, dauert es weniger lange, bis sich der verbleibende Speicher füllt. Wenn der Speicher voll ist, löst die JVM ein weiteres GC-Ereignis aus. Schließlich führt die JVM wiederholt „Stop the World“-Ereignisse aus, was zu erheblichen Leistungsproblemen führt.

CPU-Auslastung –

Und das alles läuft auf die CPU-Auslastung hinaus. Ein Hauptsymptom für kontinuierliche GC / Stop-the-World-Ereignisse ist ein Anstieg der CPU-Auslastung. GC ist ein rechenintensiver Vorgang und kann daher mehr als nur einen Teil der CPU-Leistung beanspruchen. Bei GCs, die mehrere Threads gleichzeitig ausführen, kann die CPU-Auslastung sogar noch höher sein. Die Wahl der richtigen GC für Ihre Anwendung hat den größten Einfluss auf die CPU-Auslastung, aber es gibt auch andere Möglichkeiten, die Leistung in diesem Bereich zu verbessern.

Aus diesen Leistungsproblemen im Zusammenhang mit der Garbage Collection wird deutlich, dass die Achillesferse von GCs, wie fortschrittlich sie auch sein mögen (und sie werden immer fortschrittlicher), dieselbe bleibt. Redundante und unvorhersehbare Objektzuweisungen. Um die Anwendungsleistung zu verbessern, reicht es nicht aus, die richtige GC zu wählen. Wir müssen wissen, wie der Prozess funktioniert, und wir müssen unseren Code so optimieren, dass unsere GCs nicht übermäßig viele Ressourcen beanspruchen oder übermäßige Pausen in unserer Anwendung verursachen.

Generational GC

Bevor wir uns mit den verschiedenen Java GCs und ihren Auswirkungen auf die Leistung beschäftigen, ist es wichtig, die Grundlagen der generationalen Garbage Collection zu verstehen. Das Grundkonzept der generationalen GC basiert auf der Idee, dass je länger ein Verweis auf ein Objekt im Heap existiert, desto unwahrscheinlicher ist es, dass es zur Löschung markiert wird. Durch die Kennzeichnung von Objekten mit einem bildlichen „Alter“ könnten sie in verschiedene Speicherbereiche aufgeteilt werden, um weniger häufig von der GC markiert zu werden.

Wenn ein Objekt dem Heap zugewiesen wird, wird es im so genannten Eden-Space abgelegt. Dort fangen die Objekte an, und in den meisten Fällen werden sie dort zum Löschen markiert. Objekte, die diese Phase überleben, „feiern Geburtstag“ und werden in den Survivor-Space kopiert. Dieser Prozess ist im Folgenden dargestellt:

Die Eden- und Survivor-Räume bilden die so genannte Young Generation. Hier spielt sich der Großteil der Handlung ab. Wenn ein Objekt in der Young Generation ein bestimmtes Alter erreicht, wird es in den Tenured-Raum (auch Old genannt) befördert. Der Vorteil der Aufteilung des Objektspeichers nach dem Alter ist, dass die GC auf verschiedenen Ebenen arbeiten kann.

Eine Minor GC ist eine Sammlung, die sich nur auf die Young Generation konzentriert und den Tenured Space völlig ignoriert. Im Allgemeinen ist die Mehrheit der Objekte in der Young Generation zum Löschen markiert und eine Major oder Full GC (einschließlich der Old Generation) ist nicht notwendig, um Speicher auf dem Heap freizugeben. Natürlich wird eine Major oder Full GC ausgelöst, wenn es notwendig ist.

Ein schneller Trick zur Optimierung der GC-Operation auf dieser Basis ist es, die Größen der Heap-Bereiche so anzupassen, dass sie am besten zu den Bedürfnissen Ihrer Anwendungen passen.

Collector Types

Es gibt viele verfügbare GCs, aus denen man wählen kann, und obwohl G1 die Standard-GC in Java 9 wurde, war sie ursprünglich dazu gedacht, den CMS-Collector zu ersetzen, der Low-Pause ist, so dass Anwendungen, die mit Throughput-Collectors laufen, möglicherweise besser geeignet sind, bei ihrem aktuellen Collector zu bleiben. Die Unterschiede in der Funktionsweise und den Auswirkungen auf die Leistung von Java-Garbage-Collectors zu verstehen, ist immer noch wichtig.

Throughput-Collectors

Besser für Anwendungen, die für hohen Durchsatz optimiert werden müssen und dafür eine höhere Latenz in Kauf nehmen können.

Seriell –

Der serielle Collector ist der einfachste und derjenige, den Sie wahrscheinlich am wenigsten benutzen werden, da er hauptsächlich für Single-Thread-Umgebungen (z.B. 32-Bit oder Windows) und für kleine Heaps konzipiert ist. Dieser Collector kann die Speichernutzung in der JVM vertikal skalieren, erfordert aber mehrere Major/Full GCs, um ungenutzte Heap-Ressourcen freizugeben. Dies verursacht häufige „Stop the World“-Pausen, was ihn in jeder Hinsicht für den Einsatz in benutzerorientierten Umgebungen disqualifiziert.

Parallel –

Wie der Name schon sagt, verwendet dieser GC mehrere parallel laufende Threads, um den Heap zu durchsuchen und zu verdichten. Obwohl der parallele GC mehrere Threads für die Garbage Collection verwendet, hält er dennoch alle Anwendungsthreads an, während er läuft. Der Parallel-Collector eignet sich am besten für Anwendungen, die für den besten Durchsatz optimiert werden müssen und dafür eine höhere Latenz tolerieren können.

Low-Pause-Collectors

Die meisten benutzerorientierten Anwendungen benötigen eine Low-Pause-GC, damit die Benutzerfreundlichkeit nicht durch lange oder häufige Pausen beeinträchtigt wird. Bei diesen GCs geht es vor allem um die Optimierung der Reaktionsfähigkeit (Zeit/Ereignis) und eine starke kurzfristige Leistung.

Concurrent Mark Sweep (CMS) –

Ähnlich wie der Parallel-Collector verwendet der Concurrent Mark Sweep (CMS)-Collector mehrere Threads, um nicht referenzierte Objekte zu markieren und zu entfernen (Sweep). Allerdings löst dieser GC nur in zwei bestimmten Fällen „Stop the World“-Ereignisse aus:

(1) bei der Initialisierung der anfänglichen Markierung von Roots (Objekte in der alten Generation, die von Thread-Einstiegspunkten oder statischen Variablen aus erreichbar sind) oder von Verweisen aus der main()-Methode, und einige weitere

(2), wenn die Anwendung den Zustand des Heaps verändert hat, während der Algorithmus parallel lief, Dadurch wird der Algorithmus gezwungen, zurück zu gehen und letzte Hand anzulegen, um sicherzustellen, dass die richtigen Objekte markiert sind

G1 –

Der Garbage First Collector (allgemein als G1 bekannt) verwendet mehrere Hintergrund-Threads, um den Heap zu durchsuchen, den er in Regionen unterteilt. Dabei werden die Regionen mit den meisten Garbage-Objekten zuerst gescannt, daher auch der Name (Garbage first).

Diese Strategie verringert die Gefahr, dass der Heap geleert wird, bevor die Hintergrund-Threads die Suche nach unbenutzten Objekten abgeschlossen haben, so dass der Kollektor die Anwendung anhalten müsste. Ein weiterer Vorteil des G1-Kollektors besteht darin, dass er den Heap während des laufenden Betriebs kompaktiert, was der CMS-Kollektor nur bei vollständigen „Stop the World“-Sammlungen tut.

Verbesserung der GC-Leistung

Die Anwendungsleistung wird direkt von der Häufigkeit und Dauer der Garbage Collections beeinflusst, was bedeutet, dass die Optimierung des GC-Prozesses durch die Reduzierung dieser Metriken erfolgt. Dazu gibt es zwei Hauptmöglichkeiten. Erstens durch die Anpassung der Heap-Größen von jungen und alten Generationen und zweitens durch die Verringerung der Objektzuweisungs- und Beförderungsrate.

Die Anpassung der Heap-Größen ist nicht so einfach, wie man vielleicht erwarten würde. Die logische Schlussfolgerung wäre, dass eine Erhöhung der Heap-Größe die GC-Häufigkeit verringern und gleichzeitig die Dauer erhöhen würde, und dass eine Verringerung der Heap-Größe die GC-Dauer verringern und gleichzeitig die Häufigkeit erhöhen würde.

Die Tatsache ist jedoch, dass die Dauer einer Minor GC nicht von der Größe des Heaps abhängt, sondern von der Anzahl der Objekte, die die Sammlung überleben. Das bedeutet, dass bei Anwendungen, die hauptsächlich kurzlebige Objekte erzeugen, eine Vergrößerung der Young Generation sowohl die Dauer als auch die Häufigkeit der GC verringern kann. Wenn jedoch die Erhöhung der Größe der jungen Generation zu einem signifikanten Anstieg der Objekte führt, die in Survivor Spaces kopiert werden müssen, werden GC-Pausen länger dauern, was zu einer erhöhten Latenz führt.

3 Tipps zum Schreiben von GC-effizientem Code

Tipp Nr. 1: Sammlungskapazitäten vorhersagen –

Alle Standard-Java-Sammlungen sowie die meisten benutzerdefinierten und erweiterten Implementierungen (wie Trove und Googles Guava) verwenden zugrunde liegende Arrays (entweder primitiv- oder objektbasiert). Da Arrays nach ihrer Zuweisung in ihrer Größe unveränderlich sind, kann das Hinzufügen von Elementen zu einer Sammlung in vielen Fällen dazu führen, dass ein altes zugrunde liegendes Array zugunsten eines größeren, neu zugewiesenen Arrays verworfen wird.

Die meisten Implementierungen von Sammlungen versuchen, diesen Neuzuweisungsprozess zu optimieren und ihn auf ein amortisiertes Minimum zu beschränken, selbst wenn die erwartete Größe der Sammlung nicht angegeben ist. Die besten Ergebnisse lassen sich jedoch erzielen, wenn die erwartete Größe der Sammlung bei der Erstellung angegeben wird.

Tipp Nr. 2: Verarbeiten Sie Datenströme direkt –

Bei der Verarbeitung von Datenströmen, wie z.B. aus Dateien gelesene oder über das Netzwerk heruntergeladene Daten, sieht man häufig etwas in der Art von:

Das resultierende Byte-Array könnte dann in ein XML-Dokument, ein JSON-Objekt oder eine Protokollpuffer-Nachricht geparst werden, um nur einige beliebte Optionen zu nennen.

Beim Umgang mit großen Dateien oder solchen von unvorhersehbarer Größe ist dies natürlich eine schlechte Idee, da es uns OutOfMemoryErrors aussetzt, falls die JVM nicht in der Lage ist, einen Puffer von der Größe der gesamten Datei zuzuweisen.

Ein besserer Weg, dies anzugehen, ist die Verwendung des entsprechenden InputStreams (in diesem Fall FileInputStream) und die direkte Einspeisung in den Parser, ohne das Ganze zuerst in ein Byte-Array zu lesen. Alle großen Bibliotheken stellen APIs zur Verfügung, um Streams direkt zu parsen, zum Beispiel:

Tipp #3: Verwenden Sie unveränderliche Objekte –

Die Unveränderlichkeit hat viele Vorteile. Einer, dem selten die Aufmerksamkeit geschenkt wird, die er verdient, ist seine Auswirkung auf die Garbage Collection.

Ein unveränderliches Objekt ist ein Objekt, dessen Felder (und in unserem Fall insbesondere nicht-primitive Felder) nicht geändert werden können, nachdem das Objekt konstruiert wurde.

Immutabilität bedeutet, dass alle Objekte, auf die ein unveränderlicher Container verweist, erstellt wurden, bevor die Konstruktion des Containers abgeschlossen ist. In GC-Begriffen: Der Container ist mindestens so jung wie die jüngste Referenz, die er enthält. Das bedeutet, dass die GC beim Durchführen von Garbage-Collection-Zyklen für junge Generationen unveränderliche Objekte überspringen kann, die in älteren Generationen liegen, da sie mit Sicherheit weiß, dass sie auf nichts in der Generation verweisen können, die gerade gesammelt wird.

Weniger zu scannende Objekte bedeuten weniger zu scannende Speicherseiten, und weniger zu scannende Speicherseiten bedeuten kürzere GC-Zyklen, was wiederum kürzere GC-Pausen und einen besseren Gesamtdurchsatz bedeutet.

Weitere Tipps und detaillierte Beispiele finden Sie in diesem Beitrag, der ausführliche Taktiken für das Schreiben von speichereffizientem Code enthält.

*** Ein großes Dankeschön an Amit Hurvitz vom R&D-Team von OverOps für seine Leidenschaft und seine Erkenntnisse, die in diesen Beitrag eingeflossen sind!

Schreibe einen Kommentar