So funktioniert die automatische Speicherverwaltung

Veröffentlicht: 15. Dez 2001 | Aktualisiert : 18. Jun 2004

Von Jeffrey Richter

Die Verwaltung von Speicher unter .NET wird durch die Garbage Collection erheblich erleichtert. Ohne genauere Einblicke in deren Funktion können jedoch ernste Probleme entstehen. Dieser Beitrag beleuchtet den Ablauf von Speicherreservierung und -freigabe.

Auf dieser Seite

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:

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.

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

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: