Blog

Garbage Collection in JDK 17

27 Mrz 2023

Zwischen JDK 11 and JDK 17 hat sich viel getan in Javas GC-Landschaft. Man könnte sogar so weit gehen, zu sagen, dass nach einer Periode gewisser Stagnation seit dem JDK 11 wieder neues Leben und Innovation in die Memory-Management-Szene gekommen sind, die zu beträchtlichen Innovationen geführt haben. Dieser Artikel soll die wichtigsten Änderungen im Bereich Java Garbage Collection zwischen JDK 11 und JDK 17 näher beleuchten.

Die für manche Entwickler vermutlich einschneidendste Veränderung dürfte die Entfernung des Concurrent Mark Sweep (CMS) Collector sein. CMS war lange Zeit der einzige GC, der versprach, „concurrent“ zu sein, d. h., neben der Anwendung zu laufen und die berüchtigten GC-Pausen zu minimieren. Er war allerdings auch nicht frei von diversen Problemen. Am hervorstechendsten im negativen Sinne war seine Eigenschaft, den Heap mit der Zeit zu fragmentieren, was irgendwann dazu führt, dass eine Allokation keinen passenden Speicherblock mehr findet, und einen Full GC auslöst – der dann wieder eine längere Stop-the-World-Pause bedeutet. Um das möglichst zu vermeiden, kann man versuchen, verschiedene Parameter zu konfigurieren. Und hier beginnt das nächste Problem: seine relative Komplexität an Einstellungsmöglichkeiten. Um den CMS Collector existiert eine Vielzahl an Flags (mehr als 70). Die Auswirkungen dieser Flags auch nur ansatzweise zu verstehen, inklusive der Interferenzen untereinander, erfordert ein mittleres Curriculum in GC Tuning. Zu den genannten äußeren Problemen kommt, dass die Codebasis von CMS unwahrscheinlich komplex und damit anfällig für Fehler ist, von denen einige sehr aufwendig zu finden und zu beheben waren und viele schlichtweg brach liegen.

All das veranlasste die OpenJDK-Community mit dem JEP 291 den CMS in JDK 9 als veraltet (deprecated) zu markieren. Als sich auch nach mehreren Releases niemand gefunden hat, der den CMS weiter pflegen wollte, wurde er schließlich mit JEP 363 in JDK 14 ganz entfernt. Damit stellt sich für bisherige Nutzer des CMS die Frage, welche Alternativen es gibt. Es bieten sich hierfür (mit Einschränkungen) G1, ZGC (seit JDK 11) und Shenandoah GC (seit JDK 12, inzwischen in JDK 11) an.

G1

Der G1 GC – G1 steht für „Garbage First“ – ist ein Garbage Collector, der teilweise concurrent arbeitet, und dessen Ziel es ist, die GC-Pausen managebar zu machen. Das soll heißen, der Nutzer kann per Flag ein Pause-Target vorgeben und der G1 GC versucht anhand eigener Messungen und Heuristiken dieses Ziel einzuhalten. Wichtig ist dabei zu verstehen, dass diese Pause-Targets vernünftig sein müssen: 200 ms (der Defaultwert) ist noch in der Komfortzone, bei 50 ms wird es schwierig, 10 ms sind nahezu unmöglich. Man muss außerdem verstehen, dass die Varianz durchaus groß sein kann: Falls der Heap in einen ungünstigen Zustand kommt, können Old Generation GCs oder sogar Full GCs getriggert werden, und die Pausen können dann durchaus auch mehrere 100 ms betragen.

Der G1 GC bietet also einen brauchbaren Kompromiss zwischen Performance und Latenzzeiten. Was sind nun die Neuentwicklungen in JDK 17?

  • JEP 344 – Abortable Mixed Collections for G1: Diese Änderung reduziert Fälle, in denen das Pause-Target verfehlt wird, indem es sogenannte Mixed Collections – Young und Old Collection während einer GC Pause – aufteilbar und abbrechbar macht. Damit kann G1 eine Mixed Collection abbrechen, wenn gemessen wird, dass das Pause-Target verfehlt wird, und bald einen zweiten GC Cycle beginnen.

  • JEP 346 – Promptly Return Unused Committed Memory from G1: Vor dieser Änderung hat G1 nur bei Full GCs Speicher an das Betriebssystem zurückgegeben. Da allerdings versucht wird, Full GCs zu vermeiden, kann es sein, dass das sehr selten oder gar nicht passiert. Diese Änderung verbessert das Verhalten, indem G1 auch Speicher zurückgibt, wenn der GC sonst nichts zu tun hat.

  • JEP 345 – NUMA-Aware Memory Allocation for G1: NUMA steht für Non-Uniform Memory Access, und bedeutet, dass Arbeitsspeicherregionen „näher“ oder „entfernter“ von CPU Sockets sind. Diese Architektur steht im Gegensatz zu UMA (Uniform Memory Access), bei der auf sämtlichen Arbeitsspeicher gleichberechtigt von CPU Sockets zugegriffen wird. NUMA findet sich vor allem auf größeren Serverkonfigurationen. Diese Änderung verbessert die Zuordnung von GC-Regionen zu NUMA Sockets und führt zu verbesserter Performance auf Systemen, die mit NUMA konfiguriert sind.

Dazu kommen zahlreiche kleinere Verbesserungen am G1 Collector, die seine Performance verbessern und für mehr Stabilität sorgen. Der G1 GC ist der Default-Collector.

Shenandoah GC

Shenandoah schickt sich an, die Lücke zu füllen, die der CMS hinterlassen hat: Er ist ein Garbage Collector, der vollständig concurrent arbeitet, also nebenläufig zur laufenden Anwendung. Das erklärte Ziel des Projekts ist, einen Garbage Collector bereitzustellen, der das Problem der GC-Pausen löst und damit die Latenz der Java VM deutlich verringert. Dabei sollten nicht die Probleme des CMS wiederholt werden: Er kompaktiert den Heap (bzw. einzelne Regionen darin) und vermeidet damit die mittel- und langfristige Fragmentierung, die beim CMS zu Problemen führte. Im Vergleich zum G1 wird auch die Evakuierungsphase nebenläufig zur Anwendung ausgeführt, was bedeutet, dass selbst bei sehr großen Heaps die Pausen kurz bleiben können.

Als OpenJDK-Projekt hat Red Hat 2013 mit der Entwicklung am Shenandoah GC begonnen, in JDK 12 ist eine erste stabile Version ins JDK aufgenommen worden (inzwischen wurde Shenandoah auch nach JDK 11 rückportiert), und es wurden in jedem Release deutliche Verbesserungen vorgenommen:

  • JDK 12 / JEP 189: Einführung des Shenandoah GC als Experimental Feature

  • JDK 13: Ein neues Barrier-Konzept (Load Reference Barriers) wurde eingeführt, das zu besserer Performance führte

  • JDK 14: Concurrent Class Unloading

  • JDK 15: Mit JEP 379 wurde Shenandoah als non-experimental (also Production-ready) eingestuft

  • JDK 16: Concurrent Weak Reference Processing

  • JDK 17: Concurrent Thread-Stack Processing

All diese Verbesserungen bewirken, dass die GC-Pausen deutlich unter 10 ms liegen, meistens sogar unter 1 ms.

Shenandoah GC wird auf allen von OpenJDK unterstützten Betriebssystemen (Linux, Windows, Mac OS X, Solaris) sowie den wichtigsten Architekturen (x86_64, x86_32, ARM64) unterstützt. Er ist Teil der OpenJDK Builds aller Anbieter, mit Ausnahme von Oracle. Shenandoah kann mit dem Kommandozeilen-Flag -XX:+UseShenandoahGC aktiviert werden.

ZGC

Der ZGC wurde der Öffentlichkeit 2017 von Oracle als OpenJDK-Projekt vorgestellt. Wie Shenandoah GC verfolgt er das Ziel, die GC-Pausen zu minimieren, indem alle GC-Phasen nebenläufig zur Java-Anwendung ausgeführt werden. Mit JEP 333 wurde ZGC in JDK 11 aufgenommen. Diese Version war voll funktionsfähig und stabil. Ähnlich wie Shenandoah GC wurden in folgenden JDK-Versionen wesentliche Verbesserungen implementiert:

  • JDK 13 / JEP 351: ZGC: Uncommit Unused Memory (Experimental)

  • JDK 14 / JEP 364: ZGC on macOS (Experimental)

  • JDK 14 / JEP 365: ZGC on Windows (Experimental)

  • JDK 15 / JEP 377: ZGC: A Scalable Low-Latency Garbage Collector (Production)

  • JDK 16 / JEP 376: ZGC: Concurrent Thread-Stack Processing

Angesichts der Tatsache, dass ZGC und Shenandoah die gleichen Ziele haben, nämlich die GC-Pausen zu minimieren, lohnt es sich, einen kurzen Blick auf die Unterschiede zu werfen. ZGC verfolgt im Vergleich zu Shenandoah einen anderen Ansatz bei der Implementierung: ZGC verwendet sogenannte „colored pointers“, um den GC-Zustand von Objekten zu markieren, sowie eine Heap-externe Tabelle, um Forwarding-Information zu speichern, während Shenandoah Forwarding-Information im Objektheader speichert (sog. Brooks Pointers) und den GC-Zustand extern in Marking-Bitmap- und anderen Strukturen verwaltet. Für den Nutzer wirkt sich dieser Unterschied hauptsächlich in der Unterstützung von Compressed References aus (-XX:+UseCompressedOops). Compressed References erlauben es der Java VM, Referenzen von einem Objekt zu einem anderen in 32 Bit darzustellen, anstatt der üblichen 64 Bit, solange der Java Heap kleiner als 32 GB ist (oder mehr, wenn man größeres Object Alignment akzeptiert – aber das führt hier zu weit). Das bedeutet, dass Objekte mit vielen Referenzen oder große Objektarrays deutlich weniger Arbeitsspeicher in Anspruch nehmen. Erreicht wird das durch smarte Komprimierung von Referenzen auf 32 Bit. Da ZGC allerdings Extrabits in Referenzen benötigt, können diese nicht mehr auf 32 Bit komprimiert werden, d. h., ZGC kann Compressed References nicht unterstützen. Allgemeiner gesprochen bedeutet das, dass ZGC bei Heap-Größen unter 32 GB etwas im Nachteil ist, was Performanz und Speicherverbrauch betrifft.

Der andere relevante Unterschied zwischen ZGC und Shenandoah liegt in der Unterstützung seitens der JVM-Anbieter: Oracle hat sich bisher geweigert, Shenandoah in seinen Builds einzubauen, alle anderen Anbieter (Red Hat, Amazon Corretto, SAP SAPMachine, Microsoft, Azul Zulu etc.) bieten Shenandoah an. ZGC wird von allen JVM-Anbietern in ihren Builds bereitgestellt. ZGC wird mit dem Kommandozeilen-Flag -XX:+UseZGC aktiviert.

Serial GC

Serial GC ist das Urgestein unter den Garbage Collectors in OpenJDK. Er ist im Wesentlichen ein klassischer single-threaded, generational, mark-compact GC, der vollständig die Anwendung blockiert, während der Speicher aufgeräumt wird. Die Tatsache, dass er nur mit einem Thread arbeitet, macht ihn für viele größere Workloads ungeeignet. Durch die Entwicklung hin zu Microservices und Containern hat er allerdings in letzter Zeit ein kleines Revival erfahren. In solchen Anwendungen, bei denen man eher kleinere Workloads hat, die wenig Speicher benötigen und bei denen Antwortzeiten und Latenz keine große Rolle spielen, kann der Serial GC die beste Wahl sein – für diese Anwendungsbereiche ist er definitiv am besten optimiert.

Abgesehen von internen Umstrukturierungen durch die Entfernung des CMS hat der Serial GC keine nennenswerten Weiterentwicklungen erfahren. Das ist vielleicht auch nicht notwendig, da er innerhalb seiner Grenzen sehr ausgereift ist. Der Serial GC kann mit dem Flag –XX:+UseSerialGC aktiviert werden.

Parallel GC

Ein weiteres Urgestein ist der Parallel GC. Im Wesentlichen ist der Parallel GC eine Weiterentwicklung der Algorithmen des Serial GC, um die Garbage Collection mit mehreren Threads parallel (aber immer noch nicht nebenläufig zur Anwendung) arbeiten lassen zu können. Das macht ihn zum Collector der Wahl, wenn es um reine Performance geht und Antwortzeiten und Latenz keine Rolle spielen, z. B. bei Batchprozessen. Die Möglichkeit, mehrere GC Threads zu verwenden, macht ihn auch geeignet für größere Workloads. Wie schon der Serial GC, hat der Parallel GC keine nennenswerten Weiterentwicklungen erfahren, abgesehen von internen Umstrukturierungen. Aktiviert wird er mit dem Flag -XX:+UseParallelGC.

Epsilon GC

Nicht unerwähnt bleiben soll der relativ neue experimentelle Epsilon GC. Epsilon nennt sich auch „no-op“ GC, einfach deswegen, weil er nichts macht. Sobald der Java Heap voll ist, steigt die JVM mit einem OutOfMemoryError aus. Das mag nach einem seltsamen Ansatz klingen, hat aber gewisse Anwendungsbereiche:

  1. Anwendungen, die beim Start einmal alle ihre Datenstrukturen erstellen und im weiteren Verlauf keine neuen mehr benötigen

  2. Anwendungen, bei denen es relativ egal ist, wenn die Anwendung aussteigt, zum Beispiel weil ein Container sie einfach schnell neustartet (FaaS, SaaS etc.)

  3. Testing, zum Beispiel um verschiedene GCs in Performancetests zu vergleichen – der Epsilon GC stellt da sozusagen die Baseline dar.

Der Epsilon GC hat Vorteile: Dadurch, dass er nichts macht, benötigt er auch keine Barriers bei Heap-Zugriffen (wie alle anderen GCs) und kann daher den Benutzercode optimal kompilieren und maximale Performance bieten. Falls man Epsilon verwenden möchte, dann kann man ihn mit den Kommandozeilenflags -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC aktivieren.

Keine Infos mehr verpassen!

ALLE NEWS ZUM EXTREME JAVA CAMP!