Java Magazin 08/09

Der Garbage Collector – das unbekannte Wesen?

Autor:

In den letzten beiden Artikeln haben wir uns bereits mit dem Thema Speichermanagement in Java beschäftigt. Nach der Vorstellung allgemeiner Antipatterns wurden die Entstehung und das Auffinden von Memoryleaks genauer beleuchtet. In diesem Artikel werden wir uns detaillierter mit dem Thema Garbage Collection befassen.

Die falsche (oder fehlende) Konfiguration des Garbage Collectors führt häufig zu Performanceproblemen. Sehr oft werden schlechte Antwortzeiten allerdings nicht sofort mit einem schlecht konfigurierten Garbage Collector in Verbindung gebracht, weil es schwierig ist, die GC-Zeiten mit Antwortzeiten oder sogar Methodenlaufzeiten zu korrelieren. Ein weiteres Problem bei der Verwendung des Garbage Collectors ist die deklarative Konfiguration. Es wird nicht konfiguriert, wann Objekte aus dem Speicher entfernt werden, sondern nur mit welcher Strategie. Ein gutes Verständis von Garbage-Collector-Algorithmen ist hier also eine Grundvoraussetzung für performante Anwendungen.

Die Zutaten

Im Prinzip benötigt man nur ein paar wenige Zutaten, um die Garbage-Collector-Algorithmen zu verstehen und optimieren zu können: die Basisalgorithmen von Garbage Collectoren und ein Verständnis für Pausenzeiten, Parallelität und Nebenläufigkeit. Die ersten JVM-Implementierungen hatten einen sehr einfachen Mark-and-Sweep-Algorithmus verwendet, um den Speicher von Müll zu befreien. Das Prinzip ist einfach. Alle aktiven Threads werden gestoppt und beginnend bei den GC Roots alle Objekte markiert, die von einem GC Root aus noch erreichbar sind, sprich, referenziert werden. Die restlichen Objekte sind Müll und werden vom Garbage Collector beseitigt. Dabei entstehen sehr schnell zwei Probleme: Erstens stört das Anhalten aller Threads (also die Pausenzeiten) und auf Dauer wird der Speicher fragmentiert und es kann zu OutOfMemory-Problemen kommen, obwohl noch genug Speicher vorhanden ist. Das passiert dann, wenn nicht mehr genug Speicher am Stück vorhanden ist, um ein Objekt im Heap abzulegen. Schnell wurden hierfür Lösungen entwickelt. Compacting Garbage Collectoren lösen das Problem mit der Fragmentierung – sie kopieren die Objekte regelmäßig vom Beginn des Heaps nacheinander, sodass keine Lücken mehr vorhanden sind und wieder der maximal verfügbare Speicher am Stück verwendet werden kann. Natürlich dauert das auch seine Zeit und die Pausen werden noch länger. Gerade bei Multiprozessormaschinen ist das extrem störend, da alle Threads angehalten werden und nur ein Thread die Garbage Collection ausführt. Der bekannte Graf aus der Sun-Dokumentation verdeutlicht dieses Problem.

Overhead bei Multiprozessormaschinen

Ein Garbage Collector, der 1% Overhead bei einer CPU verursacht, löst mehr als 20% Overhead bei 32 CPUs aus, da diese nicht arbeiten können, während der Garbage Collector seine Arbeit verrichtet. Natürlich wird schnell klar, wie man das Problem mit den Pausenzeiten lösen könnte: Man müsste einfach den Garbage-Collector-Algorithmus parallelisieren. Bei Multiprozessormaschinen verrichtet dann nicht mehr ein Thread die Arbeit, sondern es werden mehrere GC-Threads parallel eingesetzt, um den Müll schneller zu beseitigen. Zudem wurden nebenläufige Garbage Collectoren entwickelt, die parallel zur „normalen“ Arbeit den Speicher analysierten und Müll vormerkten, sodass die Pausenzeiten reduziert werden. Compacting-Phasen und das Ermitteln der GC Roots können allerdings nicht nebenläufig passieren. Die Kombination aus Mark-and-Sweep, Compacting, Parallelität und Nebenläufigkeit ist bis heute im Einsatz und die Basis der meisten angebotenen Garbage Collectoren. Zusätzlich wurde aber noch der Copying Garbage Collector entwickelt. Dieser räumt den Müll nicht weg, sondern kopiert die lebenden Objekte in einen anderen Bereich des Heaps. Dazu teilt er den Heap in zwei gleich große Bereiche ein. Während einer GC-Phase werden die lebenden Objekte von einem Bereich an den Anfang des anderen Bereichs kopiert. Der erste Bereich gilt dann als bereinigt. Fragmentation tritt nicht auf. Diese Form des Garbage Collectors eignet sich vor allem dann sehr gut, wenn es sehr viel Müll zu beseitigen gibt.

Viele Köche …

Sehr oft wird bei der Erklärung der Garbage Collection so getan, als gäbe es nur eine JVM (nämlich die Sun JVM) beziehungsweise als würden die Implementierungen der verschiedenen Hersteller identisch sein. Dem ist allerdings nicht so. Wir wollen uns deshalb im ersten Schritt die am weitesten verbreiteten JVMs ansehen. Die wohl bekannteste ist die Sun JVM. Sie verfügt seit der Version 1.4 über die Java HotSpot VM [1] und eine Generational Garbage Collection. Der Heap wird hierbei in mehrere Bereiche aufgeteilt.

Aufbau des JVM Heaps

Die Young Generation selbst teilt sich in den Eden Space und die Survivor Spaces auf. Objekte werden im Eden Space erzeugt und im Laufe von Garbage Collections dann in den Survivor und später die Tenured Generation verlagert, sofern noch Referenzen darauf existieren. Klassen werden bei Sun in die so genannte Permanent Generation geladen und liegen somit nicht auf dem „normalen“ Heap.
Die IBM JVM hat sehr lange am klassischen Marc-and-Sweep-Algorithmus festgehalten, bietet seit Java 5 aber sowohl die Möglichkeit, einen kontinuierlichen Heap oder ebenfalls einen Generational Heap zu verwenden. Der kontinuierliche Heap wird bei Anwendungen mit kleinen Heap (100 MB) empfohlen. Das Layout des Generational Heap von IBM ist ebenfalls in der Abbildung zu sehen. Das Nursery ist das Equivalent zur Young Generation des Sun Heaps und das Tenured Space ist equivalent zum Tenured Space der Sun JVM. Das Nursery Space teilt sich hier in zwei gleich große Teile auf, den Allocate Space und den Survivor Space. Sie werden bei einer Garbage Collection geswitcht.
Die Oracle JRockit JVM verfügt wie die IBM JVM über die Möglichkeit, einen kontinuierlichen Heap oder einen Generational Heap zu verwenden. Auch bei Oracle wird zwischen einer Young Space und Old Space unterschieden. Im Gegensatz zu den anderen Implementierungen besitzt die JRockit JVM keinen Survior Space. Stattdessen verfügt sie über eine so genannte Keep Area, in der die zuletzt allokierten Objekte enthalten sind. Diese werden im Fall einer Garbage Collection nicht in die Old Generation verschoben.

Visual GC Screenshot

Generation Y(oung)

Allen JVMs ist also gemein, dass sie über einen Generational Heap verfügen. Die Motivation dafür ist die Tatsache, dass die meisten Objekte über eine kurze Lebenszeit verfügen. Für diese kurzlebigen Objekte wird also die Garbage Collection optimiert, was durch effizientes Freigeben nicht mehr benötigter Objekte erreicht wird. In den meisten Fällen kommt deshalb eine Variante des Copying Collectors zum Einsatz. Bei der Sun JVM werden in der Young Generation Objekte ständig zwischen Survivor Space I und Survivor Space II hin und her, beziehungsweise aus dem Eden Space kopiert (Minor Collection) , in der Hoffnung, dass die Objekte die nächste Aufräumaktion nicht überleben. Zu jedem Objekt wird erfasst, wie oft es bereits hin und her kopiert wurde – ist ein konfiguriertes Limit erreicht, gilt das Objekt als besonders zäh und wird in die Tenured Generation verfrachtet, wo es dann mithilfe eines klassischen Marc-and-Sweep-Algorithmus (Major Collection) verarbeitet wird – natürlich mit optimierten parallelen, seriellen oder nebenläufigen Algorithmen. Allerdings kann nicht immer gewartet werden, bis die maximale Anzahl von Kopieraktionen für ein Objekt in der Young Generation erreicht wurde, um es in die Tenured Generation zu kopieren. Ist kein Platz mehr in den Survivor Spaces oder ist ein Objekt sogar größer als eine Survivor Space, wird es direkt in die Tenured Generation kopiert. Zusätzlich erlauben es die aktuellen JVMs auch, Objekte in speziellen Heap-Segmenten zu allokieren, die nur einem Thread zur Verfügung stehen. Das vermeidet einerseits Synchronisation beim Heap-Zugriff und erlaubt effizientes Aufräumen von Objekten, die nur innerhalb eines Threads verwendet werden. Dieser Speicher wird „Thread Local Heap“ bei IBM, „Thread Local Area“ bei JRockit genannt. Sun nennt dieses Feature „Thread-Local Object Allocation“. Es sollte nicht mit Thread-local-Variablen in Java verwechselt werden. Gerade bei Anwendungen die kurzlaufende Transaktionen ausführen, führt ein Generational Heap zu besserem Garbage-Collection-Verhalten.

Generation Sizing – auf die Größe kommt es an

Die Größenverhältnisse zwischen Young und Tenured Generation sind also entscheidend für die Performance. Erzeugt eine Anwendung sehr viel kurzlebigen Müll und ist die Young Generation zu klein, so wird es sehr schnell passieren, dass kurzlebige Objekte in der Tenured Generation landen. Dort kommt es dann zu gehäuften Major Collections, die auf die Performance drücken können. Es empfiehlt sich daher, gerade bei Webanwendungen die Standardeinstellungen der Generationen zu kontrollieren und wenn nötig anzupassen, um die Performance zu verbessern. Bei der Sun JVM gibt beispielsweise der Parameter –XX:NewRation=3 ein Verhältnis von 1:3 für Young:Tenured Generation an – d. h. die Young Generation belegt einen Viertel des mit –Xmx angegebenen maximalen Heaps. Je nach JVM (Server oder Client) und Rechnerarchitektur kann das eine deutliche Vergrößerung gegenüber der Standardeinstellung bedeuten und nicht selten eine erhebliche Reduktion des GC Overheads. Aufpassen muss man allerdings, dass die Tenured Generation nicht zu klein wird – gerade wenn Caches und Sessions groß sind und schon einen großen Teil des Speichers belegen. Auch dann kommt es zu erhöhter GC-Aktivität, die durch den hohen Füllgrad mit lebenden Objekten auch noch deutlich länger für eine Mark oder Compaction- Phase brauchen. Webanwendungen und transaktionale Anwendungen im Allgemeinen profitieren von der Verwendung des Thread Local Heaps. Während diese bei IBM und Oracle standardmäßig aktiviert sind, müssen sie bei der Sun JVM mit -XX:+UseTLAB (vor Java 5 mit useTLE) aktiviert werden. Der G1 Garbage Collector verwendet diese standardmäßig.

OutOfMemory trotz freien Speichers?

Wie kann es sein, dass man einen OutOfMemory-Fehler bekommt, obwohl man scheinbar noch genug Speicher frei hat? Eine Möglichkeit wurde bereits am Anfang des Artikels erwähnt: Durch Fragmentierung wurde der Speicher so stark zerteilt, dass nicht mehr genug am Stück verfügbar ist und die Anforderungen für eine Objektallokation nicht erfüllt werden können. Bei einigen Implementierungen (z. B. Sun) kann aber auch ein zu großer Garbage Collection Overhead dazu führen, dass ein OutOfMemory-Fehler geworfen wird. Diese Funktion soll verhindern, dass eine Applikation mit zu wenig verfügbarem Heap weiterläuft, mehr als 90 % mit Müllbereinigung zubringt und dabei weniger als 2 % des Speichers aufräumen kann. In dieser Situation sollte man über ein Resizing des Heaps nachdenken oder mithilfe eines Heapdumps analysieren, warum und womit der Speicher belegt ist. Wenn nötig müssen Caches verkleinert oder die Session aufgeräumt werden.

Überwachung der Garbage Collection

Den Heap und den Garbage Collector zu überwachen ist unbedingt notwendig, um die richtige Einstellung für die Generationen und die Heap-Größe im Ganzen zu ermitteln. Im Prinzip ist eine laufende Anwendung die beste Möglichkeit zu validieren, ob man die Parameter anpassen muss oder nicht. Jeder JVM-Hersteller bringt seine eigenen Tools mit, wobei jconsole seit Java 5 ein Standardwerkzeug für den Heap und Garbage Collector über JMX ist. Sun bietet neben jconsole und jvisualvm auch ein Tool an, das Visual GC [2] heißt und kostenlos heruntergeladen werden kann.

Garbage Collector Verhalten und Antwortzeiten

Der RMI Garbage Collector

RMI nutzt einen verteilten Garbage-Collector-Algorithmus, um einem ganz natürlichen Problem von verteilten Anwendungen gerecht zu werden. Ein Remote-Objekt auf dem Server weiß im Prinzip nicht, ob es von seinen Clients noch referenziert wird oder nicht. Deshalb erweitert RMI den lokalen Garbage-Collector-Algorithmus um einen verteilten Garbage Collector (Distributed Garbage Collector – DGC), damit bei verteilten Anwendungen der GC auch auf der Serverseite die Objekte wegräumen kann, die nicht mehr referenziert werden. Aus diesem Grund führt RMI in regelmäßigen Abständen einen GC durch, wenn das nicht schon auf Grund regulärer GC-Tätigkeiten passiert ist. Bis Java 6 war das Intervall auf 60 Sekunden gesetzt, was häufig zu Problemen geführt hat. Gerade bei großen und gefüllten Heaps verursachte dieses RMI-GC-Intervall alle 60 Sekunden eine Major Collection, was häufig zu einem enormen Overhead geführt hat. Mit dem Parameter sun.rmi.dgc.server.gcInterval kann das Intervall konfiguriert werden. Ein zu langes Intervall ist allerdings auch gefährlich, weil das dazu führen kann, dass Remote-Objekte nicht dereferenziert werden können, obwohl keine Referenzen mehr auf das Objekt gehalten werden. Nur erfährt der Server nichts davon, da die Referenz nicht abgeräumt wird und nur dann eine entsprechende Nachricht des DGC geschickt wird. Im Extremfall kann das auch zu einem OutOfMemory führen.

Die Entwicklung geht weiter: G1

G1 heißt nicht nur das Google Handy, sondern auch ein neuer Garbage-Colletion-Algorithmus, der in Sun Java 7 Einzug finden soll und für den es mit dem letzten Servicerelease auch schon Backports nach Java 6 gibt. Der Algorithmus des G1 wird „Garbage First“ genannt. Er soll erhebliche Performanceverbesserungen bringen, auch wenn das dahintersteckende Prinzip wiederum recht einfach ist. Der Heap wird in eine Reihe von fixen Teilbereichen zerlegt. Zu jedem Teilbereich wird eine Liste mit Referenzen von Objekten gepflegt („Remember Set“), die noch Objekte in dem Bereich referenzieren. Jeder Thread informiert dann den GC, sollte er eine Referenz verändern, die eine Anpassung der „Remember Sets“ verursachen könnte. Wird eine Garbage Collection angefordert, werden dann zuerst die Bereiche bereinigt, die am meisten Müll beinhalten („Garbage first“). Im besten (und wahrscheinlich nicht seltenen Fall) ist ein Bereich komplett voll mit Müll – dann kann der Bereich einfach als frei „definiert“ werden – ohne lästigen Mark-and-Sweep-Algorithmus. Zusätzlich kann man beim G1 Collector Ziele – beispielsweise den Overhead oder die Pausenzeiten definieren. Der Collector säubert dann immer nur so viele Bereiche, wie er in dem vorgegebenen Intervall schaffen kann.

Fazit

GC-Algorithmen zu verstehen ist wichtig, um die Performance von Anwendungen vorhersagen und optimieren zu können. Seit der ersten JVM haben sich die Algorithmen stetig weiterentwickelt und verbessert. Je nach Hersteller und Version kann man daher für seine Anwendung alleine durch GC-Optimierungen Performance und Skalierbarkeit gewinnen, und das, ohne Code zu verändern. Der Optimierung der Garbage Collection sollte ähnliche Bedeutung wie der Optimierung einer Datenbank zugemessen werden. Das bedeutet, dass es einen Verantwortlichen im Projekt geben muss, der die spezifischen Eigenschaften der verwendeten JVM kennt. Zudem sind entsprechende Lasttests notwendig, um verschiedene Konfigurationen zu testen. Der damit verbundene Aufwand steht auf jeden Fall dafür. In einigen Fällen konnten mithilfe einer Optimierung der Garbage-Collection-Strategie die Antwortzeiten um bis zu 25 % verbessert werden. Moderne Diagnosewerkzeuge erlauben zudem den Anteil der Garbage Collection an den Responsezeiten unmittelbar zu ermitteln.

Links & Literatur

[1] Sun Java HotSpot VM: http://java.sun.com/javase/technologies/hotspot/
[2] VisualGC: http://java.sun.com/performance/jvmstat/
[3] The Truth about Garbage Collection: http://java.sun.com/docs/books/performance/1st_edition/html/JPAppGC.fm.html
[4] Oracle JRockit Tuning the Memory Management:
http://edocs.bea.com/jrockit/geninfo/diagnos/memman.html
[5] Sun Java System Application Server Enterprise, Edition 8.1 2005Q1, Performance Tuning Guide
[6] Chapter 4, Tuning the Java Runtime System:
http://docs.sun.com/source/819-0084/pt_tuningjava.html#wp56995

Vollständiger Artikel