Mit Shenandoah und ZGC gibt es im OpenJDK erstmals zwei Low-Pause Garbage Collectors, die die berüchtigten Pausenzeiten von Java-Anwendungen auf Millisekunden beschränken. Wir stellen die Arbeitsweise der beiden Collectors vor, messen mit Hilfe einer praxisnahen Benchmark, ob die Collectors halten, was sie versprechen, und diskutieren, wie sich Shenandoah und ZGC voneinander abgrenzen.
Zu den dynamischsten Gebieten des OpenJDK zählen derzeit die Garbage Collectors. Gab es 20 Jahre nach Einführung von Java mit SerialGC, ParallelGC, CMS und G1 lediglich vier Collectors, sind seit Java 11 mit ZGC und Shenandoah gleich zwei neue hinzugekommen. Beides sind Low-Pause Collectors, die Anwendungen nur wenige Millisekunden unterbrechen und den Großteil ihrer Aktivitäten nebenläufig zu den Anwendungsthreads ausführen. Das ist ein Novum im OpenJDK: Vergleichbare Produkte wie C4 von Azul waren in der Vergangenheit nur auf kommerzieller Basis verfügbar.
In der Literatur [1], [2] wurden Shenandoah und ZGC in der Vergangenheit separat behandelt, ohne ihre Funktionsweise zueinander in Bezug zu setzen. Wir verfolgen einen anderen Ansatz und arbeiten heraus, dass die beiden Collectors auf demselben Prozessmodell basieren.
Objekte werden in Java in einem spezifischen Bereich, dem Heap, gespeichert. Ein Objekt heißt lebend, wenn die Anwendung darauf zugreifen kann. Objekte, die für die Applikation nicht erreichbar sind, werden als Garbage bezeichnet. Ein Garbage Collector (GC) hat die Aufgabe, Garbage zu identifizieren und zu recyceln, d. h. den von ihm belegten Speicherplatz wieder verfügbar zu machen. Ein GC ist regionenbasiert, wenn er den Heap in kleinere Bereiche (Regionen) unterteilt, die separat recycelt werden können.
Ein GC heißt parallel, wenn er seine Aufgaben auf mehrere parallel arbeitende Threads verteilt, er heißt concurrent oder nebenläufig, wenn die Threads des GCs und der Anwendung gleichzeitig ausgeführt werden. Hält ein GC die Anwendung an, spricht man von Stop the World (STW), die Anwendungsunterbrechung wird als Pause oder Latenz bezeichnet.
ZGC wurde von Oracle zunächst proprietär für das eigene JDK entwickelt, aber Ende 2017 in das OpenJDK überführt und in Java 11 freigegeben. Shenandoah (benannt nach dem gleichnamigen Nationalpark in den USA) wird seit 2013von Red Hat entwickelt und ist seit Java 12 Teil des OpenJDK. In Release 13 wurde Version 2.0 veröffentlicht, die weitreichende konzeptionelle Änderungen umfasst (Eliminierung des Extraworts zur Speicherung des Forward Pointers, Ersetzen der Read und Write Barriers durch eine Load Barrier).
Die vorliegende Darstellung bezieht sich auf Shenandoah 2.x und basiert bei beiden Collectors auf Java 14. Die wesentlichen Eigenschaften der Collectors sind die folgenden:
Pausenzeiten im Millisekundenbereich, auch bei Terabyte-Heaps
die Pausenzeit ist unabhängig von der Größe des Heap
Parallele und nebenläufige Verarbeitung in allen GC-Phasen
regionenbasiert, keine Generationen
Unterstützung von Concurrent Class Unloading und Uncommit Memory
Die Collectors zeichnen sich ferner durch Anwenderfreundlichkeit aus, da kein aufwendiges Konfigurieren nötig ist, um ein gutes Verhältnis von Durchsatz und kurzen Pausenzeiten zu erzielen.
Garbage Collectors arbeiten zyklisch. Ein Zyklus besteht bei Shenandoah und ZGC im Kern aus den Phasen Markierung, Evakuierung und Remapping (Abb. 1).
Die Phasen werden zum größten Teil nebenläufig zur Anwendung ausgeführt. Innerhalb eines Zyklus gibt es bei Shenandoah vier und bei ZGC drei Pausen, die in der Regel jeweils etwa 1 ms dauern.
Die Integrität der Anwendung, die nebenläufig zum GC auf dem Heap operiert, wird durch Barrieren sichergestellt. Eine Barriere ist Programmcode, der vom Hotspot-Compiler generiert und automatisch in den Anwendungscode eingebettet wird. Shenandoah und ZGC verwenden eine Mark und eine Load Barrier, die entsprechend der jeweiligen Phase ausgeführt werden. In den folgenden Abschnitten erläutern wir die Aufgaben der Phasen und ihr Zusammenspiel mit den Barrieren.
Die Markierung ermittelt durch Traversieren des Objektgraphen die lebenden Objekte und den von ihnen belegten Speicherplatz. Anhand dieser Daten wählt der GC Regionen, die besonders viel Garbage enthalten, zum Recyceln aus (Region 1 in Abb. 2). Um Objekte nicht mehrfach zu verarbeiten, markiert der GC die erfassten Objekte mit Hilfe einer Bitmap. ZGC setzt zusätzlich ein Markierungsbit im Colored Pointer.
Die Markierung der Root-Objekte, d. h. der Wurzeln des Graphen (darunter fallen u. a. lokale und statische Variablen), erfolgt derzeit in einer STW-Phase. Allerdings arbeiten die Teams bei Shenandoah und ZGC aktuell daran, dass auch dieser Schritt künftig nebenläufig ausgeführt wird. Zum Abschluss der Markierung gibt es nochmals eine Pause, in der Einträge der lokalen Buffer (siehe nächster Abschnitt) abgearbeitet und Regionen, die ausschließlich Garbage enthalten, sofort wieder freigegeben werden.
Die Schwierigkeit bei der nebenläufigen Markierung besteht darin, dass die Anwendung den Objektgraphen während der laufenden Markierung verändern kann. Zum Beispiel könnte ein neues Wurzelobjekt erzeugt werden. Ohne Vorkehrung würde dieses Objekt nicht als lebend erkannt und im schlimmsten Fall recycelt – Inkonsistenzen wären die Folge. Daher kommt eine Mark Barrier zum Einsatz. Bei Shenandoah ist das eine klassische SATB Barrier [3], die nach den folgenden Regeln arbeitet:
Alle Objekte, die nach Beginn der Markierungsphase neu erzeugt wurden, werden als lebend markiert.
Ändert sich eine Referenz während der Markierungsphase, wird das ursprünglich referenzierte Objekt von der Mark Barrier in einen Buffer geschrieben. Objekte im Buffer und von ihnen aus erreichbare Objekte werden als lebend markiert.
ZGC verwendet ein etwas gröberes Verfahren, indem es alle Objekte, deren Referenz geladen wird, in den Buffer schreibt. Durch dieses Verfahren wird sichergestellt, dass alle lebenden Objekte markiert werden. Darüber hinaus können Objekte markiert sein, die am Ende der Markierungsphase nicht mehr lebend sind. Das beeinträchtigt jedoch nicht die Integrität des Heap.
Eine Region kann erst recycelt werden, nachdem die lebenden Objekte dieser Region an eine freie Stelle im Heap kopiert (evakuiert) wurden. Dieser Kopiervorgang erfolgt in der Evakuierungsphase, die zugleich der Defragmentierung des Heap dient. Sie beginnt mit der Evakuierung der Root-Objekte, wobei Root-Referenzen sofort umgesetzt werden (Referenz r in Abb. 2).
Daneben werden Forward-Informationen gespeichert, die es ermöglichen, anhand der Speicheradresse des originalen Objekts die Adresse der Kopie zu ermitteln. Bei Shenandoah wird diese Information per Forward Pointer (Kasten: „Pointers“) direkt im Originalobjekt hinterlegt, bei ZGC in einer Zuordnungstabelle (Forward Table) außerhalb des Heap. Da bei ZGC die Forward-Daten außerhalb des Heap lagern, kann eine Region direkt nach der Evakuierung recycelt werden (Quick Release). Bei Shenandoah ist die Freigabe der Region hingegen erst nach dem Remapping möglich, da die Forward Pointer bis dahin benötigt werden.
Ein weiterer Unterschied besteht darin, dass Root-Objekte bei ZGC in einer STW-Phase evakuiert und remappt werden, während Shenandoah diesen Schritt seit JDK 14 nebenläufig ausführt. Allerdings ist die nebenläufige Verarbeitung der Root-Objekte auch bei ZGC in Arbeit.
Nach der Evakuierung ist für jedes lebende Objekt, dessen Region recycelt werden soll, eine Kopie angelegt. Jedoch verweisen die Referenzen innerhalb des Heap noch auf die Originale (in Abb. 2 durch Referenz x dargestellt). Um den GC-Zyklus abzuschließen, müssen diese Referenzen noch auf die Kopien umgesetzt werden. Dieser Vorgang wird als Remapping bezeichnet. In der Remapping-Phase wird erneut der Heap durchlaufen und für jede Referenz geprüft, ob ein Remapping notwendig ist – und die Referenz entsprechend mit Hilfe der Forward-Informationen korrigiert.
Forward Pointer ist ein Mechanismus, bei dem Informationen über die Zuordnung von einem Objekt zu seiner Kopie im Objekt selbst gespeichert werden. Ein Java-Objekt besteht aus einem Mark Word (64 Bit), dem Class Pointer und seinen Feldern (Abb. 3).
Shenandoah macht sich zunutze, dass bei einem regulären Objekt die beiden niederwertigsten Bits des Mark Word niemals mit 112 belegt sind: Sobald bei der Evakuierung ein Objekt kopiert wurde, schreibt Shenandoah die Adresse der Kopie in das Mark Word des Originals (Bits 2-63) und setzt die beiden niederwertigsten Bits auf 112.
Die Aktualität einer Referenz kann nun von der Load Barrier und vom GC beim Remapping geprüft werden, indem das Mark Word des referenzierten Objekts gelesen wird. Beginnt es nicht mit 112, ist die Referenz aktuell, ansonsten ist sie auf die im Mark Word hinterlegte Adresse umzusetzen.
Colored Pointers ist eine Technik, bei der Metadaten in den Objektreferenzen hinterlegt werden. Abbildung 4 zeigt den Aufbau einer Objektreferenz bei ZGC.
ZGC nutzt Colored Pointers, um in den Barrieren effizient den Status abrufen zu können. So zeigt ein gesetztes marked0- bzw. marked1-Bit der Mark Barrier an, dass das referenzierte Objekt bereits markiert ist, und anhand des remapped-Bit entscheidet die Load Barrier, ob eine Referenz aktuell oder ob ein Remapping erforderlich ist.
Colored Pointers haben zur Folge, dass mehrere Adressen (korrespondierend zu den gesetzten Bits) auf den physischen Speicherplatz eines Objekts verweisen. Um aus einer Referenz die physische Speicheradresse zu ermitteln, greift ZGC auf Multi-Mapping-Funktionen des Betriebssystems zurück. Deshalb ist ZGC nur für Betriebssysteme verfügbar, die Multi-Mapping unterstützen (zurzeit Linux, macOS, Windows 10 und Windows Server 2019).
Konkret prüft ZGC bei diesem Vorgang zunächst, ob in der Referenz das remapped-Bit gesetzt ist. Ist das nicht der Fall und verweist die Referenz in eine Region, die evakuiert wurde, holt der GC die neue Adresse aus der Forward Table, korrigiert die Referenz und setzt das remapped-Bit. Shenandoah prüft hingegen direkt, ob die Referenz in eine evakuierte Region verweist und liest in diesem Fall das Mark Word des referenzierten Objekts. Sind dessen zwei niederwertigste Bits mit 112 belegt, wird die neue Adresse dem Mark Word entnommen und die Referenz entsprechend korrigiert.
Der Ablauf unterscheidet sich zwischen ZGC und Shenandoah auch insofern, als ZGC das Remapping gemeinsam mit der Markierung des nächsten GC-Zyklus durchführt (wodurch ein separater Durchlauf des Heap eingespart wird), während bei Shenandoah das Remapping direkt im Anschluss an die Evakuierung erfolgt.
Nachdem ein Objekt evakuiert wurde, gibt es mit dem Original und der Kopie temporär zwei Versionen des Objekts (Abb. 2, Evakuierung). Hieraus können Inkonsistenzen und Datenverluste resultieren: Nehmen wir an, die Anweisung x.i = 7 wird nach der Evakuierung des von x referenzierten Objekts und vor dem Remapping ausgeführt. In diesem Fall verweist die Referenz noch auf das Original, weshalb der zugewiesene Wert i = 7 verlorengeht, wenn die Referenz beim Remapping auf die Kopie (die noch den alten Wert von i enthält) umgesetzt wird (Abb. 2).
Die Load Barrier wendet diese Probleme ab, indem sie sicherstellt, dass die Applikation stets auf die Kopie zugreift („to-space invariant“). Im Detail prüft sie beim Laden einer Objektreferenz, ob es zum referenzierten Objekt eine Kopie gibt, und remappt in diesem Fall die Referenz direkt auf die Kopie. Ist ein Objekt vom GC zur Evakuierung vorgesehen, die Evakuierung aber noch nicht erfolgt, so führt die Load Barrier die Evakuierung selbst durch. In Pseudocode wird die Anweisung x.i = 7 vom Hotspot-Compiler überführt in:
if (is_eglible_for_evacuation(x))
if (has_copy(x))
x = get_copy(x);
else
x = create_copy(x);
x.i = 7;
Führt die Load Barrier ein Remapping oder eine Evakuierung durch, spricht man von Self-Healing (Abb. 3): Wird die Referenz später nochmal geladen, verweist sie bereits auf das korrekte Objekt.
Wir haben Durchsatz und Pausenzeiten von G1, ParallelGC, Shenandoah und ZGC mit Hilfe der DaCapo-Benchmark [4] gemessen. DaCapo umfasst eine Sammlung von Real-World-Java-Applikationen. Ausgeführt wurde die Benchmark unter Linux x86_64 mit sechs CPUs und der Heap-Größe 1 GB. Die Ergebnisse sind in Abbildung 5 und Abbildung 6 dargestellt.
Die Laufzeiten der Anwendungen unterscheiden sich zwischen Shenandoah und ZGC kaum; verglichen mit ParallelGC und G1 sind sie um bis zu 10 Prozent höher. Die höhere Laufzeit resultiert aus dem Overhead, der entsteht, wenn der Garbage Collector CPU-Ressourcen in Anspruch nimmt, während er nebenläufig zu den Anwendungsthreads arbeitet. Tendenziell reduziert sich dieser Overhead, je mehr CPUs zur Verfügung stehen. Die maximale Pausenzeit liegt bei Shenandoah und ZGC in der Regel deutlich unter 10 ms und ist gegenüber ParallelGC und G1 um mehr als den Faktor 10 besser.
Für Applikationen, die viele schwache Referenzen erzeugen, sind die Pausenzeiten bei Shenandoah länger als bei ZGC. Ursache ist, dass Shenandoah schwache Referenzen in einer STW-Phase markiert, ZGC hingegen nebenläufig. Das spiegelt sich in der Benchmark wider, wo avrora die geringste und tradebeans die größte Anzahl an schwachen Referenzen hat.
ZGC und Shenandoah verfügen mit Markierung, Evakuierung und Remapping über dieselben Kernprozesse, die jeweils von Mark und Load Barriers unterstützt werden. Konzeptionell sind die Unterschiede in der Arbeitsweise gering. Die wichtigsten technischen Unterschiede sind die folgenden:
Shenandoah nutzt Forward Pointer, während ZGC Forward-Informationen in einer Forward Table außerhalb des Heap speichert.
ZGC setzt Colored Pointers ein, um Statusinformationen in den Objektreferenzen abzulegen, die von den Barrieren effizient abgeprüft werden.
Aufgrund der Colored Pointers ist ZGC auf 64-Bit-Betriebssysteme beschränkt und unterstützt keine Compressed Oops. Shenandoah unterstützt 64- und 32-Bit-Plattformen und Compressed Oops.
Nach Einschätzung des Shenandoah-Entwicklers Aleksey Shipilëv von Red Hat werden bei der Wahl eines Low-Pause Collectors weniger technische Leistungsmerkmale, sondern primär Servicegesichtspunkte im Vordergrund stehen [5], denen wir uns nun zuwenden wollen.
Wir beginnen mit der Verfügbarkeit. In den Binärdistributionen des Oracle (Open)JDK ist Shenandoah nicht enthalten [6]. Oracle-Kunden sind daher hinsichtlich eines Low-Pause Collectors auf ZGC festgelegt. In den Distributionen der anderen gängigen (Open)JDK-Anbieter sind sowohl ZGC als auch Shenandoah enthalten. Verfügbar ist ZGC seit JDK 11, Shenandoah seit JDK 12 und in den Long-Term-Support-(LTS-)Releases JDK 8u und 11u.
Das leitet über zum nächsten Stichpunkt: ZGC und Shenandoah verfolgen eine unterschiedliche Politik bezüglich Backports für zurückliegende LTS. Der Ansatz von ZGC ist, keine Backports vorzunehmen und den Anwendern stattdessen eine Migration auf die neueste Version zu empfehlen [7]. Da zwischen LTS JDK 11 und JDK 14 umfangreiche Verbesserungen vorgenommen wurden, empfiehlt ZGC aktuell ein Upgrade auf JDK 14. Shenandoah setzt auf eine konträre Strategie und portiert Weiterentwicklungen und Fehlerbehebungen kontinuierlich in frühere JDK-Releases. Daher ist Shenandoah insbesondere in den älteren LTS JDK 8u und JDK 11u auf dem neuesten Stand. Dieser Unterschied kann für Anwender insofern von Relevanz sein, als beide Collectors derzeit intensiv weiterentwickelt werden und in den nächsten Releases mit weiteren Verbesserungen zu rechnen ist. Je nach Servicemodell ist dann ein Update oder ein Wechsel des JDK-Release notwendig, um von den Neuerungen zu profitieren.
Wir haben die neuen Garbage Collectors Shenandoah und ZGC vorgestellt. Ihre Garbage Collection basiert auf demselben Prozessmodell, entsprechend gering sind die konzeptionellen Unterschiede. Größere Differenzen gibt es hingegen im Releasemanagement bei Backports von Weiterentwicklungen und Bugfixes in zurückliegende Long-Term-Support-Releases, die bei der Auswahl bedacht werden sollten.
In Benchmarks beeindrucken die Collectors mit Latenzzeiten, die um mehr als den Faktor 10 unter den Latenzzeiten von G1 und ParallelGC bleiben. Zugleich liegt ihr Durchsatz nur geringfügig unter dem der anderen Collectors. Sie sind daher prädestiniert für Anwendungen, die auf geringe Latenzzeiten angewiesen sind, um etwa vorgegebene Service-Level zu erfüllen. Das gilt umso mehr, als die Latenzzeiten im Gegensatz zu den klassischen Collectors nicht mit der Größe des Heap steigen. Aufgrund dieser positiven Eigenschaften ist damit zu rechnen, dass die Anzahl der Nutzer rasch anwachsen wird, wenn Shenandoah und ZGC ab Java 15 den experimentellen Status verlassen und in den Production-Status übergehen.
Ralph Ellinger ist Mathematiker und Sun-zertifizierter Java- und Business-Komponenten-Developer. Er beschäftigt sich seit über zehn Jahren mit der Konzeption und Entwicklung von Software im Java-Umfeld. Sein besonderes Interesse gilt reaktiven Programmiermodellen.
[1] Beckwith, Monica: „OpenJDK: In the New Age of Concurrent Garbage Collectors“: https://jaxlondon.com/wp-content/uploads/2019/11/OpenJDK_-_in_the_new_Age_of_concurrent_Garbage_Collectors.pdf
[2] Bordet, Simone: „Concurrent GCs ZGC & Shenandoah“: https://www.youtube.com/watch?v=WU_mqNBEacw
[3] Strauch, Walery: „Wer bringt den Müll raus?“; in: Java Magazin 11.18
[4] http://dacapobench.sourceforge.net
[5] https://www.opsian.com/blog/aleksey-shipilev-shenandoah-concurrent-gcs
[6] https://developers.redhat.com/blog/2019/04/19/not-all-openjdk-12-builds-include-shenandoah-heres-why
[7] https://mail.openjdk.java.net/pipermail/zgc-dev/2019-November/000836.html