JVM-Leistungsoptimierung, Teil 3: Speicherbereinigung

Der Garbage Collection-Mechanismus der Java-Plattform erhöht die Entwicklerproduktivität erheblich, aber ein schlecht implementierter Garbage Collector kann Anwendungsressourcen überbeanspruchen. In diesem dritten Artikel der JVM- Reihe zur Leistungsoptimierung bietet Eva Andreasson Java-Anfängern einen Überblick über das Speichermodell und den GC-Mechanismus der Java-Plattform. Sie erklärt dann, warum Fragmentierung (und nicht GC) das Hauptproblem ist! der Leistung von Java-Anwendungen und warum Generationen-Garbage-Collection und -Komprimierung derzeit die führenden (wenn auch nicht innovativsten) Ansätze zur Verwaltung der Heap-Fragmentierung in Java-Anwendungen sind.

Garbage Collection (GC) ist der Prozess, der darauf abzielt, belegten Speicher freizugeben, auf den kein erreichbares Java-Objekt mehr verweist, und der ein wesentlicher Bestandteil des dynamischen Speicherverwaltungssystems der Java Virtual Machine (JVM) ist. In einem typischen Speicherbereinigungszyklus bleiben alle Objekte erhalten, auf die noch verwiesen wird und die somit erreichbar sind. Der von zuvor referenzierten Objekten belegte Speicherplatz wird freigegeben und zurückgefordert, um die Zuweisung neuer Objekte zu ermöglichen.

Um die Garbage Collection und die verschiedenen GC-Ansätze und -Algorithmen zu verstehen, müssen Sie zunächst einige Dinge über das Speichermodell der Java-Plattform wissen.

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

Garbage Collection und das Java-Plattform-Speichermodell

Wenn Sie die Startoption -Xmxin der Befehlszeile Ihrer Java-Anwendung angeben (z. B. java -Xmx:2g MyApp:), wird der Speicher einem Java-Prozess zugewiesen. Dieser Speicher wird als Java-Heap (oder einfach als Heap ) bezeichnet. Dies ist der dedizierte Speicheradressraum, in dem alle von Ihrem Java-Programm (oder manchmal der JVM) erstellten Objekte zugewiesen werden. Während Ihr Java-Programm weiter ausgeführt wird und neue Objekte zuweist, füllt sich der Java-Heap (dh der Adressraum).

Schließlich ist der Java-Heap voll, was bedeutet, dass ein zuweisender Thread keinen ausreichend großen aufeinanderfolgenden Abschnitt des freien Speichers für das Objekt finden kann, das er zuordnen möchte. Zu diesem Zeitpunkt bestimmt die JVM, dass eine Garbage Collection durchgeführt werden muss, und benachrichtigt den Garbage Collector. Eine Garbage Collection kann auch ausgelöst werden, wenn ein Java-Programm aufruft System.gc(). Verwenden vonSystem.gc()garantiert keine Speicherbereinigung. Bevor eine Speicherbereinigung gestartet werden kann, ermittelt ein GC-Mechanismus zunächst, ob das Starten sicher ist. Es ist sicher, eine Garbage Collection zu starten, wenn sich alle aktiven Threads der Anwendung an einem sicheren Punkt befinden, um dies zu ermöglichen, z. B. einfach erklärt, dass es schlecht wäre, die Garbage Collection mitten in einer laufenden Objektzuweisung oder in der Mitte von zu starten Ausführen einer Folge optimierter CPU-Anweisungen (siehe meinen vorherigen Artikel über Compiler), da Sie möglicherweise den Kontext verlieren und dadurch die Endergebnisse durcheinander bringen.

Ein Garbage Collector sollte niemals ein aktiv referenziertes Objekt zurückfordern. Dies würde die Java Virtual Machine-Spezifikation brechen. Ein Müllsammler ist auch nicht erforderlich, um tote Gegenstände sofort zu sammeln. Tote Objekte werden schließlich während nachfolgender Speicherbereinigungszyklen gesammelt. Obwohl es viele Möglichkeiten gibt, die Speicherbereinigung zu implementieren, gelten diese beiden Annahmen für alle Sorten. Die eigentliche Herausforderung bei der Speicherbereinigung besteht darin, alles zu identifizieren, was aktiv ist (auf das noch verwiesen wird), und nicht referenzierten Speicher zurückzugewinnen, ohne jedoch die laufenden Anwendungen mehr als erforderlich zu beeinträchtigen. Ein Müllsammler hat also zwei Mandate:

  1. Schnelles Freigeben von nicht referenziertem Speicher, um die Zuordnungsrate einer Anwendung zu erfüllen, damit ihr nicht der Speicher ausgeht.
  2. Zurückgewinnen von Speicher bei minimaler Beeinträchtigung der Leistung (z. B. Latenz und Durchsatz) einer laufenden Anwendung.

Zwei Arten der Müllabfuhr

Im ersten Artikel dieser Reihe habe ich die beiden Hauptansätze zur Speicherbereinigung angesprochen, nämlich das Zählen von Referenzen und das Nachverfolgen von Sammlern. Dieses Mal werde ich näher auf jeden Ansatz eingehen und dann einige der Algorithmen vorstellen, die zum Implementieren von Tracing-Kollektoren in Produktionsumgebungen verwendet werden.

Lesen Sie die JVM-Reihe zur Leistungsoptimierung

  • JVM-Leistungsoptimierung, Teil 1: Übersicht
  • JVM-Leistungsoptimierung, Teil 2: Compiler

Referenzzählsammler

Referenzzählungssammler verfolgen, wie viele Referenzen auf jedes Java-Objekt verweisen. Sobald die Anzahl für ein Objekt Null wird, kann der Speicher sofort zurückgefordert werden. Dieser sofortige Zugriff auf zurückgewonnenen Speicher ist der Hauptvorteil des Referenzzählansatzes für die Speicherbereinigung. Es gibt sehr wenig Overhead, wenn es darum geht, nicht referenzierten Speicher zu behalten. Es kann jedoch sehr kostspielig sein, alle Referenzzählungen auf dem neuesten Stand zu halten.

Die Hauptschwierigkeit bei Referenzzählungssammlern besteht darin, die Referenzzählungen genau zu halten. Eine weitere bekannte Herausforderung ist die Komplexität im Umgang mit kreisförmigen Strukturen. Wenn zwei Objekte aufeinander verweisen und kein lebendes Objekt auf sie verweist, wird ihr Speicher niemals freigegeben. Beide Objekte bleiben für immer mit einer Zählung ungleich Null. Das Zurückgewinnen von Speicher, der mit kreisförmigen Strukturen verbunden ist, erfordert eine umfassende Analyse, die den Algorithmus und damit die Anwendung mit kostspieligem Aufwand belastet.

Sammler aufspüren

Tracing-Kollektoren basieren auf der Annahme, dass alle lebenden Objekte gefunden werden können, indem alle Referenzen und nachfolgenden Referenzen aus einem anfänglichen Satz von bekannten Live-Objekten iterativ verfolgt werden. Der anfängliche Satz von Live-Objekten ( Root-Objekte oder kurz Roots genannt ) wird durch Analysieren der Register, globalen Felder und Stapelrahmen zu dem Zeitpunkt lokalisiert, zu dem eine Garbage Collection ausgelöst wird. Nachdem ein anfänglicher Live-Satz identifiziert wurde, folgt der Ablaufverfolgungssammler den Referenzen dieser Objekte und stellt sie in die Warteschlange, um sie als aktiv zu markieren, und lässt anschließend ihre Referenzen verfolgen. Markieren Sie alle gefundenen referenzierten Objekte livebedeutet, dass der bekannte Live-Satz mit der Zeit zunimmt. Dieser Vorgang wird fortgesetzt, bis alle referenzierten (und damit alle lebenden) Objekte gefunden und markiert wurden. Sobald der Ablaufverfolgungskollektor alle aktiven Objekte gefunden hat, wird der verbleibende Speicher zurückgefordert.

Nachverfolgungskollektoren unterscheiden sich von Referenzzählkollektoren darin, dass sie kreisförmige Strukturen verarbeiten können. Der Haken bei den meisten Verfolgungskollektoren ist die Markierungsphase, die eine Wartezeit erfordert, bevor nicht referenzierter Speicher zurückgefordert werden kann.

Ablaufverfolgungskollektoren werden am häufigsten für die Speicherverwaltung in dynamischen Sprachen verwendet. Sie sind bei weitem die am häufigsten in der Java-Sprache verwendeten und haben sich in Produktionsumgebungen seit vielen Jahren kommerziell bewährt. Ich werde mich im weiteren Verlauf dieses Artikels auf die Verfolgung von Kollektoren konzentrieren, beginnend mit einigen Algorithmen, die diesen Ansatz für die Speicherbereinigung implementieren.

Tracing-Collector-Algorithmen

Kopieren und Mark-and-Sweep- Garbage-Collection sind nicht neu, aber sie sind immer noch die beiden am häufigsten verwendeten Algorithmen, die die Tracing-Garbage-Collection implementieren.

Sammler kopieren

Herkömmliche Kopierkollektoren verwenden einen From-Space und einen To-Space , dh zwei separat definierte Adressräume des Heaps. Zum Zeitpunkt der Speicherbereinigung werden die Live-Objekte innerhalb des als from-space definierten Bereichs in den nächsten verfügbaren Bereich innerhalb des als to-space definierten Bereichs kopiert. Wenn alle lebenden Objekte im Raum entfernt sind, kann der gesamte Raum zurückgefordert werden. Wenn die Zuweisung erneut beginnt, beginnt sie am ersten freien Ort im To-Space.

In älteren Implementierungen dieses Algorithmus wird zwischen dem Raum und dem Raum umgeschaltet. Dies bedeutet, dass bei vollem Raum die Speicherbereinigung erneut ausgelöst wird und der Raum zum Raum wird, wie in Abbildung 1 dargestellt.

Modernere Implementierungen des Kopieralgorithmus ermöglichen die Zuweisung beliebiger Adressräume innerhalb des Heaps als Raum und Raum. In diesen Fällen müssen sie nicht unbedingt den Standort miteinander wechseln. Vielmehr wird jeder zu einem anderen Adressraum innerhalb des Heaps.

Ein Vorteil des Kopierens von Kollektoren besteht darin, dass Objekte im Raum eng miteinander verbunden sind, wodurch eine Fragmentierung vollständig vermieden wird. Fragmentierung ist ein häufiges Problem, mit dem andere Garbage Collection-Algorithmen zu kämpfen haben. etwas, das ich später in diesem Artikel besprechen werde.

Nachteile des Kopierens von Sammlern

Kopierkollektoren sind normalerweise Stop-the-World-Kollektoren , was bedeutet, dass keine Anwendungsarbeit ausgeführt werden kann, solange sich die Garbage Collection im Zyklus befindet. In einer Stop-the-World-Implementierung ist die Auswirkung auf die Anwendungsleistung umso größer, je größer der zu kopierende Bereich ist. Dies ist ein Nachteil für Anwendungen, die empfindlich auf Reaktionszeiten reagieren. Bei einem Kopiersammler müssen Sie auch das Worst-Case-Szenario berücksichtigen, in dem sich alles im Weltraum befindet. Sie müssen immer genügend Kopffreiheit lassen, damit lebende Objekte bewegt werden können. Dies bedeutet, dass der To-Space groß genug sein muss, um alles im From-Space aufzunehmen. Der Kopieralgorithmus ist aufgrund dieser Einschränkung leicht speichereffizient.

Mark-and-Sweep-Sammler

Die meisten kommerziellen JVMs, die in Produktionsumgebungen von Unternehmen bereitgestellt werden, führen Mark-and-Sweep-Kollektoren (oder Markierungskollektoren) aus, die nicht die Auswirkungen auf die Leistung haben, die Kopierkollektoren haben. Einige der bekanntesten Markierungssammler sind CMS, G1, GenPar und DeterministicGC (siehe Ressourcen).

Ein Mark-and-Sweep-Kollektor verfolgt Referenzen und markiert jedes gefundene Objekt mit einem "Live" -Bit. Normalerweise entspricht ein gesetztes Bit einer Adresse oder in einigen Fällen einem Satz von Adressen auf dem Heap. Das Live-Bit kann beispielsweise als Bit im Objektheader, als Bitvektor oder als Bitmap gespeichert werden.

Nachdem alles live markiert wurde, beginnt die Sweep-Phase. Wenn ein Collector eine Sweep-Phase hat, enthält er im Grunde einen Mechanismus zum erneuten Durchlaufen des Heaps (nicht nur des Live-Sets, sondern der gesamten Heap-Länge), um alle nicht markierten zu lokalisieren Blöcke aufeinanderfolgender Speicheradressräume. Nicht markierter Speicher ist frei und kann zurückgefordert werden. Der Sammler verknüpft diese nicht markierten Blöcke dann zu organisierten freien Listen. In einem Garbage Collector können verschiedene kostenlose Listen vorhanden sein, die normalerweise nach Blockgrößen sortiert sind. Einige JVMs (z. B. JRockit Real Time) implementieren Kollektoren mit Heuristiken, die dynamisch Größenbereichslisten basierend auf Anwendungsprofildaten und Objektgrößenstatistiken erstellen.

Wenn die Sweep-Phase abgeschlossen ist, beginnt die Zuordnung erneut. Neue Zuordnungsbereiche werden aus den freien Listen zugewiesen, und Speicherblöcke können an Objektgrößen, Objektgrößenmittelwerte pro Thread-ID oder an die Anwendung abgestimmte TLAB-Größen angepasst werden. Wenn Sie den freien Speicherplatz näher an die Größe Ihrer Anwendung anpassen, wird der Arbeitsspeicher optimiert und die Fragmentierung verringert.

Mehr über TLAB-Größen

Die Partitionierung von TLAB und TLA (Thread Local Allocation Buffer oder Thread Local Area) wird in JVM-Leistungsoptimierung, Teil 1 erläutert.

Nachteile von Mark-and-Sweep-Sammlern

Die Markierungsphase hängt von der Menge der Live-Daten auf Ihrem Heap ab, während die Sweep-Phase von der Größe des Heaps abhängt. Da Sie warten müssen, bis sowohl die Markierungs- als auch die Sweep- Phase abgeschlossen sind, um Speicher zurückzugewinnen, verursacht dieser Algorithmus Pausenzeitprobleme für größere Heaps und größere Live-Datensätze.

Eine Möglichkeit, Anwendungen mit hohem Speicherbedarf zu unterstützen, besteht darin, GC-Optimierungsoptionen zu verwenden, die verschiedene Anwendungsszenarien und -anforderungen berücksichtigen. In vielen Fällen kann die Optimierung dazu beitragen, dass zumindest eine dieser Phasen nicht zu einem Risiko für Ihre Anwendung oder Service Level Agreements (SLAs) wird. (Ein SLA gibt an, dass die Anwendung bestimmte Antwortzeiten der Anwendung erfüllt, dh die Latenz.) Die Optimierung für jede Laständerung und Anwendungsänderung ist jedoch eine sich wiederholende Aufgabe, da die Optimierung nur für eine bestimmte Arbeitslast und Zuordnungsrate gültig ist.

Implementierungen von Mark-and-Sweep

Es gibt mindestens zwei im Handel erhältliche und bewährte Ansätze zur Implementierung der Mark-and-Sweep-Sammlung. Einer ist der parallele Ansatz und der andere ist der gleichzeitige (oder meist gleichzeitige) Ansatz.

Parallele Kollektoren

Parallele Erfassung bedeutet, dass dem Prozess zugewiesene Ressourcen parallel zum Zweck der Speicherbereinigung verwendet werden. Die meisten kommerziell implementierten Parallelkollektoren sind monolithische Stop-the-World-Kollektoren - alle Anwendungsthreads werden gestoppt, bis der gesamte Speicherbereinigungszyklus abgeschlossen ist. Durch das Stoppen aller Threads können alle Ressourcen effizient parallel verwendet werden, um die Speicherbereinigung während der Markierungs- und Sweep-Phasen abzuschließen. Dies führt zu einem sehr hohen Wirkungsgrad, was normalerweise zu hohen Punktzahlen bei Durchsatz-Benchmarks wie SPECjbb führt. Wenn der Durchsatz für Ihre Anwendung wesentlich ist, ist der parallele Ansatz eine ausgezeichnete Wahl.