Freigeben über


Garbage Collector-Grundlagen und Tipps zur Leistung

 

Rico Mariani
Microsoft Corporation

April 2003

Zusammenfassung: Der .NET Garbage Collector stellt einen Hochgeschwindigkeitszuordnungsdienst mit guter Speicherauslastung und ohne langfristige Fragmentierungsprobleme bereit. In diesem Artikel wird erläutert, wie Garbage Collectors funktionieren, und anschließend werden einige der Leistungsprobleme erläutert, die in einer Garbage Collection-Umgebung auftreten können. (10 gedruckte Seiten)

Gilt für:
   Microsoft® .NET Framework

Inhalte

Einführung
Vereinfachtes Modell
Sammeln des Garbage
Leistung
Abschluss
Zusammenfassung

Einführung

Um zu verstehen, wie Sie den Garbage Collector gut nutzen und welche Leistungsprobleme bei der Ausführung in einer Garbage Collection-Umgebung auftreten können, ist es wichtig, die Grundlagen der Funktionsweise von Garbage Collectors zu verstehen und wie sich diese internen Vorgänge auf die Ausführung von Programmen auswirken.

Dieser Artikel ist in zwei Teile unterteilt: Zunächst werde ich die Art des Garbage Collector der Common Language Runtime (CLR) allgemein unter Verwendung eines vereinfachten Modells erläutern, und dann werde ich einige Auswirkungen auf die Leistung dieser Struktur erläutern.

Vereinfachtes Modell

Zur Erläuterung sollten Sie das folgende vereinfachte Modell des verwalteten Heaps berücksichtigen. Beachten Sie, dass dies nicht das ist, was tatsächlich implementiert wird.

Abbildung 1. Vereinfachtes Modell des verwalteten Heaps

Die Regeln für dieses vereinfachte Modell sind wie folgt:

  • Alle Garbage Collectable-Objekte werden aus einem zusammenhängenden Adressraumbereich zugeordnet.
  • Der Heap wird in Generationen unterteilt (mehr dazu später), sodass es möglich ist, den größten Teil des Mülls zu beseitigen, indem nur ein kleiner Teil des Heaps betrachtet wird.
  • Objekte innerhalb einer Generation sind alle ungefähr gleich alt.
  • Generationen mit höherer Nummer weisen auf Bereiche des Heaps mit älteren Objekten hin– diese Objekte sind viel wahrscheinlicher stabil.
  • Die ältesten Objekte befinden sich an den niedrigsten Adressen, während neue Objekte bei steigenden Adressen erstellt werden. (Die Adressen werden in Abbildung 1 oben immer größer.)
  • Der Zuordnungszeiger für neue Objekte markiert die Grenze zwischen den verwendeten (zugeordneten) und nicht verwendeten (freien) Speicherbereichen.
  • In regelmäßigen Abständen wird der Heap komprimiert, indem tote Objekte entfernt und die aktiven Objekte in Richtung des unteren Adressendes des Heaps nach oben schieben. Dadurch wird der nicht verwendete Bereich am unteren Rand des Diagramms erweitert, in dem neue Objekte erstellt werden.
  • Die Reihenfolge der Objekte im Arbeitsspeicher bleibt die Reihenfolge, in der sie erstellt wurden, um eine gute Lokalität zu erreichen.
  • Es gibt nie Lücken zwischen Objekten im Heap.
  • Nur ein Teil des freien Speicherplatzes wird committet. Bei Bedarf wird ** mehr Arbeitsspeicher vom Betriebssystem im reservierten Adressbereich abgerufen.

Sammeln des Garbage

Die am einfachsten zu verstehende Art der Sammlung ist die vollständig komprimierende Garbage Collection, daher werde ich zunächst darauf sprechen.

Vollständige Sammlungen

In einer vollständigen Sammlung müssen wir die Programmausführung beenden und alle Wurzeln im GC-Heap finden. Diese Wurzeln kommen in einer Vielzahl von Formen vor, sind aber vor allem Stapel- und globale Variablen, die in den Heap zeigen. Ausgehend von den Wurzeln besuchen wir jedes Objekt und folgen jedem Objektzeiger, der in jedem besuchten Objekt enthalten ist, das die Objekte markiert. Auf diese Weise hat der Collector jedes erreichbare oder aktive Objekt gefunden. Die anderen Objekte, die unerreichbaren , werden nun verurteilt.

Abbildung 2. Wurzeln im GC-Heap

Sobald die nicht erreichbaren Objekte identifiziert wurden, möchten wir diesen Platz für die spätere Verwendung freigeben. Das Ziel des Sammlers an dieser Stelle ist es, die lebenden Objekte nach oben zu schieben und den verschwendeten Platz zu beseitigen. Wenn die Ausführung beendet wurde, ist es für den Collector sicher, alle diese Objekte zu verschieben und alle Zeiger so zu korrigieren, dass alles an seiner neuen Position ordnungsgemäß verknüpft ist. Die überlebenden Objekte werden auf die Nummer der nächsten Generation heraufgestuft (die Grenzen für die Generationen werden aktualisiert), und die Ausführung kann fortgesetzt werden.

Partielle Auflistungen

Leider ist die vollständige Garbage Collection einfach zu teuer, um jedes Mal zu tun, daher ist es jetzt angebracht, zu diskutieren, wie uns generationen in der Sammlung hilft.

Betrachten wir zunächst einen imaginären Fall, in dem wir außerordentlich viel Glück haben. Angenommen, es gab vor kurzem eine vollständige Sammlung, und der Heap ist schön komprimiert. Die Programmausführung wird fortgesetzt, und einige Zuordnungen werden durchgeführt. Tatsächlich werden viele und viele Zuordnungen durchgeführt, und nach ausreichenden Zuordnungen entscheidet das Speicherverwaltungssystem, dass es An der Zeit ist, zu sammeln.

Hier haben wir Glück. Angenommen, während der gesamten Ausführung seit der letzten Auflistung haben wir überhaupt keines der älteren Objekte geschrieben, nur neu zugeordnete Objekte der Generation 0 (Gen0) wurden in geschrieben. Wenn dies geschehen würde, wären wir in einer großartigen Situation, da wir den Garbage Collection-Prozess massiv vereinfachen können.

Anstelle unserer üblichen vollständigen Sammlung können wir einfach davon ausgehen, dass alle älteren Objekte (Gen1, Gen2) noch live sind – oder zumindest genug von ihnen lebendig sind, dass es sich nicht lohnt, diese Objekte zu betrachten. Da keines von ihnen geschrieben wurde (erinnern Sie sich daran, wie glücklich wir sind?), gibt es keine Zeiger von den älteren Objekten auf die neueren Objekte. Was wir also tun können, ist, alle Wurzeln wie üblich zu betrachten, und wenn Wurzeln auf alte Objekte verweisen, ignorieren Sie diese einfach. Für andere Wurzeln (diejenigen, die auf Gen0 zeigen) gehen wir wie gewohnt fort und folgen allen Zeigern. Immer wenn wir einen internen Zeiger finden, der auf die älteren Objekte zurückgeht, ignorieren wir ihn.

Wenn dieser Prozess abgeschlossen ist, haben wir jedes Liveobjekt in Gen0 besucht, ohne Objekte aus den älteren Generationen besucht zu haben. DieGen-0-Objekte können dann wie üblich verurteilt werden, und wir gleiten nur diesen Bereich des Gedächtnisses hoch, sodass die älteren Objekte ungestört bleiben.

Jetzt ist dies wirklich eine großartige Situation für uns, weil wir wissen, dass der größte Teil des toten Raums wahrscheinlich in jüngeren Objekten sein wird, wo es sehr viel Abwanderung gibt. Viele Klassen erstellen temporäre Objekte für ihre Rückgabewerte, temporäre Zeichenfolgen und verschiedene andere Hilfsklassen wie Enumeratoren und whatnot. Wenn wir nur gen0 betrachten, erhalten wir einen einfachen Weg, den größten Teil des toten Raums zurück zu bekommen, indem wir nur sehr wenige der Objekte betrachten.

Leider haben wir nie das Glück, diesen Ansatz zu verwenden, da sich zumindest einige ältere Objekte ändern müssen, sodass sie auf neue Objekte verweisen. In diesem Fall reicht es nicht aus, sie einfach zu ignorieren.

Generationen mit Schreibbarrieren arbeiten lassen

Damit der obige Algorithmus tatsächlich funktioniert, müssen wir wissen, welche älteren Objekte geändert wurden. Um den Speicherort der modifiziert-Objekte zu speichern, verwenden wir eine Datenstruktur, die als Karte-Tabelle bezeichnet wird, und um diese Datenstruktur beizubehalten, generiert der Compiler für verwalteten Code so genannte Schreibbarrieren. Diese beiden Konzepte sind für den Erfolg der generationsbasierten Garbage Collection von zentraler Bedeutung.

Die Karte Tabelle kann auf verschiedene Arten implementiert werden, aber die einfachste Möglichkeit, sie als Array von Bits zu betrachten. Jedes Bit in der Karte Tabelle stellt einen Speicherbereich auf dem Heap dar, z. B. 128 Bytes. Jedes Mal, wenn ein Programm ein Objekt in eine Adresse schreibt, muss der Schreibbarrierecode berechnen, welcher 128-Byte-Block geschrieben wurde, und dann das entsprechende Bit in der Karte Tabelle festlegen.

Nachdem dieser Mechanismus eingerichtet ist, können wir den Sammlungsalgorithmus jetzt erneut nutzen. Wenn wir eine Garbage Collection der Generation0 durchführen, können wir den Algorithmus wie oben beschrieben verwenden, wobei wir alle Zeiger auf ältere Generationen ignorieren, aber sobald wir dies getan haben, müssen wir auch jeden Objektzeiger in jedem Objekt finden, das auf einem Block liegt, der in der Karte Tabelle als geändert markiert wurde. Wir müssen sie wie Wurzeln behandeln. Wenn wir diese Zeiger auch berücksichtigen, werden wir nur die Gen0-Objekte korrekt erfassen.

Dieser Ansatz wäre überhaupt nicht hilfreich, wenn die Karte Tabelle immer voll wäre, aber in der Praxis werden vergleichsweise wenige zeiger aus den älteren Generationen tatsächlich geändert, sodass dieser Ansatz erhebliche Einsparungen bietet.

Leistung

Nachdem wir nun über ein grundlegendes Modell für die Funktionsweise verfügen, betrachten wir einige Dinge, die schief gehen könnten, was es langsam machen würde. Das gibt uns eine gute Vorstellung davon, welche Arten von Dingen wir vermeiden sollten, um die beste Leistung des Sammlers zu erzielen.

Zu viele Zuordnungen

Das ist wirklich das Grundlegendste, was schief gehen kann. Das Zuweisen von neuem Arbeitsspeicher mit dem Garbage Collector ist wirklich recht schnell. Wie Sie in Abbildung 2 oben sehen können, ist, dass der Zuordnungszeiger in der Regel verschoben wird, um Platz für Ihr neues Objekt auf der "zugeordneten" Seite zu schaffen– er wird nicht viel schneller. Früher oder später muss jedoch eine Garbage Collection erfolgen, und alles, was gleich ist, ist es besser, wenn dies später als früher geschieht. Sie möchten also beim Erstellen neuer Objekte sicherstellen, dass dies wirklich notwendig und angemessen ist, auch wenn die Erstellung nur eines schnell ist.

Dies mag nach offensichtlichen Ratschlägen klingen, aber tatsächlich ist es erstaunlich leicht zu vergessen, dass eine kleine Codezeile, die Sie schreiben, viele Zuordnungen auslösen könnte. Angenommen, Sie schreiben eine Vergleichsfunktion in irgendeiner Art und nehmen an, dass Ihre Objekte über ein Schlüsselwörterfeld verfügen und dass bei ihrem Vergleich die Groß-/Kleinschreibung in der angegebenen Reihenfolge nicht beachtet werden soll. In diesem Fall können Sie nicht einfach die gesamte Schlüsselwörterzeichenfolge vergleichen, da die erste Schlüsselwort (keyword) sehr kurz sein kann. Es wäre verlockend, String.Split zu verwenden, um die Schlüsselwort (keyword) Zeichenfolge in Teile zu unterteilen, und dann die einzelnen Teile in der Reihenfolge zu vergleichen, indem sie den normalen Vergleich ohne Beachtung der Groß-/Kleinschreibung verwenden. Klingt gut, oder?

Nun, wie es sich herausstellt, es so zu tun, ist keine so gute Idee. Sie sehen, dass String.Split ein Array von Zeichenfolgen erstellt, was bedeutet, dass ein neues Zeichenfolgenobjekt für jede Schlüsselwort (keyword), die ursprünglich in Ihrer Schlüsselwörterzeichenfolge enthalten ist, sowie ein weiteres Objekt für das Array. Huch! Wenn wir dies in einem bestimmten Kontext tun, sind dies viele Vergleiche, und Ihre zweizeilige Vergleichsfunktion erstellt jetzt eine sehr große Anzahl temporärer Objekte. Plötzlich wird der Garbage Collector sehr hart für Sie arbeiten, und selbst mit dem cleversten Sammlungsschema gibt es nur noch viel Müll zu sauber. Besser, eine Vergleichsfunktion zu schreiben, für die die Zuordnungen überhaupt nicht erforderlich sind.

Too-Large Zuordnungen

Bei der Arbeit mit einem herkömmlichen Zuteilungscode wie malloc()schreiben Programmierer häufig Code, der so wenige Aufrufe wie möglich an malloc() durchführt, da sie wissen, dass die Kosten für die Zuordnung vergleichsweise hoch sind. Dies führt zur Praxis der Zuweisung in Blöcken, oft spekulativ zuzuordnende Objekte, die wir möglicherweise benötigen, damit wir weniger Gesamtzuordnungen durchführen können. Die vorab zugewiesenen Objekte werden dann manuell aus einer Art Pool verwaltet, wodurch eine Art benutzerdefinierter Hochgeschwindigkeitszuordnung erstellt wird.

In der verwalteten Welt ist diese Praxis aus mehreren Gründen viel weniger überzeugend:

Erstens sind die Kosten für eine Zuordnung extrem niedrig – es gibt keine Suche nach kostenlosen Blöcken wie bei herkömmlichen Zuteilungen; Alles, was geschehen muss, ist die Grenze zwischen den freien und zugewiesenen Bereichen, die verschoben werden müssen. Die niedrigen Kosten für die Zuordnung bedeuten, dass der überzeugendste Grund für das Poolen einfach nicht vorhanden ist.

Zweitens, wenn Sie sich für eine Vorabzuweisung entscheiden, werden Sie natürlich mehr Zuordnungen vornehmen, als für Ihre unmittelbaren Anforderungen erforderlich sind, was wiederum zusätzliche Garbage Collections erzwingen könnte, die andernfalls unnötig gewesen wären.

Schließlich kann der Garbage Collector keinen Speicherplatz für Objekte zurückgewinnen, die Sie manuell wiederverwenden, da aus globaler Sicht alle diese Objekte, einschließlich der objekte, die derzeit nicht verwendet werden, weiterhin aktiv sind. Möglicherweise werden Sie feststellen, dass viel Arbeitsspeicher verschwendet wird, um einsatzbereite, aber nicht verwendete Objekte zur Hand zu halten.

Das heißt nicht, dass vorzuverzeigen immer eine schlechte Idee ist. Möglicherweise möchten Sie dies tun, um zu erzwingen, dass bestimmte Objekte zunächst zusammen zugeordnet werden, für instance, aber Sie werden wahrscheinlich feststellen, dass dies als allgemeine Strategie weniger überzeugend ist als in nicht verwaltetem Code.

Zu viele Zeiger

Wenn Sie eine Datenstruktur erstellen, die ein großes Gitter von Zeigern ist, treten zwei Probleme auf. Erstens wird es viele Objektschreibvorgänge geben (siehe Abbildung 3 unten), und zweitens, wenn es an der Zeit ist, diese Datenstruktur zu sammeln, werden Sie den Garbage Collector dazu bringen, alle diese Zeiger zu befolgen und sie bei Bedarf zu ändern, während sich die Dinge bewegen. Wenn Ihre Datenstruktur langlebig ist und sich nicht viel ändert, muss der Collector alle diese Zeiger nur besuchen, wenn vollständige Sammlungen (aufGen2-Ebene ) auftreten. Wenn Sie jedoch eine solche Struktur auf transitorischer Basis erstellen, z. B. im Rahmen der Verarbeitung von Transaktionen, dann zahlen Sie die Kosten viel häufiger.

Abbildung 3. Datenstruktur mit starken Zeigern

Datenstrukturen, die stark in Zeigern sind, können auch andere Probleme haben, die nicht mit der Garbage Collection-Zeit zusammenhängen. Wie bereits erwähnt, werden Objekte beim Erstellen zusammenhängend in der Reihenfolge der Zuordnung zugeordnet. Dies ist großartig, wenn Sie eine große, möglicherweise komplexe Datenstruktur erstellen, indem Sie für instance Informationen aus einer Datei wiederherstellen. Obwohl Sie über unterschiedliche Datentypen verfügen, sind alle Ihre Objekte im Arbeitsspeicher eng beieinander, was wiederum dem Prozessor hilft, schnellen Zugriff auf diese Objekte zu haben. Wenn die Zeit verstreicht und Ihre Datenstruktur geändert wird, müssen jedoch wahrscheinlich neue Objekte an die alten Objekte angefügt werden. Diese neuen Objekte wurden viel später erstellt und befinden sich daher nicht in der Nähe der ursprünglichen Objekte im Arbeitsspeicher. Selbst wenn der Garbage Collector Ihren Arbeitsspeicher komprimiert, werden Ihre Objekte nicht im Arbeitsspeicher gemischt, sondern nur zusammengeschoben, um den verschwendeten Speicherplatz zu entfernen. Die resultierende Störung kann mit der Zeit so schlimm werden, dass Sie möglicherweise geneigt sind, eine neue Kopie Ihrer gesamten Datenstruktur zu erstellen, alles schön verpackt, und lassen Sie die alte ungeordnete vom Sammler zu gegebener Zeit verurteilt werden.

Zu viele Wurzeln

Der Garbage Collector muss wurzeln natürlich zur Sammelzeit sonderlich behandeln – sie müssen immer aufgezählt und nach und nach berücksichtigt werden. Die Gen0-Sammlung kann nur so schnell sein, dass Sie ihr keine Flut von Wurzeln geben, die Sie berücksichtigen sollten. Wenn Sie eine tief rekursive Funktion erstellen würden, die viele Objektzeiger unter den lokalen Variablen enthält, kann das Ergebnis tatsächlich sehr kostspielig sein. Diese Kosten entstehen nicht nur durch die Berücksichtigung all dieser Wurzeln, sondern auch durch die übergroße Anzahl vonGen0-Objekten , die diese Wurzeln möglicherweise nicht sehr lange am Leben erhalten (siehe unten).

Zu viele Objektschreibvorgänge

Denken Sie erneut an unsere frühere Diskussion, dass jedes Mal, wenn ein verwaltetes Programm einen Objektzeiger ändert, auch der Schreibbarrierecode ausgelöst wird. Dies kann aus zwei Gründen schlecht sein:

Erstens können die Kosten der Schreibbarriere mit den Kosten für das, was Sie überhaupt tun wollten, vergleichbar sein. Wenn Sie für instance einfache Vorgänge in einer Art Enumeratorklasse ausführen, stellen Sie möglicherweise fest, dass Sie bei jedem Schritt einige Ihrer Schlüsselzeiger aus der Standard-Auflistung in den Enumerator verschieben müssen. Dies ist eigentlich etwas, das Sie vermeiden möchten, da Sie die Kosten für das Kopieren dieser Zeiger aufgrund der Schreibsperre effektiv verdoppeln und es möglicherweise ein oder mehrmals pro Schleife auf dem Enumerator tun müssen.

Zweitens ist das Auslösen von Schreibbarrieren doppelt schlecht, wenn Sie tatsächlich für ältere Objekte schreiben. Wenn Sie Ihre älteren Objekte ändern, erstellen Sie effektiv zusätzliche Stamme, um zu überprüfen (oben erläutert), wann die nächste Garbage Collection erfolgt. Wenn Sie genug von Ihren alten Objekten geändert haben, würden Sie effektiv die üblichen Geschwindigkeitsverbesserungen negieren, die mit dem Sammeln nur der jüngsten Generation verbunden sind.

Diese beiden Gründe werden natürlich durch die üblichen Gründe ergänzt, nicht zu viele Schreibvorgänge in irgendeiner Art von Programm zu machen. Alle Dinge sind gleich, es ist besser, weniger Arbeitsspeicher zu berühren (in der Tat Lesen oder Schreiben), um den Cache des Prozessors sparsamer zu nutzen.

Zu viele fast langlebige Objekte

Schließlich ist die vielleicht größte Tücke des generationsalen Garbage Collector die Erstellung vieler Objekte, die weder genau temporär noch genau langlebig sind. Diese Objekte können viele Probleme verursachen, da sie nicht von einer Gen0-Auflistung (die billigste) bereinigt werden, da sie immer noch notwendig sein werden und möglicherweise sogar eineGen1-Auflistung überleben, weil sie noch in Gebrauch sind, aber sie sterben bald danach.

Das Problem ist, sobald ein Objekt auf derGen2-Ebene angekommen ist, wird es nur von einer vollständigen Auflistung entfernt, und vollständige Sammlungen sind ausreichend kostspielig, dass der Garbage Collector sie so lange verzögert, wie es vernünftigerweise möglich ist. Das Ergebnis vieler "fast langlebiger" Objekte ist also, dass Ihr Gen2 tendenziell wächst, möglicherweise mit einer alarmierenden Rate; es wird möglicherweise nicht fast so schnell bereinigt, wie Sie möchten, und wenn es bereinigt wird, wird es sicherlich viel teurer, dies zu tun, als Sie sich vielleicht gewünscht hätten.

Um diese Art von Objekten zu vermeiden, gehen Ihre besten Verteidigungslinien wie folgt aus:

  1. Ordnen Sie so wenige Objekte wie möglich zu, und beachten Sie dabei die Menge des temporären Speicherplatzes, den Sie verwenden.
  2. Halten Sie die längeren Objektgrößen auf ein Minimum.
  3. Behalten Sie so wenige Objektzeiger auf Ihrem Stapel wie möglich bei (das sind Wurzeln).

Wenn Sie diese Dinge tun, sind Ihre Gen0-Sammlungen wahrscheinlich sehr effektiv, und Gen1 wird nicht sehr schnell wachsen. Daher könnenGen1-Sammlungen weniger häufig durchgeführt werden, und wenn es ratsam ist, eineGen1-Sammlung zu erstellen, sind Ihre Objekte der mittleren Lebensdauer bereits tot und können zu diesem Zeitpunkt kostengünstig wiederhergestellt werden.

Wenn es gut läuft, wird ihreGen-2-Größe während des Steady-State-Betriebs überhaupt nicht größer!

Abschluss

Nachdem wir nun einige Themen mit dem vereinfachten Zuordnungsmodell behandelt haben, möchte ich die Dinge ein wenig verkomplizieren, damit wir ein weiteres wichtiges Phänomen besprechen können, und das sind die Kosten für Finalisierer und Finalisierungen. Kurz gesagt, ein Finalizer kann in jeder Klasse vorhanden sein– es ist ein optionales Element, das der Garbage Collector verspricht, ansonsten tote Objekte aufzurufen, bevor er den Arbeitsspeicher für dieses Objekt zurückgewinnt. In C# verwenden Sie die Syntax ~Class, um den Finalizer anzugeben.

Auswirkungen der Finalisierung auf die Sammlung

Wenn der Garbage Collector zum ersten Mal auf ein Objekt stößt, das andernfalls tot ist, aber noch finalisiert werden muss, muss er seinen Versuch, den Platz für dieses Objekt zurückzufordern, zu diesem Zeitpunkt aufgeben. Das -Objekt wird stattdessen einer Liste von Objekten hinzugefügt, die abgeschlossen werden müssen, und darüber hinaus muss der Collector sicherstellen, dass alle Zeiger innerhalb des Objekts gültig bleiben, bis die Finalisierung abgeschlossen ist. Dies ist im Grunde dasselbe wie die Aussage, dass jedes Objekt, das finalisiert werden muss, aus Der Sicht des Collectors wie ein temporäres Stammobjekt ist.

Sobald die Auflistung abgeschlossen ist, durchläuft der treffend benannte Finalisierungsthread die Liste der Objekte, die finalisiert werden müssen, und ruft die Finalizer auf. Wenn dies geschieht, werden die Objekte wieder tot und werden auf natürliche Weise gesammelt.

Finalisierung und Leistung

Mit diesem grundlegenden Verständnis der Finalisierung können wir bereits einige sehr wichtige Dinge ableiten:

Erstens leben Objekte, die eine Finalisierung benötigen, länger als Objekte, die dies nicht tun. Tatsächlich können sie viel länger leben. Für instance angenommen, ein Objekt, das sich in gen2 befindet, muss finalisiert werden. Die Finalisierung wird geplant, aber das Objekt befindet sich noch in der2. Generation, sodass es erst wieder gesammelt wird, wenn die nächsteGen2-Auflistung erfolgt. Das kann tatsächlich sehr lange dauern, und wenn es gut läuft, wird es eine lange Zeit dauern, daGen2-Sammlungen teuer sind und wir daher wollen , dass sie sehr selten passieren. Ältere Objekte, die eine Finalisierung benötigen, müssen möglicherweise dutzende, wenn nicht Hunderte vonGen0-Sammlungen warten, bevor ihr Speicherplatz zurückgewonnen wird.

Zweitens verursachen Objekte, die finalisiert werden müssen, Kollateralschäden. Da die internen Objektzeiger gültig bleiben müssen, verbleiben nicht nur die Objekte, die direkt die Finalisierung benötigen, im Arbeitsspeicher, sondern alles, auf das sich das Objekt direkt und indirekt bezieht, auch im Arbeitsspeicher. Wenn eine riesige Struktur von Objekten durch ein einzelnes Objekt verankert wurde, das eine Finalisierung erforderte, würde die gesamte Struktur möglicherweise lange bleiben, wie wir gerade erläutert haben. Daher ist es wichtig, Finalizer sparsam zu verwenden und sie auf Objekten zu platzieren, die so wenige interne Objektzeiger wie möglich haben. Im soeben erwähnten Strukturbeispiel können Sie das Problem einfach vermeiden, indem Sie die ressourcen, die finalisiert werden müssen, in ein separates Objekt verschieben und einen Verweis auf dieses Objekt im Stamm der Struktur beibehalten. Mit dieser bescheidenen Änderung bliebe nur das eine Objekt (hoffentlich ein schönes kleines Objekt) und die Finalisierungskosten werden minimiert.

Schließlich erstellen Objekte, die eine Finalisierung benötigen, Arbeit für den Finalisierungsthread. Wenn ihr Abschlussprozess komplex ist, wird der einzige Finalisierungsthread viel Zeit mit der Ausführung dieser Schritte aufwenden, was zu einem Backlog der Arbeit führen kann und daher mehr Objekte auf die Finalisierung warten. Daher ist es von entscheidender Bedeutung, dass die Finalisierer so wenig Wie möglich arbeiten. Denken Sie auch daran, dass alle Objektzeiger während der Finalisierung zwar gültig bleiben, es jedoch sein kann, dass diese Zeiger zu Objekten führen, die bereits abgeschlossen wurden und daher weniger nützlich sind. Es ist im Allgemeinen am sichersten, die Folgenden von Objektzeigern im Finalisierungscode zu vermeiden, obwohl die Zeiger gültig sind. Ein sicherer, kurzer Abschlusscodepfad ist der beste.

IDisposable und Dispose

In vielen Fällen ist es möglich, dass Objekte, die andernfalls immer abgeschlossen werden müssten, diese Kosten durch die Implementierung der IDisposable-Schnittstelle vermeiden. Diese Schnittstelle bietet eine alternative Methode zum Wiederherstellen von Ressourcen, deren Lebensdauer dem Programmierer bekannt ist, und das geschieht tatsächlich einiges. Natürlich ist es immer noch besser, wenn Ihre Objekte einfach nur Arbeitsspeicher verwenden und daher überhaupt keine Finalisierung oder Entsorgung erfordern; Wenn die Finalisierung jedoch erforderlich ist und es viele Fälle gibt, in denen die explizite Verwaltung Ihrer Objekte einfach und praktisch ist, dann ist die Implementierung der IDisposable-Schnittstelle eine gute Möglichkeit, die Finalisierungskosten zu vermeiden oder zumindest zu reduzieren.

Im C#-Sprachgebrauch kann dieses Muster durchaus nützlich sein:

class X:  IDisposable
{
   public X(…)
   {
   … initialize resources … 
   }

   ~X()
   {
   … release resources … 
   }

   public void Dispose()
   {
// this is the same as calling ~X()
        Finalize(); 

// no need to finalize later
System.GC.SuppressFinalize(this); 
   }
};

Dabei entfällt ein manueller Aufruf von Dispose , dass der Collector das Objekt am Leben hält und den Finalizer aufruft.

Zusammenfassung

Der .NET-Garbage Collector bietet einen Hochgeschwindigkeitszuordnungsdienst mit guter Speichernutzung und ohne langfristige Fragmentierungsprobleme, es ist jedoch möglich, Dinge zu tun, die Ihnen viel weniger als optimale Leistung bieten.

Um das Beste aus dem Allocator herauszuholen, sollten Sie Methoden wie die folgenden in Betracht ziehen:

  • Ordnen Sie den gesamten Arbeitsspeicher (oder so viel wie möglich) zu, der mit einer bestimmten Datenstruktur gleichzeitig verwendet werden soll.
  • Entfernen Sie temporäre Zuordnungen, die mit geringem Aufwand vermieden werden können.
  • Minimieren Sie die Häufigkeit, mit der Objektzeiger geschrieben werden, insbesondere die Schreibvorgänge, die an ältere Objekte vorgenommen werden.
  • Reduzieren Sie die Dichte von Zeigern in Ihren Datenstrukturen.
  • Verwenden Sie die Finalisierer nur in begrenztem Umfang und dann nur für "Blatt"-Objekte, so weit wie möglich. Unterbrechen Sie objekte bei Bedarf, um dies zu unterstützen.

Die regelmäßige Überprüfung Ihrer wichtigen Datenstrukturen und die Durchführung von Speichernutzungsprofilen mit Tools wie Allocation Profiler trägt dazu bei, dass Ihre Speichernutzung effektiv bleibt und der Garbage Collector optimal für Sie funktioniert.