Wenn eine Java-Anwendung plötzlich langsamer wird, Requests verzögert beantwortet oder mit einem OutOfMemoryError endet, liegt die Ursache oft nicht im eigentlichen Fachcode, sondern im Speichermanagement. Der Garbage Collector in Java räumt nicht einfach „im Hintergrund auf“, sondern beeinflusst Latenz, Durchsatz und Betriebskosten direkt. Ich zeige, wie die automatische Speicherverwaltung funktioniert, welcher Collector zu welchem Szenario passt und wie sich Probleme systematisch diagnostizieren lassen.
Die wichtigsten Entscheidungen für stabile Java-Anwendungen
- G1 ist in aktuellen Server-JDKs meist der sinnvolle Ausgangspunkt.
- ZGC eignet sich besonders für sehr niedrige Pausen und große Heaps.
- Der Collector gibt Speicher nur dann frei, wenn Objekte nicht mehr erreichbar sind.
- Zu aggressive JVM-Parameter verschlechtern die Leistung oft stärker als sie helfen.
- GC-Logs, JFR und jcmd liefern die Grundlage für jede seriöse Optimierung.
Was der Garbage Collector in Java tatsächlich erledigt
Java verwaltet Objekte im Heap, also in einem Speicherbereich, den die JVM für dynamisch erzeugte Objekte reserviert. Sobald ein Objekt von keiner aktiven Referenz mehr erreicht werden kann, wird es für die Speicherbereinigung interessant. Der Garbage Collector sucht diesen nicht mehr benötigten Speicher und macht ihn für neue Objekte wieder nutzbar.
Das ist der große Vorteil gegenüber manueller Speicherverwaltung in C oder C++. Entwickler müssen normalerweise weder free() aufrufen noch selbst Buch führen, welche Objekte noch gebraucht werden. Gleichzeitig bedeutet automatische Verwaltung nicht, dass Speicherprobleme verschwinden. Eine statische Liste, ein unbereinigter Cache oder ein langlebiger Listener kann weiterhin ungewollt Referenzen halten.
Young Generation und Old Generation
Die Heap-Struktur folgt einer praktischen Beobachtung. Viele Objekte leben nur sehr kurz, etwa temporäre Strings, JSON-Zwischenobjekte oder Request-Daten. Deshalb werden neue Objekte zunächst in der Young Generation angelegt. Überleben sie mehrere Bereinigungen, können sie in die Old Generation wechseln.
Eine Bereinigung der jungen Generation ist meist schnell, weil dort viele Objekte bereits verschwunden sind. Eine Sammlung langlebiger Objekte kostet mehr Zeit. Genau deshalb ist die Objektlebensdauer für die Leistung wichtiger als die reine Zahl der erzeugten Objekte. Hohe Allokationsraten sind nicht automatisch ein Fehler, solange kurzlebiger Speicher schnell wieder freigegeben werden kann.
Erreichbarkeit statt manueller Löschung
Der Collector löscht nicht anhand einer Variablen, die der Entwickler „nicht mehr braucht“. Entscheidend ist die Erreichbarkeit von GC Roots. Dazu gehören unter anderem aktive Threads, statische Felder und lokale Variablen auf dem Stack. Solange von einer solchen Wurzel aus ein Pfad zu einem Objekt existiert, bleibt es erhalten.
In der Praxis sehe ich deshalb häufig vermeintliche Speicherlecks, die eigentlich Referenzierungsfehler sind. Ein Objekt ist fachlich längst überflüssig, hängt aber noch in einer globalen Map. Die richtige Lösung lautet dann nicht „mehr GC“, sondern die Lebensdauer dieser Referenz zu korrigieren.
Wie ein Bereinigungszyklus abläuft
Vereinfacht arbeitet ein Collector in drei Schritten. Zuerst werden erreichbare Objekte markiert, danach werden nicht mehr erreichbare Objekte erkannt und schließlich wird der freigewordene Speicher wieder organisiert. Je nach Collector laufen diese Phasen teilweise parallel zur Anwendung oder erfordern eine kurze Stop-the-world-Pause.
Young GC, Mixed GC und Full GC
| Sammlung | Was passiert | Typische Bedeutung |
|---|---|---|
| Young GC | Bereinigung der jungen Generation | Normaler Vorgang bei hoher Objektproduktion |
| Mixed GC | Junge Generation und ausgewählte alte Regionen werden gesammelt | Typisch für G1 während der Speicherfreigabe |
| Full GC | Der Heap wird umfassend bearbeitet, häufig mit längerer Pause | Warnsignal bei Speicherdruck oder ungünstiger Konfiguration |
Eine hohe Anzahl an Young GCs ist daher nicht automatisch problematisch. Ich bewerte zuerst Pausendauer, Häufigkeit und Anwendungslatenz. Ein System mit vielen sehr kurzen Sammlungen kann gesünder sein als eines mit wenigen, aber langen Full GCs.
Warum Stop-the-world nicht immer vermeidbar ist
Auch moderne, nebenläufige Collector müssen bestimmte Arbeitsschritte synchron mit den Anwendungsthreads ausführen. Referenzen müssen konsistent aktualisiert und bestimmte Objektbereiche sicher verschoben werden. „Concurrent“ bedeutet deshalb nicht „ohne jede Pause“, sondern dass der größte Teil der Arbeit parallel zur Anwendung stattfindet.
Die tatsächliche Pausenzeit hängt von mehreren Faktoren ab. Dazu gehören die Menge lebender Objekte, die Zahl der GC-Threads, die CPU-Auslastung, die Größe des Heaps und die Geschwindigkeit, mit der die Anwendung neue Objekte erzeugt. Ein Zielwert von 50 Millisekunden ist deshalb kein Versprechen, das die JVM unter jeder Last garantieren kann.
G1, ZGC und andere Collector sinnvoll vergleichen
Die Wahl des Collectors sollte vom Engpass ausgehen. Geht es um möglichst hohen Durchsatz, gleichmäßige Antwortzeiten oder um einen sehr großen Heap mit extrem kurzen Pausen? Für viele Anwendungen ist der Standard bereits gut gewählt. Ich ändere den Collector erst, wenn Messdaten zeigen, dass die Standardstrategie das reale Ziel verfehlt.
| Collector | Stärke | Geeignet für | Zu beachten |
|---|---|---|---|
| G1 | Guter Kompromiss aus Durchsatz und Pausen | Webanwendungen, APIs, typische Server-Workloads | Die Pausenziele sind Wahrscheinlichkeitsziele, keine harten Garantien |
| ZGC | Sehr niedrige Pausen auch bei großen Heaps | Latzenzkritische Anwendungen und große Datenmengen | Benötigt ausreichend Headroom und kann mehr CPU-Ressourcen nutzen |
| Parallel GC | Hoher Gesamtdurchsatz | Batch-Jobs und Anwendungen, bei denen Pausen weniger kritisch sind | Längere Pausen sind möglich |
| Serial GC | Geringer Verwaltungsaufwand | Kleine Anwendungen und Umgebungen mit wenigen CPU-Kernen | Skaliert schlecht bei großen Heaps und hoher Parallelität |
G1 als vernünftiger Startpunkt
G1 teilt den Heap in Regionen auf und sammelt bevorzugt die Bereiche, in denen sich besonders viel freier Speicher gewinnen lässt. Auf Server-Systemen wird er in aktuellen Java-Versionen typischerweise automatisch ausgewählt. Mit -XX:+UseG1GC lässt er sich ausdrücklich aktivieren.
G1 versucht, ein gesetztes Pausenziel mit hoher Wahrscheinlichkeit einzuhalten. Der häufig verwendete Parameter -XX:MaxGCPauseMillis ist allerdings kein harter Grenzwert. Wird er zu niedrig angesetzt, kann die JVM mehr CPU-Zeit für die Speicherverwaltung verwenden und dadurch den Gesamtdurchsatz reduzieren.
ZGC für niedrige Latenz
ZGC erledigt den aufwendigen Teil der Sammlung weitgehend nebenläufig und ist für Anwendungen interessant, bei denen einzelne lange Pausen nicht akzeptabel sind. Die Pausen hängen deutlich weniger von der Heap-Größe ab als bei klassischen Strategien. In aktuellen JDK-Versionen arbeitet ZGC generationsbasiert, wodurch kurzlebige Objekte effizienter behandelt werden können.
Die niedrigen Pausen haben ihren Preis. ZGC braucht genügend freien Spielraum, damit die Anwendung während der nebenläufigen Arbeit weiter Objekte anlegen kann. Bei der Auswahl würde ich daher nicht nur auf die maximale Heap-Größe schauen, sondern auch auf Live-Set, Allokationsrate und verfügbaren Arbeitsspeicher.
Wann Parallel GC die bessere Wahl sein kann
Ein Batch-Prozess, der nachts große Datenmengen verarbeitet und keine interaktiven Requests bedienen muss, profitiert oft stärker von hohem Durchsatz als von besonders kurzen Pausen. In solchen Fällen kann Parallel GC sinnvoll sein. Ein Collector ist nicht „modern“ oder „veraltet“, nur weil er andere Ziele verfolgt.
JVM-Parameter richtig einsetzen
Die wichtigsten Einstellungen sind meist weniger spektakulär als lange Listen spezieller Flags. Mit -Xms wird die anfängliche Heap-Größe festgelegt, mit -Xmx die Obergrenze. In Containern muss zusätzlich berücksichtigt werden, wie viel Speicher dem Prozess tatsächlich zur Verfügung steht und wie viel davon außerhalb des Java-Heaps benötigt wird.
Ich beginne bei einer neuen Anwendung gewöhnlich mit einem passenden Maximal-Heap und lasse den gewählten Collector möglichst viel selbst regulieren. Wer Young Generation, Survivor-Bereiche und zahlreiche interne Schwellenwerte gleichzeitig festlegt, nimmt der JVM oft genau die Anpassungsmöglichkeiten, die G1 oder ZGC für wechselnde Lasten benötigen.
Ein pragmatisches Startprofil
java -Xms2g -Xmx2g -Xlog:gc*:file=gc.log:time,uptime,level,tags -jar app.jarDas Beispiel setzt einen konstanten Heap von 2 GB und aktiviert strukturierte GC-Logs. Die konkrete Größe ist kein allgemeiner Richtwert. Sie muss zum Live-Set, zur Last und zum Speicherbudget passen. Ein zu kleiner Heap führt zu häufigen Sammlungen, ein unnötig großer Heap kann Startzeit, Container-Ressourcen und Diagnose erschweren.
Was ich nicht vorschnell verändern würde
-
-Xmnoder feste Young-Generation-Größen bei G1 - zahlreiche historische CMS- oder Parallel-GC-Parameter
- extrem niedrige Pausenziele ohne Messung
- eine größere Heap-Größe als Ersatz für einen echten Leak
Besonders gefährlich ist die Annahme, ein größerer Heap löse jedes Speicherproblem. Er kann die Zeit bis zum Fehler verlängern, aber eine dauerhaft gehaltene Referenz bleibt bestehen. Bei einem echten Leak steigt das Live-Set weiter an, bis auch ein großzügiger Heap nicht mehr reicht.
GC-Probleme systematisch diagnostizieren
Bei Beschwerden wie „Java ist langsam“ beginne ich nicht mit Tuning, sondern mit einer zeitlichen Korrelation. Treten die langen Requests genau während langer GC-Pausen auf? Steigt die Heap-Belegung nach jeder Sammlung weiter? Oder liegt die Verzögerung vielleicht bei Datenbank, Netzwerk, Locks oder externen APIs?
GC-Logs lesen
GC-Logs zeigen, welcher Collector aktiv ist, wie oft gesammelt wird und wie lange einzelne Pausen dauern. Besonders aufschlussreich sind Full GC-Ereignisse, eine dauerhaft hohe Belegung nach der Sammlung und lange Phasen, in denen die Anwendung schneller Speicher anfordert, als der Collector ihn freigeben kann.
Ein einzelner langer Ausreißer ist noch kein Beweis für ein systematisches Problem. Ich suche nach Mustern über mehrere Minuten oder Lasttests hinweg. Für G1 liefert eine detailliertere Konfiguration wie -Xlog:gc*=debug zusätzliche Phaseninformationen, erzeugt aber auch deutlich mehr Logvolumen.
Heap und Objekte untersuchen
Mit jcmd lassen sich unter anderem Heap-Informationen und Klassenzusammenfassungen abfragen. Ein Heap-Dump zeigt, welche Objekte den Speicher tatsächlich belegen und über welche Referenzketten sie erreichbar bleiben. Das ist bei Verdacht auf ein Leak wesentlich aussagekräftiger als ein Blick auf die momentane Heap-Auslastung.
Für Produktionssysteme nutze ich bevorzugt Java Flight Recorder, weil sich Allokationen, Threads, CPU-Nutzung und GC-Ereignisse zeitlich zusammen betrachten lassen. Ein Monitoring sollte nicht nur den Heap-Prozentsatz anzeigen, sondern auch Pausenquantile, Allokationsrate, Old-Generation-Auslastung und Fehler nach einer Sammlung.
Lesen Sie auch: End-to-End-Testing richtig einsetzen - stabile Tests für CI/CD
Typische Ursachen für lange Pausen
- Zu wenig Heap-Headroom bei hoher Allokationsrate
- Sehr viele langlebige Objekte und ein großes Live-Set
- Große sogenannte humongous objects, etwa riesige Byte-Arrays
- Unkontrollierte Caches oder statische Sammlungen
- Unpassende JVM-Flags aus einer älteren Java-Version
Große Objekte verdienen besondere Aufmerksamkeit, weil sie bei G1 mehrere Regionen belegen können und die Speicherorganisation erschweren. Oft ist es besser, eine Anwendung so umzubauen, dass sie Daten streamt oder in kleinere Blöcke zerlegt, anstatt ausschließlich den Collector zu verändern.
Die häufigsten Denkfehler in der Praxis
Der erste Irrtum lautet, dass System.gc() den Speicher zuverlässig sofort freigibt. Der Aufruf ist höchstens eine Bitte an die JVM und kann je nach Konfiguration ignoriert oder anders behandelt werden. In produktivem Anwendungscode hat er deshalb nur selten einen Platz.
Ein weiterer Fehler ist das ständige Optimieren auf die kürzeste einzelne Pause. Für eine API zählt meist die Verteilung der Antwortzeiten, etwa das p95- oder p99-Perzentil, nicht nur der Durchschnitt. Ein System mit minimalem Durchschnitt, aber gelegentlichen mehrsekündigen Pausen kann für Nutzer deutlich schlechter wirken.
Auch ein Wechsel zu ZGC ist kein automatischer Leistungsschub. Wenn die Anwendung zu viele Objekte erzeugt, ein Cache ungebremst wächst oder ein externer Dienst blockiert, bleibt die eigentliche Ursache bestehen. Der Collector kann Symptome abmildern, aber er ersetzt kein sauberes Objekt- und Lastdesign.
Schließlich wird oft die JVM optimiert, obwohl die Anwendung selbst unnötig viel Speicher produziert. Wiederverwendbare Puffer, begrenzte Caches, passende Datenstrukturen und Streaming können mehr bewirken als ein exotischer Parameter. Profiling vor Tuning ist für mich deshalb keine Floskel, sondern die wichtigste Reihenfolge.
Eine belastbare Entscheidung für das nächste Java-Projekt
Für eine typische Webanwendung würde ich mit G1, einem realistisch gesetzten -Xmx und aktivierten GC-Logs starten. Zeigen Messungen, dass die Latenz trotz ausreichendem Headroom nicht passt, ist ZGC ein sinnvoller Kandidat. Bei Batch-Verarbeitung mit klarer Priorität auf Durchsatz kann Parallel GC die passendere Richtung sein.
Die beste Konfiguration entsteht nicht aus einer allgemeinen Tabelle, sondern aus dem Verhältnis von Live-Set, Allokationsrate, Pausenziel und CPU-Budget. Wer diese vier Größen kennt und über Logs oder Flight Recorder überprüft, kann den Java Garbage Collector gezielt einsetzen, statt ihn auf Verdacht zu übersteuern.
Mein praktischer Rat lautet daher, zunächst die Objektlebensdauer und die realen Pausen zu messen, danach den Collector auszuwählen und erst zum Schluss einzelne Parameter zu verändern. So bleibt die automatische Speicherverwaltung ein verlässlicher Teil der JVM und wird nicht selbst zur neuen Fehlerquelle.