So funktioniert die automatische Speicherverwaltung

Veröffentlicht: 15. Dez 2001 | Aktualisiert: 09. Nov 2004

Von Jeffrey Richter

In dieser Folge des Artikels geht es um starke und schwache Objektreferenzen, die für die Verwaltung großer Objekte eingesetzt werden, und um Mechanismen, mit denen Sie die Leistung der Speicherbereinigung optimieren können.

Auf dieser Seite

Schwache Referenzen Schwache Referenzen
Funktionsweise der schwachen Referenzen Funktionsweise der schwachen Referenzen
Generationen Generationen
Leistungsoptimierung durch generationsbezogene GC Leistungsoptimierung durch generationsbezogene GC
Direkte Kontrolle mit System.GC Direkte Kontrolle mit System.GC
Leistung für Multithread-Anwendungen Leistung für Multithread-Anwendungen
Garbage Collection mit großen Objekten Garbage Collection mit großen Objekten
Überwachung der Garbage Collections Überwachung der Garbage Collections
Ressourcenanforderung Ressourcenanforderung
Der Sammelalgorithmus Der Sammelalgorithmus
Finalisierung Finalisierung
Hinter den Kulissen der Finalisierung Hinter den Kulissen der Finalisierung
Die Wiedergeburt Die Wiedergeburt
Erzwingen der Aufräumarbeiten Erzwingen der Aufräumarbeiten
Fazit Fazit

Diesen Artikel können Sie hier lesen dank freundlicher Unterstützung der Zeitschrift:

Bild05



Im letzten Heft "So funktioniert die automatische Speicherverwaltung (Teil 1)" habe ich Ihnen den Grund genannt, der für die Ausstattung eines Computersystems mit einer automatischen Speicherbereinigung (Garbage Collection) spricht: aus der Sicht des Anwendungsentwicklers wird die Speicherverwaltung wesentlich einfacher. Außerdem habe ich den Algorithmus skizziert, den die allen Sprachen gemeinsame Laufzeitschicht (common language runtime oder CLR) benutzt, und einige Details dieses Algorithmus erläutert. Zudem war die Rede davon, dass sich der Entwickler weiterhin selbst um die Verwaltung der Ressourcen und deren spätere Beseitigung kümmern muss, indem er die Methoden Finalize, Close und/oder Dispose implementiert. In diesem Artikel möchte ich nun meine Besprechung des Garbage-Collectors abschließen.

Ich beginne mit einem Angebot des Collectors, das "schwache Referenzen" (weak references) genannt wird. Damit können Sie den Druck auf die verwaltete Speicherhalde (managed heap) verringern, den große Objekte ausüben. Anschließend möchte ich darauf eingehen, wie der Garbage Collector zur Leistungsverbesserung "Generationen" einsetzt. Zum Abschluss möchte ich noch kurz einige andere Dinge besprechen, die der Garbage Collector zur Leistungsverbesserung anbietet, zum Beispiel für die Speicherbereinigung bei Anwendungen, in denen mehrere Threads laufen. Und es wird noch von den Leistungszählern die Rede sein, mit denen Sie das Echtzeitverhalten des Garbage Collectors überprüfen können.

Schwache Referenzen

Solange eine Wurzel (root) auf ein Objekt zeigt, kann das Objekt nicht eingesammelt werden, weil das Objekt noch für die Anwendung zugänglich ist. Zeigt also eine Wurzel auf ein Objekt, nennt man sie eine starke Referenz auf das Objekt. Nun kennt der Garbage Collector auch schwache Referenzen. Die schwache Referenz ermöglicht es dem Collector, das betreffende Objekt einzusammeln. Trotzdem kann die Anwendung noch auf das Objekt zugreifen. Ja, so ist es.

Was hier nach der Quadratur des Kreises klingt, ist letztlich nur eine Frage des richtigen Timings. Wenn es für ein bestimmtes Objekt nur schwache Referenzen gibt und der Carbage Collector läuft, fällt das Objekt ihm zum Opfer. Sollte die Anwendung anschließend versuchen, auf das Objekt zuzugreifen, schlägt der Zugriff fehl. Andererseits muss sich das Objekt sowieso erst einmal eine starke Referenz beschaffen, um auf ein Objekt zugreifen zu können, von dem es nur eine schwache Referenz hat. Erhält die Anwendung diese starke Referenz, bevor das Objekt dem Carbage Collector in die Finger gerät, kann der Sammler das Objekt nicht mitnehmen, weil es eine starke Referenz auf das Objekt gibt. Ich weiß - das klingt alles etwas verwirrend. Werfen Sie zur Klärung der Verhältnisse einen Blick auf den Code in Listing L1.

L1 Starke und schwache Referenzen

Void Method() { 
   Object o = new Object();    // erzeugt eine starke Referenz 
                               // auf das Objekt 
   // Lege eine starke Referenz auf ein WeakReference-Objekt an. 
   // Das WeakReference-Objekt verwaltet das Objekt. 
   WeakReference wr = new WeakReference(o); 
   o = null;    // Entferne nun die starke Referenz auf das Objekt 
   o = wr.Target; 
   if (o == null) { 
      // Der Carbage Collector war unterwegs und hat das Objekt mitgenommen 
   } else { 
      // Der Carbage Collector war noch nicht da und wir können auf das 
      // Objekt zugreifen. 
      // Arbeite mit o 
   } 
}


Wozu dienen solche schwachen Referenzen? Nun, es gibt so manche Datenstruktur, die sich zwar leicht zusammenstellen lässt, aber viel Speicherplatz belegt. Vielleicht arbeiten Sie zum Beispiel an einer Anwendung, die sämtliche Verzeichnisse und Dateien auf der Festplatte kennen muss. Es ist nicht sonderlich schwer, im laufenden Programm einen Baum zusammenzustellen, der diese Informationen enthält. Anschließend können Sie für die weiteren Arbeiten auf diesen Baum zurückgreifen und müssen die Informationen nicht mehr von der Platte einlesen. Je nach den Umständen wird das Programm dadurch beträchtlich schneller.

Allerdings hat diese Leistungssteigerung auch ihren Preis. Der Baum kann nämlich recht groß werden und viel Speicher beanspruchen. Sobald der Anwender zu einem anderen Teil des Programms übergeht, ist der Baum vielleicht gar nicht mehr erforderlich und daher nur noch im Weg. Sie könnten den Baum zwar löschen, aber falls der Anwender wieder auf die Idee kommt, zum ursprünglichen Programmpunkt zurückzukehren, in dem er den Baum braucht, müssten Sie den Baum dann neu einlesen und aufbauen. Unter solchen Umständen bietet eine schwache Referenz eine ebenso einfache wie effiziente Lösung.

Sobald der Anwender den ersten Teil des Programms verlässt, können Sie eine schwache Referenz auf den Baum anlegen und alle starken Referenzen entsorgen. Sollte der Speicherbedarf des Systems im zweiten Teil der Anwendung gering bleiben, wird der Garbage Collector den Baum nicht einsammeln. Falls der Anwender wieder in den ersten Teil des Programms zurückgehen, fordert das Programm wieder eine starke Referenz auf den Baum an. Hat es damit Erfolg, braucht es den Baum nicht neu aufzustellen und die Daten nicht erneut von der Festplatte einzulesen.

Der WeakReference-Typ hat zwei Konstruktoren:

WeakReference(Object target); 
WeakReference(Object target, Boolean trackResurrection); 


Der Zielparameter nennt das Objekt, das vom WeakReference-Objekt verwaltet werden soll. Der Parameter mit dem seltsamen Namen trackResurrection gibt an, ob sich das WeakReference-Objekt auch noch um das Objekt kümmern soll, nachdem seine Finalize-Methode aufgerufen wurde. Das übliche Argument für den Parameter trackResurrection ist false. Der erste Konstruktor legt sowieso eine WeakReference an, die keine Wiederbelebung des Objekts zulässt (Im ersten Teil dieser kleinen Artikelserie finden Sie eine ausführlichere Beschreibung zu diesem Wiederbelebungsmechanismus.).

Der Bequemlichkeit halber wird eine schwache Referenz, die keine Wiederbelebung des Objekts zulässt, eine "kurze" schwache Referenz genannt, während es sich bei der anderen, die sich wiederbeleben lässt, um eine lange schwache Referenz handelt. Wenn ein Typ keine Finalize-Methode anbietet, verhalten sich kurze und lange schwache Referenzen gleich. Vom Einsatz langer schwacher Referenzen wird übrigens dringendst abgeraten. Mit langen schwachen Referenzen können Sie auch einen Zombie wiederbeleben, also ein Objekt, das schon längst finalisiert wurde und dessen Zustand daher eigentlich schon undefiniert ist.

Normalerweise setzen Sie alle starken Referenzen auf ein bestimmtes Objekt auf null, sobald Sie eine schwache Referenz auf das Objekt angelegt haben. Wenn nämlich noch starke Referenzen übrig bleiben, kann der Garbage Collector das Objekt nicht einsammeln und die zusätzliche schwache Referenz wäre ziemlich sinnlos.

Um wieder auf das Objekt zugreifen zu können, müssen Sie die schwache Referenz in eine starke Referenz umwandeln. Zu diesem Zweck rufen Sie einfach das Target-Property des WeakReference-Objekts auf und weisen das Ergebnis einer der Programmwurzeln zu. Sollte das Target-Property nur eine null liefern, so hat der Garbage Collector das Objekt bereits eingesammelt. Ist das Ergebnis aber ungleich null, so ist die Wurzel eine starke Referenz auf das Objekt und der Code kann wie gewohnt mit dem Objekt arbeiten. Solange es die starke Referenz gibt, kann der Carbage Collector das Objekt nicht verschwinden lassen.

Funktionsweise der schwachen Referenzen

Schon aus der bisherigen Schilderung sollte deutlich geworden sein, dass sich schwache Referenzen nicht wie andere Objekttypen verhalten. Wenn es in Ihrer Anwendung nämlich eine Wurzel gibt, die auf ein Objekt verweist, und dieses Objekt wiederum auf ein weiteres Objekt verweist, so sind normalerweise beide Objekte erreichbar und der Garbage Collector kann sich den Speicher nicht zurückholen, der von beiden Objekten benutzt wird. Hat die Anwendung aber eine Wurzel, die sich auf ein WeakReference-Objekt bezieht, wird das Objekt, auf das sich das WeakReference-Objekt bezieht, nicht als erreichbares Objekt angesehen und kann als Müll eingesammelt werden.

Werfen wir zum besseren Verständnis wieder einen Blick auf die verwaltete Speicherhalde (managed heap). Die verwaltete Halde enthält zwei interne Datenstrukturen, deren einziger Zweck die Verwaltung der schwachen Referenzen ist, nämlich eine Tabelle für die kurzen schwachen Referenzen und eine Tabelle für die langen schwachen Referenzen. Diese beiden Tabellen enthalten einfach nur Zeiger auf Objekte, die auf der verwalteten Halde angelegt wurden.

Anfangs sind beide Tabellen leer. Wenn Sie ein WeakReference-Objekt anlegen, entsteht dieses Objekt aber nicht auf der verwalteten Halde. Statt dessen wird ein leerer Platz in einer der Tabellen mit den schwachen Referenzen belegt. Kurze schwache Referenzen benutzen natürlich die Tabelle für kurze schwache Referenzen und lange die für lange schwache Referenzen.

Sobald der leere Platz in der Tabelle gefunden ist, wird dort die Adresse des Objekts eingetragen, das nun verwaltet werden soll. Der Zeiger auf dieses Zielobjekt wurde ja an den WeakReference-Konstruktor übergeben und steht daher zur Verfügung. Der Rückgabewert vom new-Operator ist die Adresse des Tabellenplatzes in der WeakReference-Tabelle. Offensichtlich werden diese beiden Tabellen für die schwachen Referenzen nicht als Programmwurzeln betrachtet. Sonst wäre der Garbage Collector nämlich nicht in der Lage, sich die Objekte zu holen, die in den Tabellen verzeichnet sind.

Wenn eine Speicherbereinigung läuft, geschieht nun folgendes:

  • Der Garbage Collector stellt einen Graphen auf, in dem alle erreichbaren Objekte verzeichnet sind. Wie das funktioniert, wurde bereits im ersten Teil beschrieben.

  • Der Garbage Collector durchsucht die Tabelle mit den kurzen schwachen Referenzen. Sobald ein Zeiger in dieser Tabelle auf ein Objekt verweist, das nicht vom Graphen erfasst wurde, so wird das Objekt als nicht mehr erreichbar bewertet und der entsprechende Eintrag in der Tabelle der kurzen schwachen Referenzen wird auf null gesetzt.

  • Der Garbage Collector durchsucht die Finalisierungs-Warteschlange. Wenn ein Zeiger aus dieser Warteschlange auf ein Objekt verweist, das nicht zum Graphen gehört, so ist dieses Objekt nicht mehr erreichbar. Der entsprechende Zeiger wird von der Finalisierungs-Warteschlange in die freachable-Warteschlange verlegt. Außerdem wird das Objekt in den Graphen aufgenommen, weil es nun wieder als erreichbar gilt.

  • Der Garbage Collector durchsucht die Tabelle mit den langen schwachen Referenzen. Verweist ein Zeiger aus dieser Tabelle auf ein Objekt, das nicht zum Graphen gehört, so gilt dieses Objekt als nicht erreichbar und der Eintrag in der Tabelle wird auf null gesetzt (An dieser Stelle gehören auch die Objekte zum Graphen, die über die Zeiger in der freachable-Warteschlange zugänglich sind.).

  • Der Garbage Collector verdichtet den Speicher, damit die Löcher verschwinden, die von den nicht mehr erreichbaren Objekten hinterlassen wurden.

Sobald man den Ablauf dieses Vorgangs verstanden hat, ist auch die Funktionsweise der schwachen Referenzen leichter zu verstehen. Der Zugriff auf das Target-Property des WeakReference-Objekts veranlasst das System einfach nur, den entsprechenden Zeiger aus der zuständigen Tabelle mit den schwachen Referenzen zu liefern. Steht dort nur noch eine null, wurde das Objekt bereits entsorgt.

Eine kurze schwache Referenz kümmert sich nicht um die Wiederbelebung des verwalteten Objekts. Das bedeutet, dass der Garbage Collector den Zeiger in der Tabelle mit den kurzen schwachen Referenzen auf null setzt, sobald er festgestellt hat, dass das Objekt nicht mehr erreichbar ist. Sofern das Objekt eine Finalize-Methode hat, wurde diese Methode aber noch nicht aufgerufen und das Objekt existiert noch. Greift die Anwendung nun auf das Target-Property des WeakReference-Objekts zu, erhält sie nur eine null, obwohl es das Objekt eigentlich noch gibt.

Eine lange schwache Referenz kann das von ihr verwaltete Objekt wiederbeleben. Das bedeutet, dass der Garbage Collector den Zeiger in der Tabelle der langen schwachen Referenzen auf null setzt, sobald der vom Objekt belegte Speicher einforderbar geworden ist. Hat das Objekt eine Finalize-Methode, so muss die Finalize-Methode aufgerufen worden sein und das Objekt darf noch nicht wiederbelebt worden sein.

Generationen

Als ich mit der Arbeit in einer Umgebung begann, in der ein Carbage Collector regiert, waren die Vorbehalte bezüglich der Leistung natürlich groß. Immerhin hatte ich bereits 15 Jahre in C oder C++ programmiert und wusste, welcher Aufwand mit der Anforderung und Freigabe von Speicherblöcken verbunden ist. Deswegen gab es ja auch in jeder Windows-Version und in jeder Version der C-Laufzeitbibliothek Versuche, durch entsprechende Änderungen im Algorithmus der Speicherverwaltung eine Leistungsverbesserung zu erreichen.

Nun, wie die Entwickler von Windows und der C-Laufzeitbibliothek haben natürlich auch die GC-Entwickler versucht, die Leistung des Garbage Collectors zu verbessern. Eines der Merkmale dieses Garbage Collectors existiert überhaupt nur aus diesem Grund, nämlich zur Verbesserung der Leistung: Generationen. Und so gelten in einem generationsbezogenen Garbage Collector folgende Annahmen:

  • Je jünger ein Objekt ist, desto kürzer wird seine Lebensdauer sein.

  • Je älter ein Objekt bereits ist, desto länger wird es noch leben.

  • Neue Objekte neigen dazu, untereinander starke Beziehungen aufzubauen. Meistens erfolgt der Zugriff auf diese Objekte daher ungefähr zur selben Zeit.

  • Die Verdichtung eines Teils der Speicherhalde erfolgt schneller als die Verdichtung der gesamten Halde.

Natürlich wurde durch viele Studien belegt, dass diese Annahmen für die überwiegende Mehrheit aller Anwendungen gelten. Sehen wir uns also an, wie sich diese Annahmen auf die Implementierung des Garbage Collectors ausgewirkt haben.

Unmittelbar nach ihrer Initialisierung enthält die verwaltete Speicherhalde (managed heap) noch keine Objekte. Die Objekte, die nun auf der Halde landen, gehören zur "Generation 0" (Bild B1). Mit der Generation 0 sind junge Objekte gemeint, die noch nie vom Garbage Collector untersucht wurden.

Bild01

B1 Generation 0

Nun werden mehr und mehr Objekte auf der Halde angelegt. Die Halde bevölkert sich immer weiter und irgendwann wird eine Speicherbereinigung erforderlich. Während der Garbage Collector die Halde untersucht, stellt er einen Graphen mit den Müllobjekten auf (hier in lila) und einen mit den anderen Objekten. Alle Objekte, die nun die Speicherbereinigung überstanden haben, werden in den unteren Teil der Halde verschoben. Diese Objekte haben eine Sammlung überlebt, sind älter und stellen nun die Generation 1 dar (Bild B2).

Bild02

B2 Die Generationen 0 und 1

Die neuen Objekte, die anschließend auf der Speicherhalde angelegt werden, gehören alle zur neuen Generation 0. Ist der verfügbare Platz durch die neue Generation 0 belegt, wird wieder eine Garbage Collection durchgeführt. Diesmal werden die überlebenden Objekte aus der Generation 1 verdichtet. Sie stellen anschließend die Generation 2 dar (Bild B3). Auch die Überlebenden aus Generation 0 werden verdichtet und bilden dann die Generation 1. Generation 0 enthält nun keine Objekte mehr. Alle Objekte, die anschließend neu angelegt werden, gehören wieder zur Generation 0.

Bild03

B3 Die Generationen 0, 1 und 2

Derzeit ist die Generation 2 die älteste Generation, die vom Garbage Collector zusammengestellt wird. Sofern weitere Speicherbereinigungen erforderlich sind, bleiben die überlebenden Objekte aus der Generation 2 einfach in der Generation 2.

Leistungsoptimierung durch generationsbezogene GC

Wie schon erwähnt, dient die Einführung von Generationen einer Leistungsoptimierung. Ist die Halde wieder einmal voll und eine Speicherbereinigung unvermeidlich, steht es dem Garbage Collector frei, nur die Objekte der Generation 0 zu untersuchen und die Objekte aus den älteren Generationen zu ignorieren. Immerhin ist die voraussichtliche Lebensdauer eines Objekts umso kürzer, je jünger es ist. Daher wird die Speicherbereinigung und Verdichtung der Generation 0 voraussichtlich schon eine beträchtliche Speichermenge liefern und schneller als die entsprechende Bearbeitung der gesamten Halde durchzuführen sein.

Das ist die einfachste Optimierung, die sich durch eine generationsbezogene GC erreichen lässt. Ein generationsbezogener Sammler hat aber noch weitere Optimierungen zu bieten. So kann er sich zum Beispiel dazu entschließen, nicht jedes Objekt in der verwalteten Halde zu untersuchen. Wenn sich nämlich eine Wurzel oder ein Objekt auf ein Objekt aus einer alten Generation bezieht, kann der Garbage Collector die inneren Referenzen des alten Objekts überspringen. Dadurch lässt sich der Graph der erreichbaren Objekte schneller aufbauen. Natürlich ist es durchaus möglich, dass sich auch ein altes Objekt auf ein neues Objekt bezieht. Damit diese Objekte untersucht werden, kann der Sammler entsprechende Informationen von der Schreibüberwachung des Systems einholen, dargestellt durch die Win32-Funktion GetWriteWatch in der Kernel32.dll. Von ihr erfährt der Sammler, auf welche der alten Objekte seit der letzten Sammlung Schreibzugriffe erfolgt sind (wenn überhaupt). Anschließend kann er die internen Referenzen dieser alten Objekte daraufhin überprüfen, ob sie auf junge Objekte verweisen.

Falls die Speicherbereinigung in der Generation 0 nicht die erforderliche Speichermenge liefert, kann der Sammler sie auf die Generation 1 ausdehnen. Führt auch das nicht zum gewünschten Erfolg, kann der Sammler die Objekte aus allen Generationen untersuchen - also aus den Generationen 0, 1 und 2. Der genaue Algorithmus, mit dem der Sammler die zu untersuchenden Generationen festlegt, dürfte wohl zu den ewigen Baustellen gehören, an denen Microsoft immer wieder Änderungen vornehmen wird.

Die meisten Speicherverwaltungen (zum Beispiel die aus der C-Laufzeitbibliothek) legen Objekte dort an, wo sie gerade einen ausreichend großen freien Platz finden. Wenn ich nun mehrere Objekte hintereinander anlege, ist es daher sehr gut möglich, dass diese Objekte im Speicher durch Megabytes voneinander getrennt sind. In einer verwalteten Halde folgen nacheinander angelegte Objekte auch im Speicher lückenlos aufeinander.

Eine der bereits beschriebenen Annahmen besagt, dass neue Objekte dazu neigen, untereinander starke Beziehungen zu entwickeln. Außerdem werden sie vom Programm mehr oder weniger zum selben Zeitpunkt benutzt. Da neue Objekte außerdem im Speicher hintereinander liegen, erstrecken sich auch die entsprechenden Zugriffe auf einen relativ engen Adressbereich. Und das bringt gewisse Leistungsvorteile mit sich. So ist es zum Beispiel sehr wahrscheinlich, dass die Objekte alle in den CPU-Cache hineinpassen. Ihre Anwendung wird mit ungewohnter Geschwindigkeit auf diese Objekte zugreifen können, weil die CPU die meisten Manipulationen mit Cache-Treffern erledigen kann, so dass kaum RAM-Zugriffe erforderlich werden.

Die Leistungstests im Hause Microsoft ergaben, dass die Speicheranforderungen von der verwalteten Halde schneller bedient werden als die üblichen Anforderungen mit der HeapAlloc-Funktion von Win32. Aus diesen Tests geht auch hervor, dass ein vollständiger GC-Lauf in der Generation 0 mit einem 200-MHz-Pentium weniger als eine Millisekunde dauert. Außerdem hat man sich bei Microsoft vorgenommen, die GCs so schnell zu machen, dass sie im Zeitrahmen eines gewöhnlichen Seitenfehlers bearbeitet werden.

Direkte Kontrolle mit System.GC

Der Typ System.GC gibt Ihrer Anwendung in gewissem Umfang die Kontrolle über den Garbage Collector. So können Sie zum Bespiel durch das Auslesen des Properties GC.MaxGeneration die Nummer der ältesten Generation herausfinden, die es in der verwalteten Halde geben kann. Derzeit liefert GC.MaxGeneration immer eine 2.

Außerdem ist es möglich, den Carbage Collector durch den Aufruf einer der beiden folgenden Methoden zur Arbeit zu bewegen:

  void GC.Collect(Int32 Generation) 
  void GC.Collect()


Mit der ersten Methode können Sie festlegen, bis zu welcher Generation sich die Sammlung erstrecken soll. Sie können eine ganze Zahl von 0 bis GC.MaxGeneration (einschließlich) übergeben. Mit der Null veranlasst man natürlich die Sammlung in der Generation 0, mit der Eins die Sammlung in den Generationen 0 und 1 und mit der Zwei die Sammlung in den Generationen 0, 1 und 2. Die parameterlose Variante von Collect bewirkt eine vollständige Speicherbereinigung in allen Generationen und entspricht folgendem Aufruf:
GC.Collect(GC.MaxGeneration);
Im Normalfall sollte man den Aufruf der Collect-Methode vermeiden. Am besten überlässt man es ganz dem Garbage Collector, sich die Arbeit so einzuteilen, wie er es für richtig hält. Da die Anwendung jedoch mehr als die Laufzeitschicht über ihr eigenes Verhalten weiß, können sich Situationen ergeben, in denen es sinnvoll ist, explizit die Speicherbereinigung auszulösen. So kann es sich zum Beispiel als sinnvoll erweisen, eine vollständige Speicherbereinigung in allen Generationen zu erzwingen, nachdem der Anwender seine Daten in einer Datei gespeichert hat. Außerdem kann ich mir Internet-Browser vorstellen, die eine vollständige Sammlung vornehmen, wenn Webseiten aus dem Speicher hinausgeworfen werden. Sie könnten auch eine Speicherbereinigung einleiten, wenn sich Ihre Anwendung sowieso mit einer längeren Arbeit beschäftigt. Dadurch wird der Umstand verdeckt, dass die Sammlung eine gewisse Zeit dauert. Außerdem verringert sich dadurch die Wahrscheinlichkeit, dass die Sammlung während intensiver Arbeitsphasen des Anwenders stattfindet und stört.

Der GC-Typ bietet auch eine WaitForPendingFinalizers-Methode an. Sie hält einfach den aufrufenden Thread solange an, bis der Thread, der die freachable-Warteschlange bearbeitet, eben diese Schlange geleert und die Finalize-Methoden der betreffenden Objekte aufgerufen hat. Allerdings dürfte es in den meisten Anwendungen nicht erforderlich sein, diese Methode aufzurufen.

Und schließlich bietet der Garbage Collector noch zwei Methoden an, mit denen sich ermitteln lässt, zu welcher Generation ein bestimmtes Objekt gehört:

Int32 GetGeneration(Object obj) 
Int32 GetGeneration(WeakReference wr) 


Die erste Version von GetGeneration erwartet eine Objektreferenz als Argument, während die zweite nur mit einer WeakReference-Referenz zufrieden ist. Das ermittelte Ergebnis liegt natürlich irgendwo zwischen 0 und GC.MaxGeneration (einschließlich).

Der Code aus Listing 2 soll noch einmal verdeutlichen, wie die Generationen funktionieren. Außerdem demonstriert er die gerade besprochenen GC-Methoden.

L2 Eine kleine Demonstration der GC-Methoden

private static void GenerationDemo() { 
    // Schauen wir einmal nach, wie die höchste Generationsnummer 
    // lautet (wir kennen ja die Antwort...) 
    Display("Maximum GC generations: " + GC.MaxGeneration); 
    // Lege ein neues BaseObj auf der Halde an 
    GenObj obj = new GenObj("Generation"); 
    // Das Objekt gehört zur Generation 0, da es gerade angelegt wurde 
    obj.DisplayGeneration();    // zeigt 0 
    // Führe eine Speicherbereinigung aus. Das Objekt wird dadurch in die 
    // nächste Generation übernommen. 
    Collect(); 
    obj.DisplayGeneration();    // zeigt 1 
    Collect(); 
    obj.DisplayGeneration();    // zeigt 2 
    Collect(); 
    obj.DisplayGeneration();    // zeigt 2 (höchste Generationsnummer) 
    obj = null;         // entsorge die starke Referenz auf das Objekt 
    Collect(0);         // Sammle Objekte in Generation 0 
    WaitForPendingFinalizers();    // wir dürften nichts sehen 
    Collect(1);         // Sammle Objekte in Generation 1 
    WaitForPendingFinalizers();    // wir dürften nichts sehen 
    Collect(2);         // Wie Collect() 
    WaitForPendingFinalizers();    // Nun sollte die Finalize-Methode 
                                   // laufen 
    Display(-1, "Demo stop: Understanding Generations.", 0); 
}

Leistung für Multithread-Anwendungen

Im vorigen Abschnitt habe ich den GC-Algorithmus besprochen und bin auf Optimierungen eingegangen. Allerdings erfolgte die bisherige Schilderung unter einer wichtigen Annahme, nämlich unter der Annahme, dass nur ein Thread läuft. In der realen Welt werden aber mit großer Wahrscheinlichkeit mehrere Threads auf die verwaltete Halde zugreifen oder zumindest mit den Objekten arbeiten, die auf der verwalteten Halde angelegt wurden. Löst ein Thread eine Sammlung aus, dürfen andere Threads nicht auf die Objekte zugreifen (einschließlich der Objektreferenzen auf seinem eigenen Stapel), da der Collector mit einer großen Wahrscheinlichkeit diese Objekte bewegt und dadurch ihre Speicheradressen ändert.

Sobald also der Garbage Collector mit einer Speicherbereinigung beginnen möchte, müssen alle Threads angehalten werden, die verwalteten Code ausführen. Die Laufzeitschicht enthält einige verschiedene Mechanismen, mit denen sie die Threads sicher anhalten und somit die Sammlung ermöglichen kann. Die verschiedenen Mechanismen gibt es aus dem Grund, um die Threads so lange wie möglich laufen lassen zu können und den Verwaltungsaufwand so weit wie möglich zu verkleinern. Aber seien Sie unbesorgt - ich möchte jetzt nicht auf sämtliche Einzelheiten eingehen. So mag an dieser Stelle die Feststellung reichen, dass sich Microsoft große Mühe gegeben hat, den Verwaltungsaufwand für die Durchführung einer Speicherbereinigung in Grenzen zu halten. Im Laufe der Zeit wird es wohl noch die eine oder andere Änderung an diesen Mechanismen geben, damit die Speicherbereinigung noch effizienter wird.

Im folgenden möchte ich einige dieser Mechanismen beschreiben, die der Garbage Collector in Gang setzt, wenn er es mit Anwendungen zu tun hat, in denen es mehrere Threads gibt.

Unterbrechbarer Code Sobald die Sammlung beginnt, hält der Sammler alle Anwendungsthreads an. Dann ermittelt der Sammler die Stellen, an denen die Threads angehalten wurden. Mit Hilfe der Tabellen, die der JIT-Compiler (just-in-time) erzeugt hat, kann der Collector feststellen, in welcher Methode ein Thread angehalten wurde, auf welche Objektreferenzen der Code gerade zugreift und wo diese Referenzen liegen (in einer Variablen, in einem CPU-Register und so weiter).

Hijacking Der Sammler kann den Stapel eines Threads so ändern, dass die Rücksprungadresse auf eine spezielle Funktion verweist. Sobald die gerade in Ausführung befindliche Methode zum Aufrufer zurückkehrt, wird diese spezielle Funktion ausgeführt und hält den Thread an. Den Ausführungspfad eines Threads in dieser Weise zu stehlen nennt man eine "Thread-Entführung" (hijacking the thread). Sobald die Sammlung abgeschlossen ist, wird der Thread seine Arbeit wieder aufnehmen und zum ursprünglichen Aufrufer zurückkehren.

Sicherungspunkte Während der JIT-Compiler eine Methode kompiliert, kann er Aufrufe von einer speziellen Funktion in die Methode einbauen, die überprüft, ob eine GC läuft. Ist das der Fall, so wird der Thread von dieser speziellen Funktion angehalten. Die GC läuft bis zu ihrem Abschluss durch und dann nimmt der Thread wieder seine Arbeit auf. Die Stelle, an welcher der Compiler diesen Methodenaufruf einfügt, nennt man einen GC-Sicherungspunkt (safe point).

Übrigens macht es die "Thread-Entführung" möglich, solche Threads, die unverwalteten Code ausführen, während einer laufenden Speicherbereinigung weiterlaufen zu lassen. Daraus ergeben sich normalerweise keine Probleme, weil der unverwaltete Code nicht auf die Objekte zugreift, die in der verwalteten Halde liegen. Jedenfalls nicht, solange sie nicht festgesteckt (pinned) wurden und keine Objektreferenzen enthalten. Der Garbage Collector darf ein solches festgestecktes Objekt nicht im Speicher verschieben. Sobald ein Thread, der gerade unverwalteten Code ausführt, zu verwaltetem Code zurückkehrt, wird der Thread entführt und angehalten, bis die Speicherbereinigung abgeschlossen ist.

Über die gerade erwähnten Mechanismen hinaus hat der Garbage Collector noch weitere Verbesserungen aufzuweisen, die zur Leistungssteigerung der Speicherzuweisungen und der Speicherbereinigung für Anwendungen dienen, in denen es mehrere Threads gibt.

Synchronisationsfreie Speicheranforderungen Auf einem Mehrprozessorsystem wird die Generation 0 der verwalteten Halde auf mehrere Speicherbereiche aufgeteilt, wobei jeder Thread einen Bereich erhält (genauer gesagt, eine "Arena"). Auf diese Weise lassen sich die Speicheranforderungen von mehreren Threads gleichzeitig bedienen, denn es wird ja kein exklusiver Zugang zur Speicherhalde erforderlich.

Skalierbare Speicherbereinigungen Auf einem Mehrprozessorsystem, auf dem die Serverversion der ausführenden Maschine läuft (MSCorSvr.dll), wird die verwaltete Halde in mehrere Abschnitte aufgeteilt. Für jede CPU ein Abschnitt. Wenn eine Sammlung anläuft, steht dem Sammler pro CPU ein Thread zur Verfügung. Alle Threads laufen gleichzeitig und führen die Sammlung in den Abschnitten durch, für die sie zuständig sind. Die Arbeitsplatzversion der ausführenden Maschine (MSCorWks.dll) kann das nicht.

Garbage Collection mit großen Objekten

Es gibt noch eine weitere Optimierung, die Sie vermutlich interessieren dürfte. Große Objekte (also Objekte mit 20.000 Bytes und mehr) werden auf einer speziellen Speicherhalde angelegt, die nur für große Objekte zuständig ist. Die Objekte aus dieser Halde werden genauso finalisiert und ans System zurückgegeben wie die kleinen Objekte, von denen bisher die Rede war. Allerdings werden die großen Objekte nicht verdichtet, weil die Verschiebung von solchen großen Speicherblöcken einfach zu viel Zeit erfordert.

Der Anwendungscode merkt indes nichts von diesen Mechanismen. Sie sind für ihn völlig transparent. Für den Anwendungsentwickler sieht es so aus, als gäbe es einfach nur eine große verwaltete Speicherhalde. Die Mechanismen sind nur innerhalb der Speicherverwaltung verfügbar und dienen einfach nur der Leistungsoptimierung.

Überwachung der Garbage Collections

Das Laufzeitschicht-Team von Microsoft hat einige Leistungszähler implementiert, mit denen sie eine ganze Menge Echtzeitstatistik über die Vorgänge in der Laufzeitschicht betreiben können. Sie können sich die Daten im Systemmonitor-Steuerelement anschauen. Am einfachsten erhalten Sie den Zugriff auf dieses Steuerelement, wenn Sie den PerfMon.exe starten und auf der Symbolleiste die Plus-Schaltfläche anklicken. Dann öffnet sich der Dialog "Leistungsindikatoren hinzufügen" (Bild B4).

Bild04

B4 Hier lässt sich das gewünschte Steuerelement aufnehmen.

Zur Überwachung des Garbage Collectors wählen Sie das Objekt COM+ Memory Performance. Außerdem können Sie im Instanzlistenfeld eine bestimmte Anwendung aussuchen. Zum Schluss wählen Sie die Zähler aus, die Sie gerne sehen möchten, drücken dann auf die Schaltfläche Hinzufügen und schließlich auf Schließen. Anschließend stellt der Systemmonitor die Echtzeitwerte grafisch dar. Tabelle T1 beschreibt die verfügbaren Zähler.

T1 Diese Leistungszähler lassen sich im Systemmonitor darstellen

Zähler

Beschreibung

# Bytes in all Heaps

Gesamtmenge des von den Generationen 0, 1 und 2 und in der Halde für große Objekte belegten Speichers. Zeigt an, wieviel Speicher der Garbage Collector zur Unterbringung der Objekte benutzt.

# GC Handles

Gesamtzahl der aktuellen GC-Handles.

# Gen 0 Collections

Zahl der Sammlungen in der Generation 0 (die jüngsten Objekte).

# Gen 1 Collections

Zahl der Sammlungen in der Generation 1.

# Gen 2 Collections

Zahl der Sammlungen in der Generation 2 (die ältesten Objekte).

# Induced GC

Gesamtzahl der GC-Läufe, die durch einen expliziten Aufruf ausgelöst wurden (zum Beispiel von den Classlibs), also nicht implizit durch eine Speicheranforderung.

# Pinned Objects

Noch nicht implementiert.

# of Sink

Blocks in useSynchronisationsmechanismen benutzen "Sink Blocks". Die Sink-Block-Daten gehören zu einem Objekt und werden bei Bedarf angelegt.

# Total committed Bytes

Gesamtzahl der Bytes von allen Speicherhalden.

% Time in GC

Gesamtzeit, die der Garbage Collector seit der letzten Messung mit der Speicherbereinigung verbracht hat, geteilt durch die Zeit, die seit der letzten Messung verstrichen ist.

Allocated Bytes/sec

Die Rate in Bytes pro Sekunde, mit der Speicher vom Garbage Collector angefordert wird. Wird nur während einer Garbage Collection aktualisiert, also nicht für jede einzelne Speicheranforderung.

Finalization Survivors

Die Zahl der Klassen, die eine Sammlung überlebt haben, weil ihre Finalisierungsmethode eine Referenz auf sie erzeugt hat.

Gen 0 heap size

Die Größe der Speicherhalde für die Generation 0 (die jüngsten Objekte) in Bytes.

Gen 0 Promoted Bytes/Sec

Die Zahl der Bytes, die pro Sekunde von der Generation 0 (die jüngste) in die Generation 1 übernommen werden. Die Objekte werden übernommen, wenn sie eine Speicherbereinigung überleben.

Gen 1 heap size

Die Größe der Speicherhalde für die Generation 1 in Bytes.

Gen 1 Promoted Bytes/Sec

Die Zahl der Bytes, die pro Sekunde von der Generation 1 in die Generation 2 (die älteste) übernommen werden. Die Objekte werden übernommen, wenn sie eine Speicherbereinigung überleben. Generation 2 ist die älteste Generation. Es wird also kein Objekt mehr in eine noch ältere Generation verlegt.

Gen 2 heap size

Die Größe der Speicherhalde für die Generation 2 (die ältesten Objekte) in Bytes.

Large Object Heap size

Die Größe der Speicherhalde für große Objekte in Bytes.

Promoted Memory from Gen 0

Die Zahl der Bytes, die eine Sammlung überleben und aus der Generation 0 in die Generation 1 übernommen werden.

Promoted Memory from Gen 1

Die Zahl der Bytes, die eine Sammlung überleben und aus der Generation 1 in die Generation 2 übernommen werden.

Ressourcenanforderung


Die allen Sprachen gemeinsame .NET-Laufzeitumgebung (common language runtime) macht es erforderlich, dass alle Ressourcen auf der "verwalteten Halde" (managed Heap) angelegt werden. Im Prinzip ist das nichts anderes als die Anforderung eines Speicherblocks von der alten C-Speicherverwaltung, aber diese Speicherblöcke brauchen Sie niemals explizit zurückzugeben. Die Speicherverwaltung holt sich automatisch alles zurück, was ihr gehört, sobald es nicht mehr von der Anwendung gebraucht wird. Womit sich natürlich die Frage erhebt, woher die Speicherverwaltung eigentlich weiß, welche Blöcke nicht mehr von der Anwendung benutzt werden. Darauf komme ich gleich noch zurück.

Heutzutage sind mehrere verschiedene GC-Algorithmen in Gebrauch. Jeder eignet sich besonders für eine bestimmte Umgebung, in der er die beste Leistung erbringt. In diesem Artikel möchte ich mich auf den GC-Algorithmus konzentrieren, der von der gemeinsamen Laufzeitschicht benutzt wird. Beginnen wir am besten mit den Grundkonzepten.

Wenn ein Prozess initialisiert wird, reserviert die Laufzeitschicht einen zusammenhängenden und lückenlosen Bereich aus dem Adressraum, der anfangs noch nicht mit Speicher hinterlegt ist. Bei diesem Adressbereich handelt es sich um die verwaltete Halde. Zur Halde gehört außerdem ein Zeiger, den ich im folgenden NextObjPtr nennen möchte. Dieser Zeiger zeigt auf den Ort in der Halde, an dem das nächste Objekt angelegt werden soll. Am Anfang wird NextObjPtr auf die Basisadresse dieses reservierten Adressenbereichs gesetzt.

Die Anwendung legt das gewünschte neue Objekt mit dem Operator new an. Dieser Operator überprüft zuerst, ob der neue Speicherblock in der erforderlichen Größe überhaupt in den reservierten Abschnitt passt. Wenn das Objekt passt, zeigt NextObjPtr bereits auf das Objekt, das neu auf der Halde entsteht. Der Konstruktor des Objekts wird aufgerufen und der new-Operator liefert die Adresse des Objekts ab.

Nun wird NextObjPtr so verändert, dass der Zeiger auf die Stelle hinter dem Objekt zeigt, an dem das nächste Objekt in der Halde abgelegt wird. Bild B1 zeigt eine verwaltete Speicherhalde, die bereits die drei Objekte A, B und C enthält. Das nächste neue Objekt entsteht an der Stelle, auf die NextObjPtr verweist (in diesem Fall direkt hinter Objekt C).

Bild01

NextObjPtr zeigt auf die Stelle, an der das nächste Objekt entsteht.

Schauen wir uns nun an, wie die C-Laufzeitschicht für den Speicher sorgt. In einer C-Speicherhalde ist es für die Zuweisung eines neuen Speicherblocks im Prinzip erforderlich, eine verkettete Liste von Datenstrukturen zu durchsuchen. Sobald ein freier Block gefunden wird, der groß genug ist, muss dieser Block meistens in ein genau passendes Stück und den Rest aufgeteilt werden. Die entsprechenden Zeiger auf die Knoten der verketteten Liste müssen dann aktualisiert werden, damit die Speicherhalde intakt bleibt. In einer verwalteten Speicherhalde bedeutet die Zuweisung eines Objekts einfach nur die Addition eines Werts zu einem Zeiger. Im Vergleich mit den Verhältnissen, die in C herrschen, ist das ein sehr schneller Vorgang. Tatsächlich ist die Anforderung eines Speicherblocks für ein Objekt aus der verwalteten Halde fast so schnell wie die Beschaffung eines entsprechenden Speicherblocks auf dem Stapel des Threads.

Bisher hört es sich an, als sei die verwaltete Halde der C-Halde weit überlegen, weil sie sehr schnell ist und sich anscheinend auch einfacher implementieren lässt. Natürlich hat sich die verwaltete Halde diese Vorteile durch eine sehr große Annahme verschafft. Sie tut nämlich so, als sei Adressraum und Speicher unerschöpflich. Diese Annahme ist natürlich falsch und es fehlt noch ein Mechanismus, der es der Speicherhalde ermöglicht, trotz dieser Annahme zu funktionieren. Dieser Mechanismus wird "Müllsammler" genannt, der Garbage Collector. Schauen wir uns an, wie er funktioniert.

Wenn eine Anwendung mit dem new-Operator ein neues Objekt anlegen möchte, ist vielleicht nicht mehr genügend Adressraum übrig, um die Speicheranforderung zu erfüllen. Die Speicherhalde erkennt das ganz einfach, indem sie die Größe des neuen Objekts zu NextObjPtr addiert und das Ergebnis mit der Endadresse des Adressbereichs vergleicht. Zeigt NextObjPtr bereits hinter das Ende des reservierten Adressbereichs, so ist die Halde voll und die Speicherverwaltung muss die nicht länger benutzten Blöcke wieder einsammeln, um Platz zu schaffen.

In der Praxis findet die erste Sammlung statt, wenn die Generation null vollständig ist. Mit "Generation" ist in diesem Zusammenhang ein Konzept gemeint, mit dem die Leistung des Müllsammlers verbessert werden soll. Neu angelegte Objekte gehören in diesem Sinne zu einer jungen Generation und die Objekte, die bereits in einem früheren Arbeitszyklus der Anwendung entstanden sind, gehören zu einer alten Generation. Durch die Aufteilung in verschiedene Generationen kann der Garbage Collector nun Sammlungen in bestimmten Generationen ausführen, statt stets sämtliche Objekte in der gesamten verwalteten Halde untersuchen zu müssen. Im Teil 2 dieser kleinen Artikelserie werden die Generationen noch genauer untersucht.

Der Sammelalgorithmus


Der Garbage Collector überprüft, ob es auf der Speicherhalde irgendwelche Objekte gibt, die nicht länger von der Anwendung benutzt werden. Wenn es solche Objekte gibt, lässt sich der Speicher, den diese Objekte belegen, zurückgewinnen und wiederverwenden (Sollte kein Speicher mehr auf der Halde frei oder wiederbeschaffbar sein, gibt der Operator new mit der Meldung der Ausnahme OutOfMemoryException auf.). Woher weiß der Garbage Collector, ob die Anwendung ein bestimmtes Objekt noch benutzt oder nicht? Wie man sich leicht vorstellen kann, ist diese Frage durchaus nicht leicht zu beantworten.

Jede Anwendung hat eine Reihe von Wurzeln. Wurzeln bezeichnen Speicherstellen, die sich auf Objekte beziehen, die auf der verwalteten Halde liegen, oder auf Objekte, die auf null gesetzt wurden. Zum Beispiel werden alle globalen und statischen Objektzeiger zu den Wurzeln der Anwendung gezählt. Außerdem gelten alle Objektzeiger in lokalen Variablen oder Parametern auf dem Stapel eines Threads als Wurzeln. Und schließlich zählen noch alle CPU-Register, die Zeiger auf Objekte aus der verwalteten Halde enthalten, zu den Wurzeln der Anwendung. Der JIT-Compiler (just-in-time) und die Laufzeitschicht führen eine Liste mit den aktiven Wurzeln, auf die der Müllsammler bei seiner Arbeit zurückgreifen kann.

Wenn der Garbage Collector mit seiner Arbeit beginnt, geht er einfach von der Annahme aus, sämtliche Objekte in der Speicherhalde seien Müll. Er nimmt also einfach an, keine der Wurzeln verweise auf Objekte in der Halde. Nun geht der Garbage Collector die Wurzeln durch und baut einen Graphen mit allen Objekten zusammen, die von den Wurzeln aus zugänglich sind. So mag der Garbage Collector zum Beispiel eine globale Variable lokalisieren, die auf ein Objekt in der Halde verweist.

Bild B2 zeigt eine Halde mit mehreren Objekten. Die Wurzeln der Anwendung verweisen direkt auf die Objekte A, C, D und F. Alle diese Objekte werden zum Bestandteil des Graphen. Wenn er das Objekt D hinzufügt, stellt der Sammler fest, dass sich dieses Objekt auf Objekt H bezieht, und nimmt auch Objekt H in den Graphen auf. Und so geht der Sammler rekursiv seines Weges über alle erreichbaren Objekte.

Bild02

Die Speicherhalde enthält bereits mehrere Objekte.

Sobald dieser Teil des Graphen vollständig ist, überprüft der Müllsammler die nächste Wurzel und geht wieder über die Objekte. Und während sich der Sammler so von Objekt zu Objekt vorarbeitet, stößt er sicherlich auch hin und wieder auf ein Objekt, das er bereits in den Graphen aufgenommen hat. In diesem Fall braucht er den betreffenden Pfad nicht erneut auszuwerten. Erstens vermeidet er dadurch doppelte Arbeit und wird dadurch wesentlich schneller, und zweitens vermeidet er dadurch unendliche Schleifen, falls Sie irgendwelche zirkulär verketteten Objektlisten erzeugt haben.

Nach der Prüfung aller Wurzeln enthält der Graph des Müllsammlers die Menge aller Objekte, die in irgendeiner legalen Weise von den Wurzeln der Anwendung aus zugänglich sind. Alle Objekte, die nicht vom Graphen erfasst werden, sind der Anwendung auch nicht mehr zugänglich und werden daher schlicht und ergreifend als Müll betrachtet. Nun durchläuft der Müllsammler linear die Speicherhalde und sucht nach zusammenhängenden Blöcken von Objektmüll, die nun wieder "freier Speicher" heißen. Dann schiebt der Müllsammler alle noch lebenden Objekte im Speicher nach unten, damit die Lücken in der Halde verschwinden. Die Verschiebung erfolgt übrigens mit der memcpy-Funktion, die wir nun schon jahrelang kennen. Durch die Verschiebung werden natürlich die Zeiger auf die Objekte ungültig. Also muss der Müllsammler die Wurzeln der Anwendung anpassen, damit die Zeiger auf die neuen Orte zeigen, an denen die Objekte nun zu finden sind. Außerdem muss der Sammler natürlich auch noch alle Zeiger auf Objekte nachführen, die in den Objekten selbst gehalten werden. Bild B3 zeigt die verwaltete Halde nach der Sammlung.

Bild03

Die verwaltete Speicherhalde nach der Sammlung.

Nachdem der Müll identifiziert, die lebenden Objekte zusammengeschoben und die Objektzeiger nachgeführt wurden, verweist NextObjPtr direkt hinter das letzte lebende Objekt. Nun wird der Versuch von new wiederholt, einen passenden Speicherblock zu beschaffen. Diesmal aller Wahrscheinlichkeit nach mit Erfolg.

Wie Sie sicher schon ahnen, kann der Müllsammler zu einem deutlichen Leistungseinbruch führen. Und das ist der wichtigste Nachteil einer verwalteten Halde. Man darf aber nicht vergessen, dass die Müllsammlung nur stattfindet, wenn die Speicherhalde voll ist. Bis dahin ist die verwaltete Halde deutlich schneller als eine herkömmliche C-Speicherhalde. Außerdem wendet der Müllsammler aus der Laufzeitschicht einige Optimierungen an, die sich deutlich auf die Geschwindigkeit der Müllsammlung auswirken (Auf die Optimierungen komme ich im Teil 2 dieser Artikelfolge zurück, wenn ich über Generationen spreche.).

An diesem Punkt möchte ich auf einige wichtige Dinge eingehen. Sie brauchen keinen Code für die Lebensdauer von irgendwelchen Ressourcen mehr zu implementieren, die in Ihrer Anwendung eingesetzt werden. Ausserdem sind die beiden Fehler, von denen ich am Anfang dieses Artikels gesprochen habe, einfach verschwunden. Erstens ist es gar nicht mehr möglich, ein Ressourcenleck zu bauen, weil sich alle Ressourcen, die nicht mehr von den Wurzeln der Anwendung aus zugänglich sind, irgendwo und irgendwann wieder einsammeln lassen. Zweitens ist es nicht mehr möglich, auf eine bereits wieder abgegebene Ressource zuzugreifen, denn wenn die Ressource noch zugänglich ist, wird sie nicht freigegeben. Und wenn sie nicht zugänglich ist, hat Ihre Anwendung keine Möglichkeit, trotzdem darauf zuzugreifen. Listing L1 zeigt, wie Ressourcen angefordert und verwaltet werden.

L1 Anforderung und Verwaltung von Ressourcen

class Application { 
    public static int Main(String[] args) { 
      // Das ArrayList-Objekt wird auf der Halde angelegt,  
      // myArray ist nun eine "Wurzel". 
      ArrayList myArray = new ArrayList(); 
      // Baue auf der Halde 10000 Objekte 
      for (int x = 0; x < 10000; x++) { 
         myArray.Add(new Object());    // Das Objekt wird auf  
                                       // der Halde angelegt 
      } 
      // Nun ist myArray eine Wurzel (auf dem Stapel des Threads). 
      // Also ist myArray erreichbar und die 10000 Objekte, auf die 
      // das Array verweist, sind es auch. 
      Console.WriteLine(a.Length); 
      // Nach der letzten Referenz auf myArray im Code ist myArray 
      // keine Wurzel mehr. 
      // Beachten Sie bitte, dass die Methode nicht extra zurück- 
      // kehren muss. Der JIT-Compiler weiß, wie er myArray nach 
      // der letzten Referenz im Code als Wurzel ungültig machen 
      // kann. 
      // Da myArray nun keine Wurzel mehr ist, sind sämtliche 
      // 10001 Objekte nicht mehr erreichbar und werden daher 
      // als Müll betrachtet. Allerdings wird der Müll bis zur 
      // nächsten Garbage Collection nicht eingesammelt. 
   } 
}

Wenn der Garbage Collector eine so tolle Sache ist, fragt man sich natürlich, warum er nicht ins ANSI-C++ aufgenommen wurde. Der Grund ist, dass ein Garbage Collector in der Lage sein muss, die Wurzeln in der Anwendung zu erkennen und alle Objektzeiger zu finden. In C++ ergibt sich das Problem, dass es die Umwandlung von einem Zeigertypen in einen anderen erlaubt und es unmöglich ist, herauszufinden, worauf ein Zeiger zeigt. In der gemeinsamen Laufzeitschicht kennt die verwaltete Speicherhalde jederzeit den tatsächlichen Typ eines Objekts und kann mit Hilfe der Metadaten auch herausfinden, welche Datenelemente des Objekts auf andere Objekte verweisen.

Finalisierung

Der Garbage Collector bietet eine weitere Dienstleistung an, auf die Sie zurückgreifen können, nämlich die "Finalisierung" (Finalization). Durch diesen Vorgang ist eine Ressource in der Lage, sauber hinter sich selbst aufzuräumen, wenn sie eingesammelt wird. Die Finalisierung erlaubt es einer Ressource, die etwa eine Datei oder eine Netzverbindung repräsentiert, ihre Angelegenheiten zu regeln, wenn sich der Müllsammler entschließt, den Speicher von dieser Ressource zurückzuholen.

Wenn man den Ablauf stark vereinfacht, geschieht ungefähr Folgendes: sobald der Garbage Collector herausfindet, dass ein bestimmtes Objekt zu Müll wird, ruft er die Finalize-Methode dieses Objekts auf, sofern es eine gibt, und holt sich dann den Speicher dieses Objekts zurück. Nehmen wir zum Beispiel an, Sie hätten in C# ("C-sharp") folgenden Typ:

public class BaseObj { 
    public BaseObj() { 
    } 
    protected override void Finalize() { 
        // Hier folgt der Code, der die Ressource wegräumt. 
        // Beispiel: Schließe Datei / schließe Netzverbindung 
        Console.WriteLine("In Finalize.");  
    } 
}


Nun können Sie mit folgender Zeile eine Objektinstanz anlegen:


BaseObj bo = new BaseObj();

Irgendwann in der Zukunft wird der Garbage Collector feststellen, dass das Objekt reif für den Mülleimer ist. Außerdem stellt er fest, dass dieser Typ eine Finalize-Methode hat, und ruft diese auf. Im Konsolenfenster erscheint dann der Text "In Finalize" und der Speicherblock, der von diesem Objekt belegt wird, geht an die Speicherverwaltung zurück.

Viele Entwickler, die an die Verhältnisse in C++ gewöhnt sind, vergleichen die Finalize-Methode sofort mit einem Desktruktor. Lassen Sie sich aber gesagt sein, dass die Objektfinalisierung und die Destruktoren eine sehr verschiedene Semantik haben. Am besten vergessen Sie sofort alles, was Sie über Destruktoren wissen, wenn Sie über die Finalisierung nachdenken. Verwaltete Objekte haben keine Destruktoren. Punkt!

Beim Entwurf eines Typs vermeidet man am besten die Finalize-Methode. Dafür gibt es mehrere Gründe:

  • Finalisierbare Objekte werden in ältere Generationen übernommen, was den Bevölkerungsdruck auf den Lebensraum "Speicher" erhöht und verhindert, dass der Speicher des Objekts eingesammelt wird, wenn der Sammler das Objekt als Müll erkennt. Außerdem werden auch alle Objekte in dieser Art "befördert", die von diesem Objekt direkt oder indirekt referenziert werden. Über Generationen und solche "Beförderungen" wird im zweiten Teil die Rede sein.

  • Der Bau von finalisierbaren Objekten dauert länger.

  • Zwingt man den Garbage Collector, eine Finalize-Methode aufzurufen, kann dies die Geschwindigkeit beträchtlich verringern. Jedes Objekt muss ja entsprechend bearbeitet werden. Wenn es sich um ein Array mit 10000 Objekten handelt, müssen von allen Objekten die Finalize-Methoden aufgerufen werden.

  • Finalisierbare Objekte beziehen sich vielleicht auf andere, nicht finalisierbare Objekte und verlängern deren Lebensdauer unnötig. Daher könnte man auf die Idee kommen, einen Typ in zwei verschiedene Typen aufzuteilen, nämlich in ein Fliegengewicht mit Finalize-Methode, das keine anderen Objekte referenziert, und in einen zweiten Typ ohne Finalize-Methode, das nach Bedarf andere Objekte anspricht.

  • Sie haben keine Kontrolle darüber, zu welchem Zeitpunkt die Finalize-Methode aufgerufen wird. Das Objekt hält die Ressourcen bis zum nächsten Lauf des Garbage Collectors fest.

  • Wenn eine Anwendung terminiert, sind einige Objekte immer noch erreichbar, deren Finalize-Methoden nicht mehr zur Laufzeit des Programms aufgerufen werden. Das kann zum Beispiel geschehen, wenn die Objekte von einem Hintergrundthread benutzt werden oder wenn sie erst beim Schließen der Anwendung angelegt werden. Außerdem werden die Finalize-Methoden von unerreichbaren Objekten normalerweise nicht aufgerufen, wenn die Anwendung terminiert, damit dieser Vorgang schnell ablaufen kann. Natürlich werden alle Betriebssystemressourcen zurückgeholt, aber die Objekte in der verwalteten Speicherhalde sind dann nicht mehr in der Lage, nach Plan aufzuräumen. Durch den Aufruf der Methode RequestFinalizeOnShutdown des Typs System.GC lässt sich das Verhalten ändern. Allerdings sollten Sie sich den Aufruf dieser Methode genau überlegen, denn es bedeutet, dass Ihr Datentyp die übliche Verfahrensweise für die gesamte Anwendung ändert.

  • Die Laufzeitschicht macht keinerlei Zusagen über die Reihenfolge, in der die Finalize-Methoden aufgerufen werden. Nehmen wir zum Beispiel an, ein Objekt enthalte einen Zeiger auf ein inneres Objekt. Der Garbage Collector hat herausgefunden, dass beide Objekte Müll sind. Nehmen wir weiterhin an, die Finalize-Methode des inneren Objekts werde zuerst aufgerufen. Anschließend wird der Finalize-Methode des äußeren Objekts der Zugriff auf das innere Objekt erlaubt. Sie darf sogar dessen Methoden aufrufen. Aber das innere Objekt wurde bereits finalisiert und das Ergebnis der Aufrufe ist nicht mehr vorhersagbar. Aus diesem Grund kann man nur empfehlen, keiner Finalize-Methode Zugriff auf irgendwelche inneren (Member-) Objekte zu geben.

Wenn Sie zu dem Schluss kommen, das Ihr Typ eine Finalize-Methode anbieten müsse, dann sorgen Sie dafür, dass der Finalizee-Code so schnell wie möglich läuft. Vermeiden Sie alle Aktionen, von denen die Finalize-Methode blockiert oder behindert werden könnte, einschließlich aller Thread-Synchronisationen. Falls Sie irgendwelche Meldungen über Ausnahmen aus der Finalize-Methode entweichen lassen, nimmt das System einfach an, die Finalize-Methode sei ganz normal zum Aufrufer zurückgekehrt, und fährt mit den Aufrufen der Finalize-Methode von anderen Objekten fort.

Wenn der Compiler Code für einen Konstruktor generiert, fügt er automatisch einen Aufruf des Konstruktors vom Basistyp ein. Auch wenn er das Gegenstück zum Konstruktor generiert, nämlich den Destruktor, baut der C++-Compiler den Code für den Aufruf vom Destruktor des Basistyps ein. Wie ich aber bereits sagte, unterscheiden sich Finalize-Methoden von Destruktoren. Über eine Finalize-Methode hat der Compiler kein spezielles Wissen. Also baut er auch keine Aufrufe von den Finalize-Methoden der Basistypen ein. Wenn Sie trotzdem den entsprechenden Aufruf brauchen - oft genug wird das der Fall sein - müssen Sie in Ihrer Finalize-Methode die Finalize-Methode des Basistyps explizit aufrufen.

public class BaseObj { 
    public BaseObj() { 
    } 
    protected override void Finalize() { 
        Console.WriteLine("In Finalize.");  
        base.Finalize();    // Rufe die Finalize-Methode des 
                            // Basistyps auf 
    } 
}


Der Aufruf der Finalize-Methode vom Basistyp erfolgt normalerweise als letzter Befehl in der Finalize-Methode des abgeleiteten Typs. Dadurch bleibt das Basisobjekt so lange wie möglich am Leben. Da der Aufruf der Finalize-Methode vom Basistyp üblich ist, bietet C# dafür eine vereinfachte Syntax an. In C# veranlasst der Quelltext

class MyObject { 
    ~MyObject() { 
        ... 
    } 
}


den Compiler, den folgenden Code zu generieren:

class MyObject { 
    protected override void Finalize() { 
        ... 
        base.Finalize(); 
    } 
}


Diese C#-Syntax ähnelt der C++-Syntax für die Definition eines Destruktors. Aber vergessen Sie nicht, dass es in C# gar keine Destruktoren gibt. Lassen Sie sich nicht durch die ähnliche Syntax in die Irre führen.

Hinter den Kulissen der Finalisierung

Bei oberflächlicher Betrachtung scheint die Finalisierung ein ziemlich einfacher Prozess zu sein: Sie legen ein Objekt an und wenn das Objekt vom Müllsammler eingesammelt wird, wird eben die Finalize-Methode des Objekts aufgerufen. An der Finalisierung ist aber mehr dran, als es den Anschein hat.

Wenn eine Anwendung ein neues Objekt anlegt, holt sich der new-Operator den erforderlichen Speicher von der Speicherhalde. Falls das Objekt eine Finalize-Methode hat, wird ein Zeiger auf dieses Objekt in eine Finalisierungs-Warteschlange gestellt. Bei dieser Warteschlange handelt es sich um eine interne Datenstruktur, die vom Garbage Collector kontrolliert wird. Jedes Element in dieser Warteschlange zeigt auf ein Objekt, dessen Finalize-Methode aufgerufen werden muss, bevor der Speicherblock des Objekts zurückgenommen werden kann.

Bild04

Eine Speicherhalde mit vielen Objekten

Bild B4 zeigt eine Halde mit mehreren Objekten. Einige dieser Objekte sind von den Wurzeln der Anwendung aus erreichbar, andere sind es nicht. Als die Objekte C, E, F, I und J angelegt wurden. stellte das System fest, dass diese Objekte Finalize-Methoden hatten, und trug die entsprechenden Zeiger auf diese Objekte in die Finalisierungs-Warteschlange ein.

Sobald eine GC durchgeführt wird, stellt das System fest, dass es sich bei den Objekten B, E, G, H, I und J um Müll handelt. Der Müllsammler durchsucht die Finalisierungs-Warteschlange nach Zeigern auf diese Objekte. Sobald ein Zeiger gefunden wird, nimmt er den Zeiger aus der Finalisierungs-Warteschlange heraus und trägt ihn in eine andere Warteschlange ein, die ich im Folgenden "F-Erreichbar" nennen möchte. Bei der Warteschlange F-Erreichbar handelt es sich um eine weitere interne Datenstruktur, die vom Garbage Collector kontrolliert wird. Jeder Zeiger in der Warteschlange F-Erreichbar identifiziert ein Objekt, dessen Finalize-Methode für den Aufruf bereit ist.

Nach der Sammlung sieht die verwaltete Halde aus wie in Bild B5. Wie Sie sehen, wurde der Speicher von den Objekten B, G und H bereits von der Speicherverwaltung zurückgefordert, weil diese Objekte keine Finalize-Methode haben, die noch aufgerufen werden müsste. Der Speicher, der von den Objekten E, I und J belegt wird, konnte dagegen noch nicht zurückgefordert werden, weil deren Finalize-Methoden noch nicht aufgerufen wurden.

Bild05

Die verwaltete Speicherhalde nach der Garbage Collection

Es gibt einen speziellen Laufzeitthread, der die Aufgabe hat, die Finalize-Methoden aufzurufen. Wenn die Warteschlange F-Erreichbar leer ist, was normalerweise auch der Fall ist, schlummert dieser Thread vor sich hin. Sobald aber Zeiger in der Warteschlange stehen, wacht der Thread auf, nimmt die Zeiger der Reihe nach aus der Warteschlange und ruft die Finalize-Methode jedes einzelnen Objekts auf. Deswegen sollte man in der Finalize-Methode auch keinen Code verwenden, der irgendwelche Annahmen über den Thread macht, auf dem dieser Code einmal laufen wird. Vermeiden Sie in der Finalize-Methode zum Beispiel Zugriffe auf den threadspezifischen Speicher (thread local storage).

Das Zusammenspiel von Finalisierungs-Warteschlange und der Warteschlange F-Erreichbar ist schonrecht interessant. Haben Sie sich schon zusammengereimt, wie die Warteschlange F-Erreichbar zu ihrem Namen gekommen ist (englisch lautet er übrigens "freachable")? Das F drängt sich geradezu auf und bedeutet Finalisierung. Jedes Objekt in der Warteschlange F-Erreichbar steht dort, weil seine Finalize-Methode aufgerufen werden soll. Das "erreichbar" im Namen bedeutet, dass die Objekte erreichbar sind. Anders gesagt, die Warteschlange F-Erreichbar wird als Wurzel betrachtet, so wie die globalen und statischen Variablen Wurzeln sind. Steht ein Objekt also in der Warteschlange F-Erreichbar, ist es erreichbar und wird daher nicht als Müll gewertet.

Um es kurz zu wiederholen: wenn ein Objekt nicht erreichbar ist, betrachtet der Garbage Collector es als Müll. Sobald der Garbage Collector das Objekt von der Finalisierungs-Warteschlange in die Warteschlange F-Erreichbar überträgt, wird das Objekt aber nicht länger als Müll betrachtet und sein Speicher nicht von der Speicherverwaltung zurückgefordert. An diesem Punkt ist der Garbage Collector mit der Identifizierung des Mülls fertig. Manche Objekte, die zuvor als Müll eingestuft wurden, wurden gewissermaßen rehabilitiert und zählen nicht mehr als Müll. Der Garbage Collector schiebt die lebenden Objekte zusammen, damit sich die Lücken zwischen den lebenden Objekten wieder zu einem fortlaufenden lückenlosen Block zusammenfinden können. Und der spezielle Laufzeitthread leert die Warteschlange F-Erreichbar, wobei er die Finalize-Methode von jedem darin verzeichneten Objekt aufruft.

Bei seiner nächsten Aktivierung stellt der Garbage Collector fest, dass es sich bei den finalisierten Objekten nun tatsächlich um Müll handelt, weil keine Wurzeln mehr auf die Objekte verweisen und sie auch nicht mehr in der Warteschlange F-Erreichbar stehen. Nun fordert er den Speicher, den die Objekte belegen, einfach zurück. Wichtig ist, dass zur Beseitigung von Objekten, die eine spezielle Finalisierung brauchen, zwei GC-Läufe erforderlich sind. In der Praxis können sogar mehr als zwei Sammlungen erforderlich werden, weil die Objekte vielleicht in eine ältere Generation übertragen werden. Bild B6 zeigt, wie die verwaltete Speicherhalde nach dem zweiten Sammellauf aussieht.

Bild06

Die verwaltete Speicherhalde nach dem zweiten GC-Lauf

Die Wiedergeburt

Das ganze Konzept der Finalisierung hat einen gewissen Reiz. Natürlich gibt es darüber noch mehr zu sagen. Im vorigen Abschnitt sollte deutlich geworden sein, dass der Garbage Collector ein Objekt als tot ansieht, sobald die Anwendung nicht mehr darauf zugreift. Sofern das Objekt aber auf eine Finalisierung angewiesen ist, wird es wieder als lebend betrachtet, bis der Aufruf der Finalize-Methode erfolgt. Anschließend ist es wirklich tot. Anders gesagt, ein Objekt, das eine Finalisierung erfordert, stirbt, lebt dann wieder und stirbt erneut. Dieses äußerst interessante Phänomen nennt sich "Resurrection", Wiedergeburt. Die Wiedergeburt erlaubt es einem Objekt, wie der Name schon andeutet, von den Toten aufzuerstehen.

Eine bestimmte Form der Wiedergeburt habe ich bereits beschrieben. Sobald der Garbage Collector eine Referenz auf ein Objekt in die Warteschlange F-Erreichbar stellt, ist das Objekt von einer Wurzel aus erreichbar und ist damit ins Leben zurückgekehrt. Irgendwann wird die Finalize-Methode des Objekts aufgerufen. Dann hat das Objekt keine Wurzel mehr und es bleibt wahrlich tot. Für alle Ewigkeit. Aber wie sieht die Sache aus, wenn die Finalize-Methode irgendwelche Codeteile ausführt, die in einer globalen oder statischen Variablen einen Zeiger auf dieses Objekt ablegen?

public class BaseObj { 
    protected override void Finalize() { 
        Application.ObjHolder = this;  
    } 
} 
class Application { 
    static public Object ObjHolder;    // wird mit null initialisiert 
... 
} 


In diesem Fall wird bei der Ausführung der Finalize-Methode ein Zeiger auf das Objekt in einer Wurzel gespeichert und das Objekt ist für den Code der Anwendung wieder erreichbar. Das Objekt wurde wiederbelebt und der Garbage Collector betrachtet es nicht mehr als Abfall. Der Anwendung steht es im Prinzip zwar frei, das Objekt zu benutzen, aber man darf nicht vergessen, dass es bereits finalisiert wurde und beim Einsatz höchstwahrscheinlich ein undefiniertes Verhalten zeigt. Außerdem darf man auch in diesem Fall nicht vergessen, dass die Wirkung über das aktuelle Objekt hinaus reicht. Sofern BaseObj nämlich Datenelemente enthält, die auf andere Objekte verweisen (sei es direkt oder indirekt), werden alle diese Objekte wiederbelebt, da sie alle von den Wurzeln der Anwendung aus erreichbar sind. Allerdings könnten einige oder alle diese Objekte bereits finalisiert worden sein.

Denken Sie beim Entwurf Ihrer eigenen Objekttypen daran, dass auch Ihre Objekte finalisiert und wiederbelebt werden könnten, ohne dass Sie in irgendeiner Form einen Einfluss darauf haben. Implementieren Sie den Code daher so, dass er mit diesen Umständen zurecht kommt. Meistens bedeutet dies wohl die Einführung eines boolschen Flags, aus dem hervorgeht, ob das Objekt bereits finalisiert wurde oder nicht. Sollten nun die Methoden eines bereits finalisierten Objekts aufgerufen werden, können Sie zum Beispiel eine Ausnahme melden. Welche Reaktion sinnvoll ist, hängt aber vom jeweiligen Typ ab.

Wenn nun ein anderer Teil des Codes Application.ObjHolder auf null setzt, ist das Objekt nicht mehr erreichbar. Irgendwann wird der Garbage Collector das Objekt als Müll deklarieren und den von ihm belegten Speicher zurückfordern. Die Finalize-Methode des Objekts wird nicht mehr aufgerufen, weil es in der Finalisierungs-Warteschlange keinen Zeiger auf das Objekt mehr gibt.

Für die Wiederbelebung gibt es eigentlich nur sehr wenige gute Gründe und Sie sollten diesen Trick auch möglichst vermeiden. Wer mit Wiederbelebung arbeitet, will normalerweise erreichen, dass das Objekt bei seinem Abtreten für einen sauberen Abgang sorgen kann. Um das zu ermöglichen, bietet der GC eine Methode namens ReRegisterForFinalize an, die als einziges Argument einen Zeiger auf das fragliche Objekt erwartet.

public class BaseObj { 
    protected override void Finalize() { 
        Application.ObjHolder = this;  
        GC.ReRegisterForFinalize(this); 
    } 
}


Wird die Finalize-Methode dieses Objekts aufgerufen, belebt sich das Objekt selbst wieder, indem es einen Wurzelzeiger auf sich selbst setzt. Dann ruft Finalize die Methode ReRegisterForFinalize auf, die einen Zeiger auf das angegebene Objekt (this) an das Ende der Finalisierungs-Warteschlange stellt. Sobald der Garbage Collector irgendwann wieder zu dem Schluss kommt, dass dieses Objekt nicht erreichbar ist, wird er den Objektzeiger in die Warteschlange F-Erreichbar stellen und die Methode Finalize wird erneut aufgerufen. Dieses spezielle Beispiel zeigt, wie man ein Objekt erzeugen kann, das sich ständig selbst wiederbelebt und niemals stirbt. Solch ein Verhalten ist aber normalerweise nicht erwünscht. Es ist gebräuchlicher, in der Finalize-Methode bestimmte Bedingungen abzufragen, unter denen die Methode eine Wurzel auf das Objekt setzen soll.

Sorgen Sie aber dafür, dass Sie ReRegisterForFinalize nur höchstens einmal pro Wiederbelebung aufrufen. Sonst wird die Finalize-Methode des Objekts mehrfach aufgerufen. Bei jedem Aufruf von ReRegisterForFinalize stellt die Funktion nämlich einen neuen Objektzeiger in die Finalisierungs-Warteschlange. Sobald die Einstufung eines Objekts als Müll erfolgt, werden diese Einträge alle von der Finalisierungs-Warteschlange in die Warteschlange F-Erreichbar übernommen, was letztlich zum mehrfachen Aufruf der Finalize-Methode führen würde.

L2 Die Implementierung des Typs FileStream

public class FileStream : Stream { 
    public override void Close() { 
        // Räume dieses Objekt auf: übertrage die restlichen 
        // Daten auf den Datenträger und schließe die Datei. 
        // Anschließend gibt es keinen Grund mehr, dieses 
        // Objekt zu finalisieren. 
        GC.SuppressFinalize(this); 
    } 
    protected override void Finalize() { 
        Close();    // Objekt aufräumen 
    } 
    // Hier folgen die restlichen FileStream-Methoden 
... 
}

Erzwingen der Aufräumarbeiten

Versuchen Sie, die Objekte so zu definieren, dass sie keine besonderen Aufräumarbeiten erfordern. Leider ist dies bei vielen Objekten schlichtweg unmöglich. Solchen Objekten müssen Sie also eine Finalize-Methode geben. Allerdings wird empfohlen, dem Typ eine zusätzliche Methode zu geben, mit der sich die Aufräumarbeiten explizit durchführen lassen, falls der Benutzer des Objekts dies für sinnvoll hält. Per Konvention sollte diese Methode Close oder Dispose heißen.

Im Normalfall ist Close sinnvoll, wenn sich das Objekt nach dem Schließen erneut öffnen oder wiederverwenden lässt. Sie können Close auch benutzen, wenn vom Objekt ganz allgemein erwartet wird, dass es sich schließen lässt, wie zum Beispiel eine Datei. Dagegen würden Sie zu Dispose greifen, wenn das Objekt anschließend gar nicht mehr benutzt werden soll. So würde man zum Beispiel die Dispose-Methode aufrufen, um ein System.Drawing.Brush-Objekt loszuwerden. Einmal entsorgt, lässt sich das Brush-Objekt nicht mehr verwenden. Der Aufruf von entsprechenden Funktionen, die das Objekt trotzdem noch benutzen wollen, könnte zur Meldung von Ausnahmen führen. Sollten Sie anschließend wider Erwarten doch noch mit einem Brush-Objekt arbeiten müssen, dann bauen Sie sich eben ein Neues.

Schauen wir uns nun einmal genauer an, was die Close/Dispose-Methode eigentlich tun soll. Der Typ System.IO.FileStream erlaubt es dem Anwender, eine Datei für Schreib- und Lesezugriffe zu öffnen. Um die Leistung zu verbessern, benutzt die Implementierung dieses Typs einen Datenpuffer. Erst wenn dieser Puffer voll ist, schreibt der Typ den Inhalt des Puffers in die Datei durch. Nehmen wir an, Sie legten ein neues FileStream-Objekt an und schrieben einige Bytes hinein. Sofern die Datenmenge nicht ausreicht, um den Puffer zu füllen, werden die Daten noch nicht auf den Datenträger übertragen. Der FileStream-Typ implementiert eine Finalize-Methode. Und wenn das FileStream-Objekt der Garbage Collection zum Opfer fällt, schreibt die Finalize-Methode die restlichen Daten, die noch im Puffer verblieben sind, in die Datei und schließt dann die Datei.
Für den Anwender des Typs FileStream ist diese Lösung aber vermutlich nicht gut genug. Nehmen wir an, das erste FileStream-Objekt sei noch nicht dem Müllsammler zum Opfer gefallen, aber die Anwendung möchte ein neues FileStream-Objekt anlegen, das dieselbe Datei benutzt. Unter solchen Umständen muss der Versuch des zweiten FileStream-Objekts zum Öffnen der Datei fehlschlagen, wenn das erste FileStream-Objekt die Datei mit Exklusivrecht geöffnet hat. Der Anwender des FileStream-Objekts braucht die Möglichkeit, das vollständige Durchschreiben der gepufferten Daten auf den Datenträger zu veranlassen und die Datei zu schließen.

Wie ein Blick in die Dokumentation des Typs FileStream zeigt, hat der Typ eine Methode namens Close. Wird sie aufgerufen, so schreibt sie die im Puffer verbliebenen Daten auf den Datenträger durch und schließt die Datei. Nun erst hat der Anwender eines FileStream-Objekts im ausreichenden Maß die Kontrolle über das Verhalten des Objekts.

Daraus ergibt sich aber eine interessante Frage: was soll die Finalize-Methode von FileStream eigentlich noch tun, wenn das FileStream-Objekt eingesammelt wird? Offensichtlich gar nichts. In der Tat gibt es überhaupt keinen Grund mehr für den Aufruf der Finalize-Methode von FileStream, sofern die Anwendung explizit Close aufgerufen hat. Wie Sie wissen, rät man ja dazu, Finalize-Methoden möglichst zu vermeiden. Unter solchen Umständen soll das System eine Finalize-Methode aufrufen, die rein gar nichts tut? Es sieht so aus, als sei noch irgendein Mechanismus erforderlich, mit dem sich der Finalize-Aufruf unterdrücken ließe. Und diesen Mechanismus gibt es. Der Typ System.GC enthält eine statische Methode namens SuppressFinalize, die als einziges Argument die Adresse eines Objekts erwartet.

Listing L2 zeigt die Implementierung des Typs FileStream. Wenn Sie SuppressFinalize aufrufen, schaltet diese Funktion ein Bitflag ein, das dem Objekt zugeordnet wird. Wenn dieses Flag gesetzt ist, weiß die Laufzeitschicht, dass sie den Zeiger auf dieses Objekt nicht in die Warteschlange F-Erreichbar stellen soll. Auf diese Weise wird der überflüssige Finalize-Aufruf effektiv verhindert.

Schauen wir uns ein anderes Problem an, das damit zusammenhängt. Es ist üblich, im Zusammenhang mit einem FileStream-Objekt auch ein StreamWriter-Objekt zu benutzen.

FileStream fs = new FileStream("C:\\SomeFile.txt",  
    FileMode.Open, FileAccess.Write, FileShare.Read); 
StreamWriter sw = new StreamWriter(fs); 
sw.Write ("Hi there"); 
// Der folgende Close-Aufruf ist der richtige Aufruf 
sw.Close();    
// Bitte beachten: StreamWriter.Close schließt den FileStream. 
//       Der FileStream sollte unter diesen Umständen nicht  
//       explizit geschlossen werden. 


Beachten Sie bitte, dass der StreamWriter-Konstruktor ein FileStream-Objekt als Parameter hat. Intern merkt sich das StreamWriter-Objekt den FileStream-Zeiger. Beide Objekte haben interne Datenpuffer, die zum Abschluss der Arbeiten in die Datei übertragen werden müssen. Der Aufruf der Close-Methode von StreamWriter schreibt die letzten Daten in den FileStream und führt intern zum Aufruf der Close-Methode von FileStream, die wiederum die Daten auf den Datenträger schreibt und die Datei schließt. Da die Close-Methode vom StreamWriter-Objekt das FileStream-Objekt schließt, das mit ihm verknüpft ist, sollten Sie in diesem Fall nicht selbst fs.Close aufrufen.

Was wird wohl geschehen, wenn man die beiden Close-Aufrufe streicht? Nun, der Garbage Collector wird korrekt erkennen, dass es sich bei den Objekten um Müll handelt, und die Objekte werden finalisiert. Der Garbage Collector garantiert aber nicht die Reihenfolge, in der die Finalize-Aufrufe erfolgen. Wird FileStream zuerst finalisiert, schließt das Objekt die Datei. Sobald dann der StreamWriter finalisiert wird, versucht er, Daten in die inzwischen geschlossene Datei zu schreiben, und löst damit die Meldung einer Ausnahme aus. Wird der StreamWriter dagegen als erstes von den beiden Objekten finalisiert, erfolgt die Übertragung der Daten in die Datei scheinbar problemlos.

Wie löst Microsoft das Problem? Dem Garbage Collector eine bestimmte Reihenfolge für die Finalisierung vorzuschreiben, ist unmöglich, denn die Objekte können gegenseitig mit Zeigern aufeinander verweisen, wodurch der Garbage Collector keine Chance mehr hat, die richtige Reihenfolge zu ermitteln. Die Microsoft-Lösung sieht so aus: der Typ StreamWriter implementiert überhaupt keine Finalize-Methode. Das ist natürlich eine Garantie für Datenverlust, wenn man vergisst, das StreamWriter-Objekt explizit zu schließen. Allerdings erwartet Microsoft von den Entwicklern, dass ihnen der Datenverlust beim Programmtest auffällt und sie den Fehler durch den entsprechenden expliziten Close-Aufruf beheben.

Wie schon erwähnt, setzt die Methode SuppressFinalize einfach ein Bitflag. Dieses Bitflag bedeutet, dass die Finalize-Methode des Objekts nicht mehr aufgerufen werden soll. Allerdings wird dieses Flag wieder gelöscht, sobald die Laufzeitschicht zu dem Schluss kommt, dass es Zeit für den Finalize-Aufruf ist. Das bedeutet, dass man die Aufrufe von ReRegisterForFinalize nicht mit entsprechenden SuppressFinalize-Aufrufen gegenrechnen kann. Aus dem Code im Listing L3 geht hervor, was ich damit meine.

L3 ReRegisterForFinalize und SuppressFinalize

void method() { 
    // Der Typ MyObj hat eine Finalize-Methode. Bei der 
    // Anlage eines MyObjs wird eine Referenz auf obj  
    // in der Finalisierungstabelle abgelegt. 
    MyObj obj = new MyObj(); 
    // Trage weitere 2 Referenzen für obj in die  
    // Finalisierungstabelle ein 
    GC.ReRegisterForFinalize(obj); 
    GC.ReRegisterForFinalize(obj); 
    // Nun gibt es in der Finalisierungstabelle 3 Referenzen 
    // auf obj. 
    // Das System soll den ersten Finalize-Aufruf für dieses 
    // Objekt ignorieren. 
    GC.SuppressFinalize(obj); 
    // Nun soll das System auch den zweiten Finalize-Aufruf für 
    // dieses Objekt ignorieren. 
    GC.SuppressFinalize(obj);   // Im Endeffekt bewirkt diese 
                                // Zeile rein gar nichts! 
    obj = null;   // Entferne die starke Referenz auf das Objekt. 
    // Ruf den Müllmann, damit er dieses Objekt wegschafft. 
    GC.Collect(); 
    // Nun wird zwar der erste Finalize-Aufruf für dieses Objekt 
    // ignoriert, aber es stehen noch zwei weitere Aufrufe in der 
    // Tabelle, die tatsächlich ausgeführt werden. 
} 


ReRegisterForFinalize und SuppressFinalize wurden aus Leistungsgründen so implementiert, wie sie sind. Solange es für jeden ReRegisterForFinalize-Aufruf einen passenden ReRegisterForFinalize-Aufruf gibt, läuft alles. Es liegt nun an Ihnen, dafür zu sorgen, dass ReRegisterForFinalize oder SuppressFinalize nicht mehrfach hintereinander aufgerufen werden. Sonst können sich mehrfache Aufrufe der Finalize-Methode ergeben.

Fazit

Das war sie also, die vollständige Geschichte der virtuellen Speicherbereinigung. In der ersten Folge habe ich beschrieben, wie Ressourcen angefordert werden, wie die automatische Garbage Collection funktioniert, wie sich die Finalisierung eines Objekts zu Aufräumarbeiten einsetzen lässt und wie sich Objekte wiederbeleben lassen.

In dieser Folge habe ich beschrieben, wie schwache und starke Referenzen auf Objekte implementiert werden, wie sich aus der Aufteilung der Objekte in verschiedene Generationen Leistungsverbesserungen ergeben und wie man mittels System.GC auf die Garbage Collection einwirken kann. Außerdem bin ich noch darauf eingegangen, wie der Garbage Collector versucht, seine Leistung für Anwendungen zu verbessern, in denen es mehrere Threads gibt. Es war die Rede davon, was mit großen Objekten geschieht, die eine Größe von 20.000 erreichen oder überschreiten. Und schließlich habe ich noch beschrieben, wie man sich die Leistungszähler des Garbage Collectors im Leistungsmonitor anschauen kann.

Mit diesen Hintergrundinformationen sollte der Umstieg auf die neue Speicherverwaltung nicht mehr allzu schwer fallen. Für den Anwendungsentwickler bringt die neue Speicherverwaltung nämlich beträchtliche Vereinfachungen mit sich. Und eine höhere Leistung.

Diesen Artikel können Sie hier lesen dank freundlicher Unterstützung der Zeitschrift:

Bild07



Die Implementierung einer brauchbaren Ressourcenverwaltung für die Anwendungen kann eine ziemlich schwierige, zähe und ungemein lästige Angelegenheit werden. Zudem wird Ihre Aufmerksamkeit von den eigentlichen Problemen abgelenkt, die mit der Anwendung gelöst werden sollen. Wäre es nicht wunderbar, wenn man sich nicht mehr darum kümmern bräuchte? Oder etwas technischer ausgedrückt, wenn es einen Mechanismus gäbe, der dem Entwickler die Fronarbeit der Speicherverwaltung erleichtert? Nun, im .NET gibt es diesen Mechanismus, nämlich den guten, alten Müllsammler oder "Garbage Collector" (GC).
Schauen wir uns noch einmal kurz das Problem mit der Speicherverwaltung an. Jedes Programm benutzt verschiedene Arten von Ressourcen - Speicherblöcke, Platz auf dem Bildschirm, Netzverbindungen, Datenbankressourcen und so weiter. In einer objektorientierten Umgebung stellt praktisch jeder Datentyp eine Ressource dar, die dem Programm zur Verfügung steht. Um mit diesen Ressourcen arbeiten zu können, muss erst einmal der Speicher bereitgestellt werden, den der fragliche Typ belegt. Bei dieser Betrachtungsweise sind für den Zugriff auf eine Ressource folgende Schritte erforderlich:

  1. Speicher für den Typ anfordern, der die gewünschte Ressource repräsentiert

  2. Den Speicher initialisieren, damit die Ressource in den Anfangszustand versetzt und einsetzbar wird

  3. Die Ressource durch entsprechende Zugriffe auf die Instanz des Typs einsetzen (bei Bedarf wiederholen)

  4. Die Ressource für die Rückgabe vorbereiten und zurückgeben

  5. Den Speicher zurückgeben

Dieser so harmlos wirkende Ablauf ist eine der Hauptfehlerquellen in der Programmierung. Wie oft hat man selbst schon vergessen, den Speicherblock nach Gebrauch wieder zurückzugeben oder nach der Rückgabe wieder auf den inzwischen ungültig gewordenen Block zugegriffen?

Diese beiden Fehler sind schlimmer als die meisten anderen, die bei der Anwendungsentwicklung üblicherweise gemacht werden, denn welche Konsequenzen sich aus den Fehlern ergeben und wann die Fehler zuschlagen, ist nicht vorherzusagen. Bei allen anderen Fehlern setzt man sich einfach wieder an die Entwicklermaschine und wirft den Fehler raus, sobald man feststellt, dass sich die Anwendung seltsam verhält. Diese beiden Fehler aber führen zu Ressourcenlecks (Speicherverbrauch) und zu ungewollten Überschreibungen von Daten und Objekten, also zu einem instabilen System. Grundsätzlich ist ja das Verhalten jeder fehlerhaften Anwendung undefiniert, aber gerade diese beiden Fehler führen in besonderem Maße dazu, dass sich das Programm zu nicht vorhersagbaren Zeiten nicht vorhersagbar verhält. Daher gibt es auch eine ganze Reihe von Entwicklungswerkzeugen, die dem Entwickler gerade beim Aufspüren solcher Fehler helfen wollen (wie zum Beispiel den Task Manager, den Systemmonitor, den BoundsChecker von CompuWare und das "Purify" von Rational).

Im Folgenden wird bei der Besprechung des GC noch deutlich werden, dass er den Entwickler im Prinzip völlig von der Last befreit, den Speicherverbrauch nachhalten und überflüssig gewordene Speicherblöcke ans System zurückgeben zu müssen. Allerdings weiß der Garbage Collector nichts über die Ressource, die von dem fraglichen Typ im Speicher repräsentiert wird. Das bedeutet, dass er sich nicht um den Schritt 4 kümmern kann - die Rückgabe der Ressource. Um eine Ressource korrekt zu entsorgen, muss der Entwickler den Code für die Rückgabe der Ressource schreiben. Im .NET-Framework schreibt der Entwickler diesen Code in eine Close-, Dispose- oder Finalize-Methode, die ich später noch beschreiben möchte. Wie Sie noch feststellen werden, ist der Garbage Collector in der Lage, diese Methode automatisch aufzurufen.

Außerdem repräsentieren viele Typen Ressourcen, die keine besonderen Aufräumarbeiten erfordern. Eine Rectangle-Ressource zum Beispiel lässt sich einfach dadurch entsorgen, dass man die Felder entsorgt, in denen die Koordinaten und Maße des Rechtecks im Speicherblock des Typs festgelegt werden (In der Praxis gibt man also einfach den Speicherblock wieder an die Speicherverwaltung zurück). Andererseits erfordert ein Typ, der eine Dateiressource oder eine Netzverbindung repräsentiert, die Ausführung eines speziellen Aufräumcodes, sobald das Programm die Ressource wieder loswerden will. Wie das im Einzelnen geschieht, wird uns später noch beschäftigen. Beschränken wir uns im Moment darauf, wie der Speicher angefordert und die Ressourcen initialisiert werden.

Der tiefere Sinn von Umgebungen mit Garbage Collector ist es, den Entwicklern die Speicherverwaltung zu erleichtern. Im ersten Teil dieser kleinen Übersicht war von einigen allgemeinen GC-Konzepten und Interna die Rede. Im nächsten Teil wird diese Diskussion abgeschlossen. Zuerst möchte ich ein Konzept namens WeakReferences erläutern, mit dem man den Druck verringern kann, den große Objekte auf die verwaltete Speicherhalde (managed heap) ausüben. Dann möchte ich einen Mechanismus untersuchen, mit dem sich die Lebensdauer eines verwalteten Objekts verlängern lässt. Zum Abschluss wird noch von verschiedenen Leistungsaspekten des Garbage Collectors die Rede sein. Es geht um Generationen, Sammlungen mit mehreren Threads und um die Leistungszähler, die von der Laufzeitschicht angeboten werden. Damit lässt sich nämlich das Echtzeitverhalten des Garbage Collectors überwachen.

Anzeigen: