(0) exportieren Drucken
Alle erweitern
Erweitern Minimieren

Garbage Collector-Grundlagen und Tipps zur Leistung

Veröffentlicht: 12. Jun 2003 | Aktualisiert: 24. Jun 2004
Von Rico Mariani

Auf dieser Seite

Einführung Einführung
Vereinfachtes Modell Vereinfachtes Modell
Garbage Collection Garbage Collection
Leistung Leistung
Beendigung Beendigung
Schlussfolgerung Schlussfolgerung

Einführung

Um zu wissen, wie Sie den Garbage Collector optimal einsetzen und welche Probleme bei der Ausführung in einer Garbage Collection-Umgebung auftreten können, müssen Sie zunächst die grundlegende Funktionsweise von Garbage Collectors und die Auswirkungen deren interner Mechanismen auf laufende Programme verstehen.

Dieser Artikel besteht aus zwei Teilen. Zunächst erläutere ich die allgemeinen Konzepte des Garbage Collectors der Common Language Runtime (CLR) anhand eines vereinfachten Modells. Anschließend werden einige Leistungsauswirkungen dieser Struktur beschrieben.

Vereinfachtes Modell

Aus Gründen der Übersichtlichkeit legen wir dem verwalteten Heap das folgende vereinfachte Modell zugrunde. Beachten Sie, dass dies nicht der tatsächlichen Implementierung entspricht.

dotnetgcbasics_01.gif

Abbildung 1. Vereinfachtes Modell des verwalteten Heap

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

  • Alle Garbage Collection-fähigen Objekte werden aus einem zusammenhängenden Adressbereich zugeordnet.

  • Der Heap ist in Generationen (später mehr dazu) unterteilt, so dass es möglich ist, einen Großteil des Garbage durch bloße Betrachtung eines kleinen Teils des Heaps zu eliminieren.

  • Objekte innerhalb einer Generation sind alle ungefähr gleich alt.

  • Generationen mit höherer Nummerierung deuten auf Heapbereiche mit älteren Objekten hin, die in der Regel stabiler sind.

  • Die ältesten Objekte haben die niedrigsten Adressen, während neue Objekte mit immer höheren Adressen erstellt werden (siehe Abbildung 1 oben).

  • Der Zuordnungszeiger für neue Objekte markiert die Grenzen zwischen den verwendeten (zugeordneten) und nicht verwendeten (freien) Speicherbereichen.

  • Der Heap wird regelmäßig komprimiert, indem deaktivierte Objekte entfernt und aktive Objekte in den Niedrigadressbereich des Heaps verschoben werden. Auf diese Weise wird der ungenutzte Bereich am unteren Diagrammende ausgeweitet, in dem neue Objekte erstellt werden.

  • Die Objekte werden im Arbeitsspeicher in der Reihenfolge ihrer Erstellung angeordnet.

  • Im Heap gibt es keine Lücken zwischen Objekten.

  • Nur ein kleiner Teil des freien Speichers ist reserviert. Bei Bedarf wird vom Betriebssystem weiterer Arbeitsspeicher im reservierten Adressbereich angefordert.

Garbage Collection

Die verständlichste Form der Auflistung ist die vollständig komprimierende Garbage Collection, die im Anschluss erörtert wird.

Vollständige Auflistungen
Bei einer vollständigen Auflistung müssen wir die Programmausführung anhalten und alle Stämme im GC-Heap finden. Diese Stämme treten in den verschiedensten Formen auf, meist jedoch als Stapel- und globale Variablen, die auf den Heap zeigen. Beginnend mit den Stämmen besuchen wir jedes Objekt und folgen allen Objektzeigern, die in allen besuchten Objekten enthalten sind. Dabei markieren wir die Objekte. Auf diese Weise findet der Collector alle errreichbaren bzw. aktiven Objekte. Die übrigen (nicht erreichbaren) Objekte werden jetzt verworfen.

dotnetgcbasics_02.gif

Abbildung 2. Stämme in den GC-Heap

Sobald die nicht erreichbaren Objekte identifiziert wurden, möchten wir diesen Speicherplatz für eine spätere Verwendung freigeben. Das Ziel des Collectors besteht zu diesem Zeitpunkt darin, die aktiven Objekte nach oben zu verschieben und den verschwendeten Speicherplatz wieder freizugeben. Bei angehaltener Ausführung ist es für den Collector sicher, all diese Objekte zu verschieben und alle Zeiger festzulegen, so dass alles korrekt mit dem neuen Speicherort verknüpft ist. Die verbleibenden Objekte avancieren in die nächsthöhere Generationsnummer (d.h., die Grenzen für die Generationen werden aktualisiert), und die Ausführung kann fortgesetzt werden.

Teilauflistungen
Leider ist die vollständige Garbage Collection auf Dauer zu kostspielig. Aus diesem Anlass sollten wir an dieser Stelle erläutern, welchen Nutzen uns Generationen in der Auflistung bringen.

Zunächst betrachten wir ein fiktives Szenario, in dem wir richtig Glück haben. Nehmen wir an, es wurde vor kurzem eine vollständige Auflistung durchgeführt, und der Heap ist weitgehend komprimiert. Die Programmausführung wird fortgesetzt, und es erfolgen einige Zuordnungen. Tatsächlich werden Unmengen von Zuordnungen vorgenommen, und nach einer bestimmten Anzahl von Zuordnungen beschließt das Speicherverwaltungssystem, dass es jetzt an der Zeit für eine Auflistung sei.

Hier kommt nun der glückliche Zufall ins Spiel. Nehmen wir an, dass wir in der ganzen Zeit seit der letzten Auflistung nicht auf den älteren Objekten geschrieben haben, sondern nur in neu zugeordneten Objekten der Generation Null (gen0). Das wäre ein echter Glücksfall, denn so können wir den Garbage Collection-Prozess erheblich vereinfachen.

Anstatt der üblichen vollständigen Auflistung können wir einfach annehmen, dass alle älteren Objekte (gen1, gen2) noch aktiv sind bzw. zumindest noch so viele dieser Objekte aktiv sind, dass es sich nicht lohnt, sie zu berücksichtigen. Da zudem keine solchen Objekte geschrieben wurden (wir hatten ja schließlich Glück!), gibt es keine Zeiger von den älteren Objekten auf die neueren Objekte. Was wir tun können, ist die Stämme wie üblich zu betrachten, und Stämme, die auf alte Objekte zeigen, einfach zu ignorieren. Bei anderen Stämmen (die auf gen0 zeigen) gehen wir einfach wie gewohnt vor, d.h. folgen allen Zeigern. Wenn wir einen internen Zeiger finden, der auf ältere Objekte verweist, ignorieren wir ihn.

Wenn dieser Prozess abgeschlossen ist, haben wir alle aktiven Objekte in gen0 besucht, jedoch keine Objekte der älteren Generationen. Die gen0-Objekte können nun wie üblich verworfen werden, und wir avancieren nur diesen Speicherbereich - die älteren Objekte bleiben unverändert.

Dies ist eine wirklich ideale Situation für uns, da wir sicher sein können, dass die jüngeren Objekte einen Großteil des deaktivierten Speicherplatzes belegen. Viele Klassen erstellen temporäre Objekte für ihre Rückgabewerte, temporäre Zeichenfolgen und weitere verwandte Dienstprogrammklassen wie Enumeratoren und Ähnliches. Wenn wir nur gen0 betrachten, können wir unter Berücksichtigung einiger weniger Objekte einen Großteil des deaktivierten Speichers wiederherstellen.

Leider haben wir nie das Glück, diesen Ansatz in die Tat umsetzen zu können, da sich zumindest einige der älteren Objekte immer ändern und somit auf neue Objekte zeigen. Das können wir nicht einfach ignorieren.

Generationen arbeiten mit Schreibbarrieren Damit der obige Algorithmus funktioniert, müssen wir wissen, welche älteren Objekte modifiziert wurden. Um uns den Speicherort der geänderten Objekte zu merken, verwenden wir eine Datenstruktur (die Kartentabelle), und zur Verwaltung dieser Datenstruktur generiert der verwaltete Codecompiler so genannte Schreibbarrieren. Diese beiden Konzepte bilden die Erfolgsgrundlage für eine generationsbasierte Garbage Collection.

Die Kartentabelle kann auf vielerlei Weise implementiert werden. Die einfachste Implementierung besteht jedoch darin, sich die Kartentabelle als Bitarray vorzustellen. Jedes Bit in der Kartentabelle stellt einen Speicherbereich im Heap dar - sagen wir, 128 Byte. Jedes Mal, wenn ein Programm ein Objekt in eine Adresse schreibt, muss der Schreibbarrierencode berechnen, welcher 128-Byte-Chunk geschrieben wurde, und anschließend das entprechende Bit in der Kartentabelle setzen.

Nachdem dieser Mechanismus nun eingerichtet ist, können wir uns erneut dem Auflistungsalgorithmus widmen. Wenn wir eine gen0-Garbage Collection durchführen, können wir den Algorithmus wie oben beschrieben einsetzen und dabei alle Zeiger auf ältere Generationen ignorieren. Dann müssen wir jedoch alle Objektzeiger in allen Objekten finden, die sich in einem Chunk befinden, der in der Kartentabelle als modifiziert markiert wurde. Diese werden anschließend wie Stämme behandelt. Wenn wir diese Zeiger ebenfalls berücksichtigen, sammeln wir korrekterweise nur die gen0-Objekte.

Dieser Ansatz wäre wenig hilfreich, wenn die Kartentabelle stets voll wäre. In der Praxis werden jedoch nur relativ wenige Zeiger der älteren Generationen verändert, so dass dieser Ansatz eine erhebliche Ersparnis bedeutet.

Leistung

Nachdem wir nun ein Grundmodell definiert haben, sollten wir einige Faktoren in Betracht ziehen, deren Fehlschlagen das ganze System verlangsamen könnte. Dies vermittelt uns ein realistisches Bild davon, welche Vorgehensweisen wir vermeiden sollten, um eine optimale Collectorleistung zu erzielen.

Zu viele Zuordnungen
Dies ist häufig das Hauptproblem. Das Zuordnen von neuem Arbeitsspeicher mit dem Garbage Collector erfolgt außerordentlich schnell. Wie Sie in der obigen Abbildung 2 sehen, muss i.d.R. lediglich der Zuordnungszeiger verschoben werden, um auf der "zugeordneten" Seite Platz für das neue Objekt zu schaffen. Noch schneller geht es nicht. Früher oder später muss eine Garbage Collection durchgeführt werden, diese sollte jedoch alles in allem so spät wie möglich erfolgen. Beim Erstellen von neuen Objekten sollten Sie sicherstellen, dass dies wirklich erforderlich und angebracht ist, auch wenn die Erstellung eines einzelnen Objekts nicht viel Zeit in Anspruch nimmt.

Dies mag eine Binsenweisheit sein, doch tatsächlich wird häufig vergessen, dass eine einzige Codezeile eine Vielzahl von Zuordnungen auslösen kann. Nehmen wir z.B. an, Sie schreiben eine Vergleichsfunktion. Nehmen wir weiter an, Ihre Objekte besitzen ein Schlüsselwortfeld, und Ihr Vergleich soll für die Schlüsselwörter in der gegebenen Reihenfolge die Groß-/Kleinschreibung nicht beachten. In diesem Fall können Sie nicht einfach die gesamte Schlüsselwortzeichenfolge vergleichen, denn das erste Schlüsselwort ist u.U. sehr kurz. Es ist äußerst verlockend, String.Split einzusetzen, um das Schlüsselwort in Segmente zu unterteilen und diese Segmente in der gegebenen Reihenfolge mit der normalen Vergleichsfunktion (ohne Beachtung der Groß-/Kleinschreibung) zu vergleichen. Klingt toll, oder?

Leider hat sich herausgestellt, dass diese Vorgehensweise keine besonders gute Idee ist. Der Grund dafür ist Folgender: String.Split erstellt ein Zeichenfolgenarray, d.h. ein neues Zeichenfolgenobjekt für jedes in der ursprünglichen Schlüsselwortzeichenfolge enthaltene Schlüsselwort plus ein weiteres Objekt für das Array. Oh Schreck! Wenn wir das in diesem Kontext tun, bedeutet dies eine Unmenge von Vergleichen, und Ihre zweizeilige Vergleichsfunktion erstellt eine Vielzahl von temporären Objekten. Plötzlich muss der Garbage Collector verstärkt für Sie arbeiten, und selbst mit einem richtig cleveren Auflistungsschema ergibt sich eine Menge Datenmüll, der bereinigt werden muss. Es ist besser, eine Vergleichsfunktion zu schreiben, die überhaupt keine Zuordnungen erfordert.

Zu große Zuordnungen
Bei der Arbeit mit einer herkömmlichen Zuweisung, z.B. malloc(), schreiben Programmierer häufig Code, der möglichst wenige Aufrufe an malloc() durchführt, da sie wissen, dass die Zuordnungskosten vergleichsweise hoch sind. Dies wird in Form der Zuordnung in Chunks umgesetzt, wobei häufig rein spekulativ erforderliche Objekte zugeordnet werden, damit wir mit weniger Zuordnungen insgesamt auskommen. Die vorab zugeordneten Objekte werden dann manuell über eine Art Pool verwaltet, wobei effektiv eine benutzerdefinierte Hochgeschwindigkeitszuweisung erstellt wird.

Bei verwaltetem Code ist diese Vorgehensweise aus den folgenden Gründen weniger ratsam:

Zunächst einmal sind die Kosten für eine Zuordnung extrem gering - es muss nicht, wie bei herkömmlichen Zuweisungen, nach freien Blöcken gesucht werden. Lediglich die Grenzen zwischen den freien und den zugeordneten Bereichen müssen verschoben werden. Aufgrund der niedrigen Zuordnungskosten entfällt der zwingendste Grund für das Pooling.

Zweitens: Wenn Sie eine Vorabzuordnung vornehmen, werden natürlich mehr Zuordnungen durchgeführt, als zwingend erforderlich sind. Folglich werden zusätzliche Garbage Collections erzwungen, die andernfalls nicht nötig gewesen wären.

Und schließlich: Der Garbage Collector kann keinen Speicherplatz für Objekte freigeben, die Sie manuell recyceln, da global gesehen all diese Objekte, einschließlich jener, die derzeit nicht verwendet werden, noch aktiv sind. Sie werden feststellen, dass viel Arbeitsspeicher dafür verschwendet wird, verwendungsbereite, aber derzeit nicht verwendete Objekte parat zu halten.

Das soll jedoch nicht heißen, dass generell von einer Vorabzuordnung abzuraten wäre. Diese ist z.B. ratsam, wenn Sie eine anfängliche gemeinsame Zuordnung bestimmter Objekte erzwingen möchten. Sie werden jedoch feststellen, dass diese Vorgehensweise als allgemeine Strategie längst nicht so zwingend erforderlich ist wie in unverwaltetem Code.

Zu viele Zeiger
Wenn Sie eine Datenstruktur erstellen, die ein komplexes Netz von Zeigern ist, sehen Sie sich vor zwei Probleme gestellt. Zum einen werden eine Vielzahl von Objektschreibvorgängen durchgeführt (siehe Abbildung 3 unten), und zum anderen folgt der Garbage Collector, wenn es an der Zeit für eine Auflistung dieser Datenstruktur ist, all diesen Zeigern und ändert sie ggf., während Objekte verschoben werden. Wenn Ihre Datenstruktur langlebig ist und sich kaum ändert, muss der Collector nur dann all diese Zeiger besuchen, wenn vollständige Auflistungen erfolgen (auf gen2-Ebene). Wenn Sie eine solche Struktur auf Übergangsbasis erstellen, z.B. bei der Verarbeitung von Transaktionen, fallen diese Kosten wesentlich häufiger an.

dotnetgcbasics_03.gif

Abbildung 3. Datenstruktur mit vielen Zeigern

Datenstrukturen mit vielen Zeigern können auch andere Probleme mit sich bringen, die nichts mit der Garbage Collection-Zeit zu tun haben. Wie bereits erläutert, werden Objekte in der Reihenfolge ihrer Erstellung zugeordnet. Dies ist äußerst praktisch, wenn Sie eine große, u.U. komplexe Datenstruktur erstellen, indem Sie z.B. Daten aus einer Datei wiederherstellen. Auch wenn Sie unterschiedliche Datentypen verwenden, liegen Ihre Objekte im Speicher alle eng beisammen, so dass der Prozessor schnell auf diese Objekte zugreifen kann. Im Laufe der Zeit wird Ihre Datenstruktur jedoch modifiziert, und den alten Objekten müssen höchstwahrscheinlich neue Objekte angefügt werden. Diese neuen Objekte wurden wesentlich später erstellt und befinden sich daher im Arbeitsspeicher nicht in der Nähe der ursprünglichen Objekte. Selbst wenn der Garbage Collector Ihren Arbeitsspeicher komprimiert, werden Ihre Objekte im Speicher nicht verschoben, sondern "rutschen" nur zusammen, um den nicht mehr benötigten Speicher freizugeben. Die sich daraus ergebende Unordnung kann im Laufe der Zeit solche Ausmaße annehmen, dass es erforderlich wird, eine neue Kopie der gesamten Datenstruktur zu erstellen, in der alles übersichtlich verpackt ist, und die chaotische alte Datenstruktur zu gegebener Zeit vom Collector verwerfen zu lassen.

Zu viele Stämme
Der Garbage Collector muss den Stämmen natürlich zur Auflistungszeit eine besondere Behandlung zukommen lassen: sie müssen stets enumeriert und der Reihe nach hinreichend berücksichtigt werden. Die gen0-Auflistung kann nur dann schnell sein, wenn Sie sie nicht zwingen, eine Unmenge von Stämmen zu berücksichtigen. Wenn Sie eine äußerst rekursive Funktion erstellen, die viele Objektzeiger unter ihren lokalen Variablen hat, kann das Ergebnis äußerst kostspielig sein. Die Kosten entstehen nicht nur dadurch, dass all diese Stämme berücksichtigt werden müssen, sondern auch durch die extrem hohe Zahl von gen0-Objekten, die diese Stämme u.U. für recht kurze Zeit am Leben halten (wie weiter unten erläutert).

Zu viele Objektschreibvorgänge
Wie bereits weiter oben erläutert, wird bei jeder Änderung eines Objektzeigers durch ein verwaltetes Programm auch der Schreibbarrierencode ausgelöst. Dies kann aus zwei Gründen nachteilig sein.

Erstens sind die Kosten der Schreibbarriere mit den Kosten für die Aktion vergleichbar, die Sie ursprünglich auszuführen versucht hatten. Wenn Sie z.B. einfache Vorgänge in einer Enumeratorklasse durchführen, werden Sie feststellen, dass Sie einige Ihrer Schlüsselzeiger in jedem Schritt aus der Hauptauflistung in den Enumerator verschieben müssen. Dies sollten Sie jedoch vermeiden, da auf diese Weise die Kosten für das Kopieren dieser Zeiger aufgrund der Schreibbarriere verdoppelt werden und Sie diesen Vorgang pro Schleife im Enumerator ein- oder mehrmals ausführen müssen.

Zweitens ist das Auslösen von Schreibbarrieren von doppeltem Nachteil, wenn Sie de facto in ältere Objekte schreiben. Beim Ändern Ihrer älteren Objekte erstellen Sie effektiv weitere Stämme, um (wie oben erläutert) zu überprüfen, wann die nächste Garbage Collection durchgeführt wird. Wenn Sie entsprechend viele alte Objekte modifizieren, machen Sie die Geschwindigkeitsoptimierungen, die für gewöhnlich mit der Auflistung nur der jüngsten Generation verbunden sind, wieder zunichte.

Zu diesen beiden Faktoren kommen natürlich noch die üblichen Gründe dafür, in einem Programm nicht zu viele Schreibvorgänge durchzuführen. Alles im allem ist es besser, weniger Arbeitsspeicher (durch Lesen oder Schreiben) zu beanspruchen, um den Prozessorcache wirtschaftlicher zu nutzen.

Zu viele fast langlebige Objekte
Der größte Nachteil des Generationen-Garbage Collectors besteht jedoch wohl in der Erstellung zu vieler Objekte, die weder wirklich temporär noch wirklich langlebig sind. Diese Objekte verursachen viel Ärger, da sie sich nicht durch eine (wenig kostspielige) gen0-Auflistung bereinigen lassen, weiterhin notwendig sind und u.U. sogar eine gen1-Auflistung überleben, da sie noch in Verwendung sind, doch kurz danach deaktiviert werden.

Das Problem liegt darin, dass ein Objekt, sobald es auf der gen2-Ebene angelangt ist, nur durch eine vollständige Auflistung beseitigt werden kann, und vollständige Auflistungen so kostspielig sind, dass der Garbage Collector sie so lange wie möglich hinauszögert. Die Folge zu vieler "fast langlebiger" Objekte ist also, dass Ihre gen2-Auflistung in potenziell alarmierender Geschwindigkeit anwächst, die Bereinigung nicht annähernd so schnell erfolgt, wie Sie es gern hätten, und wenn sie erfolgt, wesentlich kostspieliger ist, als Ihnen lieb ist.

Die beste Strategie zur Vermeidung dieser Objekte ist Folgende:

  1. Ordnen Sie so wenig Objekte wie möglich zu, und beachten Sie den temporären Speicherplatz, den Sie verwenden.

  2. Halten Sie die langlebigeren Objekte möglichst klein.

  3. Bewahren Sie möglichst wenige Objektzeiger in Ihrem Stapel auf (dies sind die Stämme).


Wenn Sie diese Richtlinien beachten, sind Ihre gen0-Auflistungen effektiver, und gen1 wächst nur langsam an. Folglich müssen gen1-Auflistungen weniger häufig durchgeführt werden, und wenn eine gen1-Auflistung fällig ist, sind Ihre Objekte mittlerer Lebensdauer bereits deaktiviert und können ohne große Kosten zu diesem Zeitpunkt wiederhergestellt werden.

Wenn alles gut läuft, wächst die Größe Ihrer gen2-Auflistung bei Vorgängen mit konstantem Status überhaupt nicht an!

Beendigung

Nachdem wir nun einige Themen anhand des vereinfachten Zuordnungsmodells abgehandelt haben, möchte ich das Ganze etwas verkomplizieren, damit wir ein wesentlich wichtigeres Phänomen erörtern können: die Kosten für Finalizer und Finalization (Beendigung). Ein Finalizer kann in jeder beliebigen Klasse enthalten sein. Es handelt sich dabei um ein optionales Member, das der Garbage Collector für ansonsten deaktivierte Objekte aufruft, bevor der Arbeitsspeicher für dieses Objekt wieder freigegeben wird. In C# verwenden Sie zur Spezifizierung des Finalizers die ~Class-Syntax.

Auswirkungen der Beendigung auf die Auflistung
Wenn der Garbage Collector zum ersten Mal auf ein deaktiviertes, aber noch zu beendendes Objekt trifft, muss er seinen Versuch aufgeben, den Speicherplatz für dieses Objekt zu diesem Zeitpunkt freizugeben. Stattdessen wird das Objekt einer Liste von Objekten hinzugefügt, die beendet werden müssen, und der Collector muss zudem sicherstellen, dass alle Zeiger im Objekt gültig bleiben, bis die Beendigung abgeschlossen ist. Das bedeutet im Endeffekt, dass jedes Objekt, das beendet werden muss, aus Sicht des Collectors wie ein temporäres Stammobjekt ist.

Sobald die Auflistung abgeschlossen ist, durchläuft der Finalizerthread die Liste der Objekte, die beendet werden müssen, und ruft die Finalizer auf. Sobald dies erfolgt ist, werden die Objekte wieder deaktiviert und auf die übliche Weise natürlich aufgelistet.

Beendigung und Leistung
Mit diesem Grundwissen zur Beendigung können wir bereits einige wichtige Erkenntnisse herleiten: Erstens haben Objekte, die beendet werden müssen, eine längere Lebensdauer als Objekte ohne Beendigung. Tatsächlich leben sie wesentlich länger. Nehmen wir z.B. an, ein Objekt in gen2 muss beendet werden. Die Beendigung wird eingeplant, aber das Objekt befindet sich weiterhin in gen2,, d.h., es wird erst neu aufgelistet, wenn die nächste gen2-Auflistung erfolgt. Dies kann sich recht lange hinziehen, und wenn alles gut läuft, wird es das auch, da gen2-Auflistungen kostspielig sind und wir möchten, dass sie selten erfolgen. Ältere Objekte, die beendet werden müssen, müssen Dutzende oder gar Hunderte von gen0-Auflistungen abwarten, bevor ihr Speicherplatz wieder freigegeben wird.

Zweitens können Objekte, die beendet werden müssen, Kollateralschäden verursachen. Da die internen Objektzeiger gültig bleiben müssen, verbleiben nicht nur die zu beendenden Objekte im Arbeitsspeicher, sondern ebenso alles, worauf das Objekt direkt oder indirekt verweist. Wenn eine große Objektverzeichnisstruktur durch ein einzelnes Objekt verankert ist, das beendet werden muss, verbleibt die gesamte Struktur im Speicher, und zwar, wie soeben erläutert, für einen potenziell recht langen Zeitraum. Daher ist es von äußerster Wichtigkeit, Finalizer sparsam einzusetzen und in Objekten zu platzieren, die so wenige interne Objektzeiger wie möglich haben. Im soeben erläuterten Verzeichnisstrukturbeispiel können Sie das auftretende Problem ganz leicht vermeiden, indem Sie die zu beendenden Ressourcen in ein separates Objekt verschieben und im Stamm dieser Verzeichnisstruktur einen Verweis auf das Objekt speichern. Dank dieser kleinen Änderung verweilt nur ein (hoffentlich kleines) Objekt im Speicher, und die Beendigungskosten werden minimiert.

Und schließlich bereiten zu beendende Objekte dem Finalizerthread Arbeit. Wenn Ihr Beendigungsprozess komplex ist, verbringt der eine Finalizerthread viel Zeit mit der Ausführung dieser Schritte. Dadurch entsteht ein Rückstau an Arbeit, und es verweilen mehr Objekte im Arbeitsspeicher, die auf ihre Beendigung warten. Daher ist es äußerst wichtig, dass Finalizer so wenig Arbeit wie möglich verrichten. Bedenken Sie, dass zwar alle Objektzeiger während der Beendigung ihre Gültigkeit behalten, aber möglicherweise zu Objekten führen, die bereits beendet wurden und daher wenig nützlich sind. In der Regel ist es am sichersten, die Nachverfolgung von Objektzeigern im Beendigungscode zu vermeiden, auch wenn die Zeiger gültig sind. Ein sicherer, kurzer Beendigungscodepfad ist am besten.

"IDisposable" und "Dispose"
In vielen Fällen ist es möglich, bei Objekten, die andernfalls beendet werden müssten, diese Kosten zu vermeiden, indem die IDisposable-Schnittstelle implementiert wird. Diese Schnittstelle bietet eine Alternativmethode für das Freigeben von Ressourcen, deren Lebensdauer dem Programmierer bekannt ist (was recht häufig der Fall ist). Natürlich ist es noch besser, wenn Ihre Objekte einfach Arbeitsspeicher verwenden und daher weder beendet noch verworfen werden müssen. Wenn jedoch eine Beendigung erforderlich ist (und in vielen Fällen ist die explizite Verwaltung Ihrer Objekte durchaus einfach und praktikabel), stellt die Implementierung der IDisposable-Schnittstelle eine gute Lösung dar, um die Beendigungskosten zu vermeiden oder wenigstens zu reduzieren.

In der C#-Sprache kann das folgende Codemuster äußerst 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);  
   } 
};

Hier ist es infolge eines manuellen Aufrufs an Dispose nicht mehr erforderlich, dass der Collector das Objekt aktiv hält und den Finalizer aufruft.

Schlussfolgerung

Der .NET Garbage Collector bietet einen Hochgeschwindigkeits-Zuordnungsdienst mit guter Speichernutzung und ohne langfristige Fragmentierungsprobleme. Es können jedoch Vorgänge durchgeführt werden, die die Leistung erheblich beeinträchtigen.

Für eine optimale Nutzung der Zuweisung sollten Sie folgende Vorgehensweisen wählen:

  • Ordnen Sie den gesamten für eine bestimmte Datenstruktur zu verwendenden Arbeitsspeicher (so weit wie möglich) gleichzeitig zu.

  • Entfernen Sie temporäre Zuordnungen, die ohne nennenswerte negative Auswirkungen auf die Komplexität vermieden werden können.

  • Minimieren Sie die Häufigkeit, mit der Objektzeiger geschrieben werden, insbesondere für Schreibvorgänge in älteren Objekten.

  • Verringern Sie die Zeigerdichte in Ihren Datenstrukturen.

  • Setzen Sie Finalizer nur begrenzt ein, und nur für "Zweigobjekte". Brechen Sie Objekte ggf. auf, um dies zu ermöglichen.

Wenn Sie Ihre wichtigsten Datenstrukturen regelmäßig überprüfen und mithilfe von Tools wie Allocation Profiler Speichernutzungsprofile erstellen, gewährleisten Sie eine effektive Speichernutzung und setzen den Garbage Collector optimal für Ihre Zwecke ein.


Microsoft führt eine Onlineumfrage durch, um Ihre Meinung zur MSDN-Website zu erfahren. Wenn Sie sich zur Teilnahme entscheiden, wird Ihnen die Onlineumfrage angezeigt, sobald Sie die MSDN-Website verlassen.

Möchten Sie an der Umfrage teilnehmen?
Anzeigen:
© 2014 Microsoft