Abfalltrennung: Den .NET Garbage Collector richtig nutzen

Einer der großen Vorteile von .NET (im Vergleich zum guten, alten C++ zum Beispiel) ist ja bekanntlich, dass der Entwickler nicht mehr daran denken muss, neu angelegte Objekte auch wieder zu löschen. Gleichzeitig ist das aber auch eines der größten Missverständnisse: Denn obwohl man sich nun sämtliche delete – Aufrufe sparen kann ist bei Architektur und Design des Codes doch ein gewisses Maß an Sorgfalt gefordert, damit die automatische Speicherbereinigung durch die .NET Garbage Collection (deutsch: Müllabfuhr) auch funktioniert wie erhofft.

Mülltrennung
Original uploaded to flicker by ♥ maggie

Für alle, die sich noch nicht weiter mit der Funktionsweise der Garbage Collection befasst haben, hier noch einmal eine kurze Zusammenfassung selbiger:

Der Garbage Collector sucht regelmäßig auf dem Heap nach Objekten, die nicht mehr benutzt werden und gibt deren Speicher wieder frei. Dass ein Objekt nicht mehr benutzt wird erkennt der Garbage Collector daran, dass keine Referenz, kein Zeiger mehr auf dieses Objekt zeigt (ähnlich wie das Reference Counting in COM) – was im Prinzip unter zwei Umständen nicht mehr der Fall ist: Entweder wird eine Variable explizit auf null gesetzt – oder ihr Gültigkeitsbereich (Scope) wird verlassen. Weiterhin arbeitet der .NET Garbage Collector generations-basierend. Das heißt: Es werden nicht einzelne Objekte (bzw. deren Speicher) frei gegeben, sondern immer Generationen von Objekten, d.h. Objekte, die eine bestimmte Lebenszeit hinter sich haben. Dabei gelten bestimmte Regeln:

  • Je neuer ein Objekt ist, desto kürzer wird seine voraussichtliche Lebenszeit sein. Analog gilt: Je älter ein Objekt bereits ist, desto länger wird seine voraussichtliche Lebenszeit sein.
  • Neue Objekte stehen meist in enger Beziehung zueinander und auf sie wird häufig zugegriffen.
  • Ein kleiner Teil des Heaps ist schneller bereinigt als der gesamte Heap.

Momentan verwaltet der Garbage Collector drei Generationen (0 bis 2). Generation 0 beinhaltet dabei alle gerade neu angelegten Objekte, also alle Objekte, die bisher noch nicht vom Garbage Collector inspiziert wurden, Generation 2 beinhaltet die ältesten Objekte. Der Algorithmus zur Bereinigung des Heaps sieht in etwa folgendermaßen aus:

  • Untersuche alle Objekte der Generation n darauf, ob sie nicht mehr benötigt werden.
  • Gebe alle nicht mehr benutzen Objekte frei.
  • Wenn n < 2: Verschiebe alle Objekte, die sich noch in Benutzung befinden, in Generation n + 1.

Dabei wird – basierend auf der oben beschriebene Regel bezüglich der Objektlebenszeit – die Bereinigung für die Generation 0 am häufigsten aufgerufen (z.B. einmal pro Sekunde), für Generation 2 am seltensten (z.B. alle 100 Sekunden).

Genauere Details zum Thema GarbageCollection finden sich z.B. in diesen beiden MSDN-Artikeln: Garbage Collection: Automatic Memory Management in the Microsoft .NET Framework und Garbage Collection – Part 2: Automatic Memory Management in the Microsoft .NET Framework.

Nach diesem Einblick in die Interna nun zum eigentlichen Thema: Wenn eine Anwendung komplexer wird stellen viele Entwickler plötzlich fest, dass das Speichermanagement nicht wie erwartet funktioniert – es wird zu wenig Speicher frei gegeben bzw. zu viel neuer Speicher belegt. Da Microsoft nun so nett war, dem Garbage Collector die Methode GC.Collect() zu spendieren, um die Speicherbereinigung manuell anzustoßen, wird dann oftmals davon Gebrauch gemacht.

Aber ist das denn sinnvoll? Verbessert der Aufruf von GC.Collect() tatsächlich die Speicherverwaltung?

Dazu ist zu sagen: Wer denkt, er müsse eine Speicherbereinigung erzwingen, hat etwas ziemlich falsch gemacht. Zunächst müsste man sich ja erst einmal fragen, welche Generation man denn bereinigen will: Erzwingt man die Bereinigung der Generation 0, so pfuscht man dem Garbage Collector dahin gehend ins Handwerk, dass er momentan zu wenig Objekte zu bereinigen hat, um ein gutes Ergebnis zu erzielen. Weiterhin erfolgt eine Generation-0-Bereinigung ja schon von Hause aus so oft, dass eine Erzwingung selbiger keinerlei messbare Vorteile bringen dürfte.

Gleiches gilt für eine Bereinigung der Generation 1, auch, wenn diese seltener erfolgt als bei Generation 0. Generation 1 ist in etwa für die Bereinigung von Objekten, die zu einer (Datenbank-)Transaktion gehören, konzipiert. Wenn viele Schritte in eine Transaktion gekapselt werden und gegebenenfalls auch noch mehrere Transaktionen parallel ablaufen, so existiert am Ende eine Menge temporärer Objekte, welche nun nicht mehr benötigt werden und deshalb bereinigt werden können. Ergeben sich Speicherprobleme im Zusammenhang mit Transaktionen sollte man überprüfen, ob nach dem Ende der Transaktion auch keine Referenzen mehr auf die temporären Objekte existieren; d.h. dass diese entweder nur im Rahmen der Transaktion gültig waren oder danach explizit auf null gesetzt wurden.

Nachdem die Erzwingung einer Bereinigung der Generationen 0 und 1 keinen Vorteil bringt bleibt nur noch Generation 2 übrig. Alle Objekte, welche die Bereinigung der Generationen 0 und 1 überlebt haben, landen in Generation 2. Generation-2-Bereinigungen sind allerdings um einiges aufwändiger als bei Generation 0 oder 1 und erfolgen daher auch wesentlich seltener. Nimmt die Anzahl von Objekten einer Anwendung, welche bis in die Generation 2 hinein leben, zu, so steigt der Prozentsatz der Laufzeit merklich an, den die Anwendung im Garbage Collector zubringt. Diesen Vorgang jetzt auch noch (regelmäßig) manuell anzustoßen erreicht eher das Gegenteil vom gewünschten Effekt: Die Leistung der Anwendung sinkt, da sie nun noch häufiger mit der performance-intensiven Bereinigung von Objekten der Generation 2 beschäftigt ist, anstatt sinnvolle Arbeit zu verrichten. Probleme mit Objekten, welche zu lange in Generation 2 überleben, können sich zum Beispiel in Webanwendungen, welche auf Webservices zugreifen, bemerkbar machen. Dauern derartige Zugriffe zu lange so altern die dafür notwendigen Objekte in Generation 2 hinein und überleben unverhältnismäßig lange.

Der Hauptgrund für Probleme bei der Speicherbereinigung liegt also darin, dass Objekte zu alt geworden sind und deshalb erst nach relativ langer Zeit bereinigt werden. Die Alterung richtet sich dabei nach den überlebten Bereinigungszyklen und deren Frequenz wiederum nach der Anzahl neu angelegter Objekte. Verringert man also die Zahl der “Neuanlegungen”, zum Beispiel durch Verzicht auf extrem kurzlebige, temporäre Objekte, so bleibt das gesamte System “jünger”. Das Ziel muss sein, alle Objekte möglichst früh sterben zu lassen, um den Speicherverbrauch stabil zu halten – was voraussetzt, dass der Code entsprechend gestaltet wird.

Kommentare sind geschlossen.