Garbage Collector in Java: G1, ZGC und wie man wählt
1. Übersicht
Der Garbage Collector (GC) bestimmt, wie lange Ihre Java-Anwendung pausiert und wie viel CPU zum Bereinigen des Speichers verbraucht wird. Bei falscher Wahl kann Latenz sprunghaft ansteigen, obwohl sich Ihr Code nicht geändert hat.
In diesem Artikel wird erklärt, wie G1 und ZGC in Java 21 funktionieren, wie man sie aktiviert und GC-Logs liest, wie man den laufenden GC per Code und Test überprüft. Am Ende stehen Kriterien zur Auswahl des GC für echte Anwendungen.
2. Die verfügbaren GC in Java 21
HotSpot JDK 21 hat folgende Haupt-GC:
| GC | Flag | Ziel |
|---|---|---|
| Serial | -XX:+UseSerialGC |
Kleiner Heap, 1 CPU, niedriger Footprint |
| Parallel | -XX:+UseParallelGC |
Maximaler Durchsatz, längere Pauses akzeptabel |
| G1 | -XX:+UseG1GC |
Gleichgewicht zwischen Durchsatz und Pause |
| ZGC | -XX:+UseZGC |
Extrem kurze Pauses, großer Heap |
| Shenandoah | -XX:+UseShenandoahGC |
Kurze Pauses (nur in einigen Builds) |
Wenn Sie kein Flag übergeben, wählt die JVM selbst. Auf einer “Server-Class”-Maschine (mindestens 2 CPUs und etwa 1792 MB RAM) verwendet die JVM G1. Andernfalls verwendet die JVM Serial.
Hinweis: In Containern liest die JVM die CPU- und Memory-Grenzen von cgroup. Ein Pod mit 1 CPU Limit wird von der JVM nicht als Server-Class behandelt und läuft stillschweigend mit Serial GC. Dies ist ein häufiger Grund für ungewöhnlich lange Pauses bei Services auf Kubernetes.
3. Wie G1 funktioniert
G1 (Garbage-First) teilt den Heap in mehrere gleich große Regionen auf, normalerweise von 1 MB bis 32 MB. Jede Region spielt eine Rolle: Eden, Survivor, Old oder Humongous (für große Objekte).
Der grundlegende Zyklus besteht aus:
- Young GC: hält alle Anwendungs-Threads an (Stop-the-World), kopiert lebende Objekte von Eden zu Survivor oder Old.
- Concurrent Marking: Wenn Old einen Schwellenwert erreicht, markiert G1 lebende Objekte parallel zur Anwendung.
- Mixed GC: Bereinigt sowohl Young als auch die Old-Regionen mit dem meisten Müll zuerst. Das ist die Bedeutung von “Garbage-First”.
Der Vorteil von G1 ist, dass Sie ein Pause-Ziel festlegen können:
java -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -Xms2g -Xmx2g -jar order-service.jar
MaxGCPauseMillis=100 fordert G1 auf, jede Pause unter 100 ms zu halten (Standard: 200 ms). G1 passt die Größe der Young Generation an, um dieses Ziel zu erreichen. Dies ist ein weiches Ziel, keine Garantie. Die Pause von G1 steigt immer noch mit der Anzahl lebender Objekte, die kopiert werden müssen.
4. Wie ZGC funktioniert
ZGC führt fast alle schweren Operationen (Marking, Relocation, Referenzaktualisierung) parallel zur Anwendung aus. Es verwendet farbige Pointer und Load Barrier: Jedes Mal, wenn der Code eine Referenz liest, prüft die Barrier, ob das Objekt verschoben wurde, und korrigiert die Referenz bei Bedarf.
Das Ergebnis sind Pauses normalerweise unter 1 ms und sie nehmen nicht mit der Heap-Größe zu. Ein 2-GB- oder 2-TB-Heap führen zu ähnlichen Pauses.
4.1. Generational ZGC in Java 21
Java 21 führt Generational ZGC (JEP 439) ein. Diese Version trennt Young und Old Generation, wodurch kurzlebige Objekte viel effizienter bereinigt werden. In Java 21 müssen Sie ein zusätzliches Flag aktivieren:
java -XX:+UseZGC -XX:+ZGenerational -Xmx4g -jar order-service.jar
Ohne -XX:+ZGenerational führt Java 21 ZGC im alten Stil aus (non-generational). Ab JDK 23 ist Generational Standard. Ab JDK 24 wurde der Non-Generational-Modus entfernt. Daher sollten Sie, wenn Sie ZGC in Java 21 verwenden, Generational gleich aktivieren.
4.2. Der Preis, den man zahlt
Kurze Pauses haben ihren Preis:
- Load Barrier verlangsamen jeden Referenzlesevorgang, daher ist der Durchsatz normalerweise einige Prozent niedriger als bei G1.
- ZGC braucht Headroom, d. h. freien Heap-Speicher. Die Anwendung allokiert weiterhin Speicher während des parallelen GC. Wenn der Heap vollläuft, bevor der GC fertig ist, werden Threads blockiert (“Allocation Stall”). Dann schießt die Latenz in die Höhe, das Gegenteil des ursprünglichen Ziels.
- GC-Threads konkurrieren parallel um CPU mit der Anwendung.
5. Test mit einem kleinen Programm
Das folgende Programm erstellt viele kurzlebige Order-Objekte und behält einen Teil, um einen Cache zu simulieren:
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class OrderLoadSimulator {
record Order(String id, String customer, byte[] payload) {}
public static void main(String[] args) {
List<Order> cache = new ArrayList<>();
long start = System.currentTimeMillis();
for (int i = 0; i < 5_000_000; i++) {
Order order = new Order(UUID.randomUUID().toString(), "customer-" + (i % 1000), new byte[512]);
if (i % 100 == 0) {
cache.add(order); // behalte 1% -> geht zu Old Generation
}
if (cache.size() > 20_000) {
cache.subList(0, 10_000).clear(); // entferne teilweise, erzeuge Müll in Old
}
}
System.out.printf("Done in %d ms, cache=%d%n",
System.currentTimeMillis() - start, cache.size());
}
}
Jede Iteration erstellt ein Order-Objekt mit 512-Byte-Payload. Die meisten Objekte sterben sofort, was der Hypothese “die meisten Objekte sterben jung” entspricht. Etwa 1% werden lange genug in cache behalten, um in die Old Generation zu gehen. Der subList(...).clear()-Befehl gibt sie zeitweise frei.
Ausführen mit G1 und GC-Logging mit Unified Logging (-Xlog) aktivieren:
java -XX:+UseG1GC -Xmx512m -Xlog:gc OrderLoadSimulator.java
Die Ausgabe sieht etwa so aus:
[0.008s][info][gc] Using G1
[0.312s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 25M->3M(512M) 2.814ms
[0.401s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 28M->4M(512M) 1.957ms
...
Done in 3120 ms, cache=15000
Jede Pause Young-Zeile zeigt den Heap vorher und nachher (25M->3M), den gesamt gebundenen Heap (512M) und die Pausenzeit (2.814ms).
Jetzt mit Generational ZGC ausführen:
java -XX:+UseZGC -XX:+ZGenerational -Xmx512m -Xlog:gc OrderLoadSimulator.java
[0.010s][info][gc] Using The Z Garbage Collector
[0.420s][info][gc] GC(0) Minor Collection (Allocation Rate) 60M(12%)->14M(3%) 0.021s
[0.610s][info][gc] GC(1) Minor Collection (Allocation Rate) 72M(14%)->16M(3%) 0.018s
...
Done in 3350 ms, cache=15000
Die Zeit am Ende jeder ZGC-Zeile (0.021s) ist die Dauer des gesamten GC-Zyklus, wobei der größte Teil parallel zur Anwendung läuft. Das ist nicht die Zeit, in der die Anwendung angehalten wurde. Um echte Pauses zu sehen, verwenden Sie -Xlog:gc,gc+phases=debug und suchen Sie nach den Zeilen Pause Mark Start, Pause Mark End, Pause Relocate Start. Diese Pauses dauern normalerweise nur Dutzende von Mikrosekunden.
Die genauen Zahlen unterscheiden sich auf Ihrem Computer. Der Trend ist normalerweise, dass ZGC etwas langsamer insgesamt ist, aber keine langen Pauses hat.
6. GC-Überprüfung zur Laufzeit per Code
In der Produktionsumgebung sollten Sie den tatsächlichen GC überprüfen, anstatt sich auf die Konfiguration zu verlassen. GarbageCollectorMXBean zeigt Ihnen das:
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.util.List;
public class GcInspector {
public static List<String> activeCollectors() {
return ManagementFactory.getGarbageCollectorMXBeans().stream()
.map(GarbageCollectorMXBean::getName)
.toList();
}
public static void main(String[] args) {
for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
System.out.printf("%s: count=%d, time=%d ms%n",
gc.getName(), gc.getCollectionCount(), gc.getCollectionTime());
}
}
}
Jeder GC registriert eine oder mehrere MXBeans mit einem charakteristischen Namen. Beispiel für G1:
G1 Young Generation: count=0, time=0 ms
G1 Concurrent GC: count=0, time=0 ms
G1 Old Generation: count=0, time=0 ms
Bei Generational ZGC sind die Namen ZGC Minor Cycles, ZGC Minor Pauses, ZGC Major Cycles und ZGC Major Pauses.
6.1. Test zum Überprüfen der GC-Konfiguration schreiben
Sie können die GC-Konfiguration mit Tests sperren, um zu verhindern, dass jemand versehentlich JVM-Optionen ändert. Konfigurieren Sie zunächst Surefire, um Tests mit ZGC auszuführen:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<argLine>-XX:+UseZGC -XX:+ZGenerational</argLine>
</configuration>
</plugin>
Schreiben Sie dann einen JUnit-5-Test mit AssertJ:
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class GcInspectorTest {
@Test
void whenRunningWithZgcFlag_thenZgcCollectorsAreActive() {
var collectors = GcInspector.activeCollectors();
assertThat(collectors)
.isNotEmpty()
.allMatch(name -> name.startsWith("ZGC"));
}
@Test
void whenGenerationalZgc_thenMinorCyclesBeanExists() {
assertThat(GcInspector.activeCollectors())
.contains("ZGC Minor Cycles");
}
}
Der erste Test überprüft, dass alle Collector ZGC sind. Der zweite Test stellt sicher, dass der Generational-Modus aktiviert ist. Wenn jemand -XX:+ZGenerational entfernt, ändert sich der Bean-Name zu ZGC Cycles und der Test schlägt fehl.
Hinweis: Dieser Test hängt von den JVM-Optionen des Test-Prozesses ab. Wenn Ihre IDE
argLinenicht von Surefire liest, schlägt der Test fehl, wenn Sie ihn direkt in der IDE ausführen.
7. GC für Spring-Boot-Anwendungen konfigurieren
Spring Boot wählt GC nicht selbst. Sie übergeben Flags an die JVM je nach Bereitstellungsmethode. Bei lokaler Ausführung mit Maven-Plugin:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<jvmArguments>-XX:+UseZGC -XX:+ZGenerational -Xmx2g</jvmArguments>
</configuration>
</plugin>
In Containern ist die eleganteste Methode die Umgebungsvariable JAVA_TOOL_OPTIONS. Die JVM liest diese Variable beim Start automatisch:
docker run -e JAVA_TOOL_OPTIONS="-XX:+UseG1GC -XX:MaxRAMPercentage=75 -Xlog:gc*:stdout:time" \
--cpus=2 --memory=2g payment-service:1.0
MaxRAMPercentage=75 setzt die maximale Heap-Größe auf 75% des Container-Speichers. Der Rest ist für Metaspace, Thread-Stack und Direct Buffer reserviert. Geben Sie immer -XX:+UseG1GC oder -XX:+UseZGC an, anstatt die JVM selbst wählen zu lassen, aus den Gründen in Abschnitt 2.
Wenn Sie Spring Boot Actuator und Micrometer haben, gibt Ihnen die Metrik jvm.gc.pause die Pausenzeit-Verteilung pro GC-Typ. Überwachen Sie die p99 und max dieser Metrik vor und nach dem GC-Wechsel.
8. GC auswählen
Beginnen Sie mit den Anforderungen der Anwendung, nicht mit dem “neuesten” GC:
G1 wählen wenn:
- Sie haben keinen spezifischen Grund zu wechseln. G1 ist die vernünftige Standardwahl für die meisten REST APIs und Services.
- Heap von einigen hundert MB bis zu einigen zehn GB.
- Pauses von Dutzenden bis 200 ms sind akzeptabel.
- CPU ist eng limitiert und Sie benötigen guten Durchsatz.
Generational ZGC wählen wenn:
- Latenzanforderungen für p99 oder p999 sind sehr streng, z. B. Trading, Game-Server, APIs mit SLA unter 50 ms.
- Große Heaps (Dutzende GB oder mehr), wenn G1-Pauses lang werden, weil viele Objekte leben.
- Sie haben zusätzliche CPU und Memory für Headroom.
Parallel wählen wenn: Sie Batch-Jobs, ETL, Offline-Datenverarbeitung ausführen. Gesamtzeit ist wichtiger als Pause.
Serial wählen wenn: Sie kleine CLIs, serverless Functions mit Heap unter etwa 100 MB und 1 CPU ausführen.
Hinweis: Verwenden Sie ZGC nicht auf kleinen Containern (1 CPU, 512 MB Heap). GC-Threads konkurrieren um CPU mit der Anwendung, wenig Headroom verursacht leicht Allocation Stalls. Das Ergebnis kann schlechter als G1 sein.
Häufige Fehler beim Tuning:
- Massenhaft alte Flags wie
-XX:NewRatiooder-XX:SurvivorRatiozu G1 hinzufügen. Diese Flags behindern den Selbstanpassungsmechanismus, umMaxGCPauseMilliszu erreichen. Sie sollten mit nur-Xmxund dem Pause-Ziel beginnen. MaxGCPauseMilliszu niedrig setzen, z. B. 10 ms, für G1. G1 wird die Young Generation verkleinern, GC läuft häufig und Durchsatz sinkt stark. Wenn Sie solch kurze Pauses brauchen, wechseln Sie zu ZGC.- GC-Wechsel, um Memory Leaks zu beheben. Kein GC kann Objekte freigeben, die noch referenziert werden. Analysieren Sie zuerst einen Heap Dump.
- Mit Benchmarks von wenigen Sekunden bewerten. Messen Sie unter produktionsähnlicher Last mindestens einige Dutzend Minuten und vergleichen Sie p99-Latenz, CPU, Memory.
9. Fazit
G1 ist die ausgewogene Standardwahl, passend für die meisten Anwendungen. ZGC, besonders die generational Version in Java 21, bietet sub-Millisekunden-Pauses mit sehr großen Heaps, kostet aber zusätzliche CPU und benötigt mehr Headroom.
Deklarieren Sie GC immer explizit, aktivieren Sie -Xlog:gc und bestätigen Sie den laufenden GC mit GarbageCollectorMXBean oder Tests. Wechseln Sie GC nur, wenn p99-Latenz-Daten zeigen, dass Pause wirklich ein Problem ist.