Einen Vorteil den Programmiersprachen mit eigener Laufzeitumgebung gegenüber denen ohne haben ist die automatische Speicherbereinigung. Die Garbage Collection sorgt dafür, dass nicht mehr benötigte Objekte aus dem Heap gelöscht werden und dadurch Speicher mit neuem Garbage gefüllt werden kann. Kein nerviges deleten mehr. Das ist natürlich super für jeden Entwickler. Doch wie genau funktioniert das ganze eigentlich? Wie kann die Garbage Collection optimiert werden und was bedeutet es für die GC wenn ich -Xmx3g setze um mein Programm wieder zum laufen zu bringen? Diese und andere Fragen versuche ich im folgenden für die Java VM zu beantworten.
Die JVM
Die Java Virtual Machine (JVM) ist wie eine Schnittstelle zwischen dem Betriebssystem und dem eigentlichen Java-Programm zu verstehen. Programme, welche für Windows erstellt wurden laufen im allgemeinen nicht auf Linux und umgekehrt. Dies hängt damit zusammen, dass die Instruktionen, die Ressourcenverwaltung und viele andere Dinge beider Betriebssysteme unterschiedlich sind. Um Programme dennoch auf jedem Betriebssystem ausführen zu können wurde in Java die JVM eingeführt. Die JVM nimmt die Instruktionen des Programms und wandelt sie in Instruktionen um, welche das Betriebssystem auf dem die JVM installiert ist versteht. Das bedeutet zwar, dass für jedes Betriebssystem eine eigene JVM benötigt wird, dafür jedoch alle in Java geschriebenen Programme lauffähig sind. Insofern eine passende JVM existiert können Java-Programme somit auf allen erdenklichen Geräten ausgeführt werden. Das reicht von Desktop-PCs, über Mobiltelefone, bis hin zu intelligenten Kühlschränken. Das erste System, welches eine JVM besaß war ein PDA.
Bevor die gewünschte Instruktion des Entwicklers jedoch ausgeführt werden kann ist eine ganze Reihe an Schritten notwendig; Der vom Entwickler geschriebene Java-Code wird zuerst einmal in Class-Dateien umgewandelt und optimiert. Das wird als linking bezeichnet. Dabei wird die Fehlerfreiheit und Konsistenz des Programms überprüft. Die gelinkten Class-Dateien werden anschließend gepackt, damit das fertige Java-Programm ausgeliefert werden kann.
Beim Ausführen des Programms wird der Class Loader aktiv. Er lädt die Class-Dateien und schreibt sie in den Methoden-Bereich der Runtime Data Area. Die RDA ist eine virtuelle Umgebung für das auszuführende Programm. In der RDA gibt es verschiedene Bereiche, welche zur Ausführung des Programms nötig sind. Es gibt Bereiche für Methoden, Threads und Programmzähler. Der wichtigste Bereich für uns ist momentan jedoch der Heap-Bereich. Dort werden dynamisch erstellte Objekte gespeichert. Der Heap ist der Bereich, auf dem die Garbage-Collection aktiv wird.
Der Garbace Collector selber ist ein Subsystem der Execution Engine. Neben dem GC befindet sich auf der Execution Engine noch der Just-in-time-Compiler. Die Aufgabe des JIT-Compilers ist es den Code aus den Class-Dateien in Maschinensprache zu kompilieren. Dies geschieht dynamisch zur Laufzeit des Programms und individuell für die aktuelle Umgebung der JVM. Für Windows erstellt der JIT-Compiler andere Instruktionen als für Linux. Je nachdem welche benötigt werden.
Die Garbage Collection
Wird ein neues Objekt erstellt so wird dies im Heap abgelegt. Je mehr Objekte im Laufe der Zeit erstellt werden, desto größer wird der Heap. Die Aufgabe der Garbage Collection ist es nun herauszufinden welche Objekte gelöscht werden können und diese zu beseitigen. Für den Entwickler soll dieser Prozess möglichst transparent dargestellt werden. Wird ein Objekt nicht mehr benötigt, wenn beispielsweise eine Methode verlassen wird, dann muss der GC dafür sorgen, dass dieses Objekt früher oder später entfernt wird, damit der Speicher für neue Objekte frei gegeben werden kann.
Ganz so einfach, wie hier beschrieben ist es jedoch nicht. Woher weiß beispielsweise der Garbage Collector, dass ein Objekt nicht mehr im Einsatz ist? Wird eine Methode verlassen, so könnte das darin erstelle Objekt durchaus im Rückgabewert der Methode referenziert werden. Gelöscht werden darf es in diesem Fall nicht.
Um herauszufinden welches Objekt noch benötigt wird führt der GC eine Markierungs-Phase (marking) durch. Dabei werden alle benötigten Objekte markiert. Ausgehend vom Root-Objekt wird geschaut auf welche Objekte Referenzen bestehen. In Java ist alles eine Klasse. In Java ist die zentrale Klasse, also ein Root-Objekt, die Main-Klasse. Ist ein Objekt nicht über die Main-Klasse erreichbar, so wird dieses nicht markiert und kann gelöscht werden.
Das ganze hört sich simpel an, es gibt jedoch eine Reihe von Problemen; Das erste ist die Responsiveness des Programms. Handelt es sich um ein großes Programm, welches sehr speicherlastig ist, so dauert das markieren der Objekte eine erhebliche Zeit. Der Programmfluss muss in dieser Zeit gestoppt werden, da es beim markieren sonst zu Fehlern kommen würde, wenn ein Objekt beispielsweise neu referenziert wird. Dies hat zur Folge, dass während der Markierungs-Phase das gesamte Programm steht. Der Benutzer ist nicht mehr in der Lage das Programm zu bedienen. Vor 20 Jahren war das vielleicht noch akzeptabel, doch in heutigen Zeiten möchte niemand mehr länger als ein paar Millisekunden auf einen Mausklick warten.
Das zweite Problem ist der Durchsatz. Durchsatz steht der Responsiveness gegenüber. Es gibt Programme, da zählt nur die Leistung. Wie viel Daten können in möglichst kurzer Zeit abgearbeitet werden. Diesen Programmen macht es nichts aus, auch einmal ein paar Sekunden für die Garbage Collection zu hängen, wenn sie im Anschluss wieder ungestört unter Vollast weiterarbeiten können, bis der Speicher erneut bis oben hin voll ist. Eine große Belastung für den Durchsatz stellt beispielsweise das Kopieren von Objekten während der Garbage Collection dar.
Eine gute Garbage Collection muss für beide Szenarios eine gute Lösung finden.
Die Speicherverwaltung
Bevor wir auf die Algorithmen der GC zu sprechen kommen muss die Speicherverwaltung von Objekten besprochen werden. Die Objekte im Heap werden in Generationen aufgeteilt. Jede Generation richtet sich nach dem Alter des Objekts und wird unterschiedlich vom Garbage Collector behandelt. Durch Analysen konnte festgestellt werden, dass die meisten Objekte kurz nach ihrer Entstehung schon wieder unnötig geworden sind und gelöscht werden können. Das bedeutet, dass es viel effizienter ist bei jungen Objekten nach Abfall zu suchen, als bei alten.
In Java gibt es drei Generationen, in welche Objekte eingeordnet werden. Die junge, die alte und die permanente Generation. Die junge Generation beinhaltet neu erstellte Objekte. Auf der jungen Generation wird häufig eine Garbage Collection ausgeführt. Die Objekte sind meist sehr klein, weswegen dieser Schritt sehr performant durchgeführt werden kann. Dieser Schritt wird minor GC genannt. Auf die Bedeutung von Eden und den Survivor-Bereiche kommen wir später zurück. Die minimale und maximale Größe der jungen Generation kann über die Parameter -XX:NewSize und -XX:MaxNewSize festgelegt werden.
Schaffen es Objekte eine gewisse Zeit zu überlegen wandern sie in die alte Generation über. Auf der alten Generation findet nur gelegentlich eine Garbage Collection statt. Dieser Schritt ist meist aufwändiger, hat aber auch den Vorteil, dass durch die tendenzielle Größe der Objekte meist viel Speicher freigegeben werden kann. Dieser Schritt wird major GC genannt. Die Größe der alten Generation kann nicht direkt bestimmt werden. Mit -Xms und -Xmx kann jedoch die minimale und maximale zusammengefaste Größe von der jungen und alten Generation eingestellt werden. Die Größe der alten Generation ergibt sich somit als Differenz zwischen -Xmx und -XX:MaxNewSize. Eine weitere Möglichkeit die Größe der jungen und alten Generation anzugeben ist -XX:NewRatio. Dieser Parameter legt das Verhältnis zwischen junger und alter Generation fest. Wurde beispielsweise -XX:NewRatio auf 3 gestellt, so ist das Verhältnis zwischen junger und alter Generation 1:3. Je größer der Wert wird, desto größer wird die alte Generation im Verhältnis zur jungen.
Die permanente Generation speichert Meta-Informationen zu Klassen und Methoden. Aufgeräumt muss in der permanenten Generation nur selten werden. Dies lohnt sich vor allem dann, wenn viele Bibliotheken verwendet werden. Eine Garbage Collection in der permanenten Generation erfolgt bei einem full GC. Die Größe dieser Region kann über die Parameter -XX:PermSize und -XX:MaxPermSize festgelegt werden.
Ablauf der Garbage Collection
Nachdem nun die Grundlagen geklärt sind können wir auf den generellen Ablauf der Garbage Collection kommen.
Objekte anlegen
Wir ein neues Objekt mit „new“ erstellt, so wird dessen Instanz auf dem Heap abgelegt. Hierfür ist der Eden-Bereich gedacht. Die Survivor-Bereiche bleiben zunächst leer. Auf ihnen kann kein Objekt direkt instantiiert werden. Sobald der Eden-Bereich sich füllt wird eine minor GC getriggert und der Garbage Collector kommt zum ersten Mal zum Einsatz.
Objekte löschen
Der Garbage Collector detektiert nun alle referenzierten Objekte und markiert diese. Im Anschluss werden alle markierten Objekte in einem der beiden Survivor-Bereiche abgelegt. Alle übrigen Objekte, welche sich nun noch im Eden-Bereich befinden werden gelöscht. Der Eden-Bereich ist somit bereinigt und kann neue Objekte aufnehmen. Während der GC werden alle Threads angehalten. Dies wird auch als Stop the World Event bezeichnet.
In jeder darauf folgenden minor GC wird neben dem Eden auch der bereits gefüllte Survivor-Bereich berücksichtigt. Die Objekte wandern also von einem in den anderen Survivor-Bereich, falls diese noch verwendet werden.
Objekten altern
Jedesmal, wenn ein Objekt in einen der beiden Survivor-Bereiche kopiert wird erhöht sich ein interner Zähler. Dies geschieht sowohl für Objekte, welche aus dem Eden-Bereich kommen wie auch für Objekte aus dem anderen Survivor-Bereich. Im Laufe der Zeit wird der Zähler damit immer weiter hoch gezählt.
Nachdem Objekte ein bestimmtes Alter erreicht haben, oder kein Platz mehr in einem der Survivor-Bereiche existiert werden diese Objekte in den Tenured-Bereich kopiert. Dieser Vorgang wird als Promotion bezeichnet. Objekte aus dem Tenured-Bereich werden während einer minor GC nicht berücksichtigt.
Java Collector-Algorithmen
In Java gibt es eine Reihe von Algorithmen, welche für die Garbage Collection verantwortlich sein können. Jeder Collector-Algorithmus hat dabei seine Vor- und Nachteile. Es lohnt sich daher einen groben Überblick über die einzelnen Algorithmen zu haben.
Serial Collector
Der Serial Collector ist der standardmäßige Garbage Collector für Java-Anwendungen. Serial wird er deswegen bezeichnet, weil nur ein einziger Thread für das abarbeiten der Garbage Collection benutzt wird, der alle Objekte nacheinander durcharbeitet. Um diesen zu aktivieren kann das Kommandozeilenargument -XX:+UseSerialGC benutz werden.
Parallel Collector
Der Parallel Collector verwendet mehrere Threads um den Speicher in der jungen Generation zu bereinigen. Für die alte Generation gibt es keinen Unterschied zum Serial Collector. Ebenso wird bei einer Maschine mit nur einem Prozessor der Serial Collector verwendet. Standardmäßig wird bei bis zu 8 Kernen pro Kern ein Thread für die Garbage Collection gestartet. Dieser Wert kann jedoch mit -XX:ParallelGCThreads geändert werden. Aktiviert werden kann der Parallel Collector mit dem Kommandozeilenargument -XX:+UseParallelGC.
Ein weiterer Vorteil des Parallel Collectors ist der, dass die Bereinigung von der jungen und alten Generation gleichzeitig geschehen kann. Die vielen parallelen Threads sorgen für einen enormen Overhead, der sich für kleine Desktop oder Mobile-Anwendungen meist nicht lohnt und zu langen Pausezeiten der Anwendung führt. Der Parallel Collector ist daher bevorzugt auf Server-Umgebungen zu verwenden.
Um die Pausezeiten der GC zu verringern kann die gewünschte Pausezeit mit -XX:MaxGCPauseMillis angegeben werden. Dies ist jedoch nur ein Richtwert. Der Garbage Collector versucht daraufhin seine Parameter so einzustellen, dass die gewünschte Zeit erreicht wird. Eine Garantie gibt es jedoch nicht.
Damit jeder Thread seine Objekte von der jungen in die alte Generation kopieren kann benötigt er einen reservierten Speicherbereich im Tenured-Speicher. Dabei wird nicht nur mehr Speicher benötigt, es enstehen auch fragmentierte Bereiche. Wird ein Objekt beispielsweise gelöscht, so entsteht innerhalb des Speichers eine Lücke. Besitzt der Speicher nun viele dieser Lücken, so kann kein Objekt, das größer ist als so eine Lücke angelegt werden, obwohl noch Speicher frei ist. Aus diesem Grund werden die Lücken nach einer full GC bereinigt, indem alle Objekte quasi zusammenrücken. Dieser Schritt wird kompaktieren genannt.
Der Parallel Collector wird auch Throughput Collector genannt, da er auf Durchsatz getrimmt ist und nicht auf Responsiveness. Dies sollte man immer im Hinterkopf behalten, wenn man sich für den Parallel Collector entscheidet.
Concurrent Mark Sweep Collector
Der Concurrent Mark Sweep Collector ist darauf ausgelegt möglichst kurze Pausezeiten bei der GC zu erreichen. Dies geschieht, indem er ein oder mehrere Threads während der Programmausführung laufen lässt. Dadurch lassen sich Pausen zwar verringern, der Overhead zur gesamten Programmausführung steigt jedoch überproportional an. Wie auch bei dem Parallel Collector können sich minor und major Garbage Collections überschneiden.
Wie bereits erwähnt laufen beim CMS Collector mehrere Threads gleichzeitig zur Programmausführung. Diese sorgen dafür, dass die alte Generation bereinigt wird. Füllt sich die alte Generation schneller mit Objekten, als diese abgebaut werden können wird eine full GC ausgeführt, was den Programmfluss anhält. Bei diesem Schritt kann es vorkommen, dass eine OutOfMemoryException geworfen wird. Dies ist dann der Fall, wenn 98% der Gesamtzeit für die Garbage Collection benötigt wird und dabei weniger als 2% des Speichers bereinigt wurden. Letztendlich wird damit verhindert, dass Programme nur noch am rechnen sind, obwohl sie schon längst nicht mehr genug Speicherplatz besitzen. Abgeschalten werden kann dieses Verhalten mit -XX:UseGCOverheadLimit.
Da die major GC parallel zu anderen Threads läuft kann es dazu kommen, dass es Objekte gibt, die während der Ausführung der Garbage Collection keine Referenzen mehr besitzen, vom GC jedoch noch als referenziert markiert werden. Selbst nach einer Garbage Collection befinden sich im Heap dann nicht mehr benötigte Objekte. Ihre Größe kann im Schnitt bis zu 20% des Tenured-Bereichs betragen. Dies bedeutet, dass das Programm mehr Speicher benötigt oder aber die GC öfters gestartet werden muss.
Garbage-First Collector
Der Garbage-First Collector (G1) ist für Serverarchitekturen gedacht, welche viel Arbeitsspeicher besitzen. Das Verfahren des G1 unterscheidet sich grundlegend von den die wir bisher angesprochen haben.
Der Heap ist zunächst einmal in gleich große Regionen aufgeteilt. Der G1 markiert nun wie gewohnt alle Objekte, welche noch referenziert werden. Im Anschluss wird ermittelt in welcher der Regionen sich die wenigsten noch referenzierten Objekte befinden. Diese Regionen werden als erstes bereinigt. Dieser Vorgang gab dem Garbage-First Collector seinen Namen. Während der Bereinigung werden alle noch referenzierten Objekte in eine gemeinsame Region verschoben. Die Objekte befinden sich so kompaktiert in einer Region. Der G1 benötigt zwar mehr Speicher, erzeugt dafür jedoch keine Fragmentierung. Während die Objekte kopiert werden muss der Programmfluss angehalten werden.
Nun stellt sich die Frage, warum zuerst die ergiebigsten Regionen abgearbeitet werden. Dies hängt damit zusammen, das dem Collector eine Zeitspanne mittels -XX:MaxGCPauseMillis vorgegeben werden kann. In dieser Zeit soll möglichst viel Speicher bereinigt werden. Am effizientesten geschieht dies nun einmal dadurch, dass die Regionen bereinigt werden, welche am wenigsten referenzierte Objekte aufweisen.
Der G1 Collector soll auf kurz oder lang den CMS Collector ersetzen. Er besitzt eine Reihe von Vorteilen wie etwa das automatische Kompaktieren und sollte daher dem CMS Collector vorgezogen werden.
Referenzen
http://www.oracle.com/technetwork/tutorials/tutorials-1876574.html
https://www.hackerearth.com/practice/notes/runtime-data-areas-of-java/
http://www.oracle.com/webfolder/technetwork/tutorials/obe/java/gc01/index.htm