.NET Framework stellt Entwicklern eine außerordentlich solide Plattform für das Erstellen und das Ver-wenden der unterschiedlichsten Typen zur Verfügung. Wer in seinem Entwicklerleben aber schon mit Speicherlöchern und Pufferüberläufen zu kämpfen hatte, der weiß eine Funktionalität von .NET besonders zu schätzen: dessen »Müllabfuhr« nämlich. Müllabfuhr heißt auf Englisch Garbage Collection, und genau das ist der technisch korrekte Ausdruck für den Teil der Common Language Runtime, die Objekte, die nicht mehr benötigt werden, identifiziert und bei Gelegenheit (wenn Zeit) oder Bedarf (wenn Platzmangel) entsorgt.
Um diese Vorgehensweise zu erörtern und deutlich zu machen, möchte ich noch einmal auf ein Beispiel zu sprechen kommen, das ursprünglich der Demonstration eines ganz anderen Themas diente. Sie erinnern sich noch an die Klasse DynamicList im Abschnitt zur Polymorphie, die die Beispielanwendung nutzte, um die Artikeldatensätze (ShopItem) zu speichern? Neben der eigentlichen Fähigkeit, eine Methode zu imple-mentieren, die eine dynamische Vergrößerung des benötigten Speichers demonstriert, zeigt dieses Beispiel noch etwas anderes – was allerdings mehr eine Fähigkeit von .NET Framework selbst ist: nämlich den nicht benötigten Speicher wieder freizugeben.
Sehen Sie sich die Add-Methode dieser Klasse
Sub Add(ByVal Item As ShopItem)
'Prüfen, ob aktuelle Arraygrenze erreicht wurde
If myCurrentCounter = myCurrentArraySize - 1 Then
'Neues Array mit mehr Speicher anlegen,
'und Elemente hinüberkopieren. Dazu:
'Neues Array wird größer:
myCurrentArraySize += myStepIncreaser
'Temporäres Array erstellen.
Dim locTempArray(myCurrentArraySize - 1) As ShopItem
'Elemente kopieren
'Wichtig: Um das Kopieren müssen Sie sich,
'anders als bei VB6, selber kümmern!
For locCount As Integer = 0 To myCurrentCounter
locTempArray(locCount) = myArray(locCount)
Next
'Temporäres Array dem Memberarray zuweisen.
myArray = locTempArray
End If
'Element im Array speichern.
myArray(myCurrentCounter) = Item
'Zeiger auf nächstes Element erhöhen.
myCurrentCounter += 1
End Sub
Schauen Sie sich diesen Codeblock noch einmal an, aber dieses Mal unter einem anderen Aspekt. Dieses Mal steht nicht der Speicherplatz im Vordergrund, der benötigt wird, und auch nicht die Art und Weise, wie Arrays wachsen können, sondern der Speicher, der durch denselben Vorgang überflüssig wird.

Was passiert mit den nach der Zuweisung im leeren Raum stehenden Array-Elementen?
Arrays in .NET sind keine Werte, sondern Verweistypen. Benötigter Speicher für alles, was von System.Array abgeleitet ist – und dazu zählen auch Arrays, die Sie durch Dim deklarieren –, wird also auf dem Managed Heap reserviert. Im Codeauszug des Beispielprogramms gibt es zwei entscheidende Zeilen, die eigentlich ein »Speicherleck«, besser bekannt unter dem neudeutschen Begriff »Memory Leak«, verursachen würden, würden wir nicht in Verbindung mit .NET Framework programmieren. Die obige Abbildung macht das Problem deutlich. Zunächst gibt es das Array und den auf dem Managed Heap dafür reservierten Speicherbereich, aber das Array ist nunmehr zu klein geworden. Also definiert die Prozedur ein neues Array und nimmt dafür die Variable locTempArray zu Hilfe. Nun passiert das Entscheidende: locTempArray wird myArray zugewiesen, der Zeiger auf den Speicherbereich der entsprechenden Elemente wird dabei quasi »verbogen«. myArray zeigt anschließend auf die Array-Elemente, auf die kurz zuvor noch locTempArray zeigte. Die Adressen auf die Elemente auf die von myArray verwiesen wurde, liegen nun unbrauchbar, da nicht mehr referenziert, irgendwo im Speicher – was passiert jetzt mit ihnen?
Erinnern wir uns, wie das bei COM (dem »Vorläufer« von .NET, eine Technologie, die Sie immer noch bewusst oder unbewusst benutzen, wenn Sie – auch in .NET – beispielsweise die Möglichkeit nutzen, Word oder Excel »fernzusteuern«) geregelt war. Bei COM gab es für jedes Objekt einen Referenzzähler. Bei der ersten Zuweisung an eine Objektvariable wurde der Zähler auf Eins gesetzt. Mit jeder weiteren Zuweisung an eine Variable – also mit jeder weiteren Referenzierung – wurde dieser Zähler um eins erhöht. Trat nun der umgekehrte Fall ein, das heißt, einer Variablen die zuvor das Objekt referenzierte, wurde ein anderes Objekt oder Nothing zugewiesen, oder das Programm verließ den Gültigkeitsbereich der Variablen, sodass sie aus diesem Grund das Objekt nicht mehr referenzieren konnte, wurde der Referenzzähler um eins verringert. Wurde er 0, dann konnte das Objekt entsorgt werden. Es wurde zu diesem Zeitpunkt nicht länger benötigt, da es von keiner Stelle des Programms aus mehr referenziert war.
Dieses Verfahren hatte allerdings zwei Nachteile: Zum einen kostete das Prinzip des Referenzzählers Re-chenzeit. Der andere Nachteil war das Problem der so genannten Zirkelverweise: Ein Objekt, das auf ein Objekt zeigte, das seinerseits wieder auf das Ausgangsobjekt zeigte, führte dazu, dass der Referenzzähler niemals null werden konnte. Selbst wenn es in diesem Fall keine Referenzierung mehr durch das eigentliche Programm gab, so referenzierten sich die Objekte dennoch selbst. Ein Speicherleck war oft genug die Folge.
.NET Framework oder um genau zu sein, die Common Language Runtime, löst dieses Problem, indem sie ein komplett anderes Verfahren anwendet, Objekte zu entsorgen.
Der Garbage Collector – die Müllabfuhr in .NET
Den vorhandenen Speicher des Managed Heap teilen sich alle Assemblys, die in einer so genannten Applica-tion Domain (»Anwendungsdomäne«, kurz »AppDomain«) laufen. Normale Windows-Anwendungen, die man parallel startet, werden durch verschiedene, streng voneinander abgeschot¬tete Prozesse isoliert. Ein Prozess kann unter normalen Umständen nicht auf einen anderen Prozess zugreifen, auch die Daten ver-schiedener Prozesse sind integer und können bestenfalls durch so genannte Proxys (Stellvertreter) unterei-nander ausgetauscht werden.
AppDomains in .NET, die durch die Common Language Runtime verwaltet werden, erlauben das gleichzei-tige Ausführen mehrerer Anwendungen in einem Prozess. Die CLR garantiert dabei, dass die Anwendungen ebenso isoliert und ungestört laufen können, wie das bei einem Windows-Prozess der Fall wäre. Jede App-Domain verwaltet einen Speicherbereich, in dem die notwendigen Daten der Assemblys und ProgrammeT, T die innerhalb der AppDomain laufen, abgelegt werden. Dieser Speicherbereich wird Managed Heap ge-nannt, und mit ihm haben wir uns bislang schon einige Male beschäftigt.
Wenn der Speicher im Managed Heap knapp wird (und auch sonst schon einmal aus anderen Gründen), dann startet ein Prozess, der im wahrsten Sinne des Wortes der Müllabfuhr im echten Leben entspricht: Während dieses Prozesses – also der Garbage Collection – werden Objekte im Speicher identifiziert, die nicht mehr in Gebrauch sind. Dies trifft für ein Objekt dann zu, wenn es durch keine Objektvariable der Anwendung mehr referenziert wird:
Die Beispieldateien können Sie hier herunterladen.
Public Class Form1
Private mykontakt As Kontakt
Private Sub Button1_Click(ByVal sender As System.Object,
ByVal e As System.EventArgs) Handles Button1.Click
'Bleibt erhalten.
Dim kontakt1 As New Kontakt With {.Nachname = "Wördehoff",
.Vorname = "Angela",
.PLZ = "99999",
.Ort = "Weißichabersagsnicht"}
'Wird gleich entsorgt.
Dim kontakt2 As New Kontakt With {.Nachname = "Löffelmann",
.Vorname = "Klaus",
.PLZ = "59555",
.Ort = "Lippstadt"}
mykontakt = kontakt1
'Nur zum Testen, sonst nicht machen!
GC.Collect()
End Sub
Private Sub Button2_Click(ByVal sender As System.Object,
ByVal e As System.EventArgs) Handles Button2.Click
'Nur zum Testen, sonst nicht machen!
GC.Collect()
End Sub
End Class
Public Class Kontakt
Public Property Nachname As String
Public Property Vorname As String
Public Property PLZ As String
Public Property Ort As String
Protected Overrides Sub Finalize()
Debug.Print("Finalizing:" & Nachname)
End Sub
End Class
Wenn Sie das Programm starten, sehen Sie ein Formular mit zwei Schaltflächen. Klicken Sie auf Objekte erstellen, dann werden die beiden Testobjekte kontakt1 (Angela Wördehoff) und kontakt2 (Klaus Löffelmann) erstellt. Kontakt1 wird anschließend der Klassenvariablen mykontakt zugewiesen, und auch wenn der Programm-Scope die Methode verlässt, bleibt damit eine Referenz auf das Objekt erhalten. Ein anschlie-ßender Klick auf Garbage Collector auslösen führt dann auch dazu, dass im Ausgabefenster von Visual Studio (im Bedarfsfall mit (Strg)+(Alt)+(O) aktivieren) der Text Finalizing:Löffelmann ausgegeben wird. Zum Objekt, das zuvor von kontakt2 referenziert wurde, gibt es nämlich keine Referenz mehr, also entsorgt der Garbage Collector das Objekt und ruft dazu dessen Finalizer auf.
Der Garbage Collector arbeitet auch bei Anwendungen, die in kurzer Zeit sehr viele entsorgbare Objekte hervorbringen, mit sehr großer Geschwindigkeit.
Wie der Garbage Collector arbeitet
Die hohe Geschwindigkeit, mit der der Garbage Collector (GC) arbeitet, ist insbesondere auch darauf zurückzuführen, dass der GC die zu testenden Objekte in Generationen klassifiziert. Bei der Entwicklung des GC-Algorithmus nahm man an, dass Objekte, die beim Start einer Applikation erzeugt werden, länger im Speicher verbleiben als solche, die irgendwann zwischendurch oder lokal in Prozeduren generiert werden. Diese Annahme führt zu dem Schluss, dass es Sinn ergibt, bei der Objektentsorgung eine Klassifizierung der Objekte in eben diese Generationen zur Optimierung des GC-Algorithmus vorzunehmen.
Der Garbage Collector markiert Objekte nicht nur für die weitere Instandhaltung, sondern er stattet sie auch mit einem Zähler aus, der aussagt, wie oft ein Objekt für die Entsorgung durch den Garbage Collector getestet wurde. Je öfter der Garbage Collector das Objekt bereits »besucht« und nicht entsorgt hat, desto älter ist logischerweise das Objekt (und umso höher ist demzufolge auch seine Generationsnummer), aber desto unwahrscheinlicher wird es auch, dass das Objekt in einem erneuten GC-Lauf entsorgt werden wird.
Das Ganze läuft also – vereinfacht dargestellt – folgendermaßen ab:
- Es wird Speicher für ein neues Objekt benötigt. Der Speicherbedarf auf dem Managed Heap erreicht dabei eine bestimmte Größe, die sich in etwa im Bereich moderner Prozessorcache-Größen befindet. Dann wird ein Garbage Collection-Durchgang ausgelöst.
- Alle Objekte werden als »untersucht« markiert, und es wird geschaut, welche entsorgt werden können. Generation 0-Objekte, die noch gültig sind, werden damit zu Generation 1-Objekten. Schon seit .NET 1.0 funktioniert das standardmäßig schon auf einem Thread, der parallel zum Hauptanwendungsthread läuft. Sollte es zu entsorgende Objekte geben, hält der Garbage Collector – vereinfacht gesprochen – alle laufenden Threads an und entsorgt die Objekte, auf die nicht mehr referenziert wird und die deshalb nicht mehr benötigt werden. Dieser Prozess ist so hoch optimiert, dass eine Generation 0-Garbage Coll-ection in der Regel im Millisekundenbereich geschieht. Auf diese Weise wird Platz geschaffen. Die zur Generation 1 promovierten, verbleibenden Objekte werden aus Performancegründen bei späteren regu-lären (kleinen) Läufen außen vor gelassen – der Garbage Collector nimmt nämlich zurecht an, dass Ob-jekte, die nicht beim ersten Mal entsorgt wurden, auch beim nächsten Durchlauf noch mit großer Wahrscheinlichkeit in Verwendung sind.
- Irgendwann ist eine weitere Schwelle mit Speicherbedarf für neue Objekte erreicht, und erst jetzt werden sowohl Generation 0- als auch Generation 1-Objekte unter die Lupe genommen, die bislang aus Performancegründen bei Generation-0-Durchläufen keine Beachtung mehr gefunden haben. Prinzipiell passiert hier nun das Gleiche wie in der Generation-0-Garbage Collection, nur dass dieses Mal auch Objekte entsorgt werden können, die sich bereits in der Generation 1 befunden haben.
- Das Gleiche passiert schließlich auch bei einer weiteren Schwelle, etwa in der 16 MB-Größenordnung. Immer noch verbleibende Objekte von Generation 1 werden nun Generation 2, Generation 0 wird wie-der zu Generation 1. Mit Generation 2 ist dann Schluss; der .NET Garbage Collector kennt nur diese 3 Generationen 0, 1 und 2.
Hinweis
Seit .NET 4.0 läuft ein Garbage Collector nicht nur gleichzeitig zum eigentlichen Programm, sondern als so genannter Hintergrundthread, bei dem trotz laufender Garbage Collection sogar Speicher für neue Objekte generiert werden kann, ohne dass der Garbage Collector das eigent-liche Programm (samt seiner Threads) anhalten muss, wie es bei älteren .NET Framework-Versionen noch der Fall war. Nur bei einem vollen Garbage Collector-Durchlauf muss das Anhalten noch passieren, sodass mit .NET 4.0 typische Clientanwendungen (beispielsweise Windows Forms oder WPF-Anwendungen) noch zügiger und störungsfreier laufen können. Für Serveranwendungen (ASP.NET-Anwendungen) gibt es diese Optimierung leider nicht.
Die Vorteile dieser mehrstufigen Vorgehensweise liegen auf der Hand: Die richtige Annahme, dass Objekte, die erst vor kurzem angelegt worden sind, auch nur kurz benötigt werden (Generation 0-Objekte), bringt einen enormen Geschwindigkeitsvorteil. Wirklich große Speicherblöcke müssen nur verschoben werden, wenn bestimmte Schwellen im MB-Bereich überschritten werden. Zudem wird bei den Schwellen Rücksicht auf die Prozessorcaches genommen, denn die Schwellen sind in etwa auf deren Größen abgestimmt.
Leider wirft diese Vorgehensweise wieder ein Problem ganz anderer Art auf. Objekte können nicht wissen, wann sie entsorgt werden – denn nur die CLR entscheidet, wann ein GC-Durchlauf stattfindet (mit einer Ausnahme):
- Die Common Language Runtime fährt herunter. Das passiert in der Regel dann, wenn eine .NET-Applikation beendet wird. Da mehrere AppDomains ihrerseits in einem Prozess laufen können und das Beenden einer AppDomain nicht unweigerlich zum Entladen der CLR führen muss, wird der Garbage Collector ebenfalls ausgeführt, wenn eine AppDomain entladen wird.
- Der Speicher wird knapp, entweder weil es zu viele Objekte gibt oder weil der Windows-Speicher aus anderen Gründen knapper wird. Der Garbage Collector startet dann, um zu sehen, ob Generation 0-Objekte entsorgt werden können und macht im Bedarfsfall davon Gebrauch. Das kann auch dann pas-sieren, wenn Windows-Speicher insgesamt zu knapp wird, denn die Common Language Runtime lässt sich über diesen Zustand von Windows informieren, sie muss diesen nicht notwendigerweise selbst feststellen.
- Der Garbage Collector ist in der AppDomain gezwungenermaßen durch die Anweisung GC.Collect() gestartet worden – aber ganz wichtig: Nicht zu Hause nachmachen, liebe Kinder, denn Microsoft rät davon dringend ab. Nur in ganz wenigen Ausnahmefällen kann es Sinn ergeben, den Garbage Collector selbst anzustoßen.
COM hatte beim Entsorgen eines Objekts, das nicht mehr benötigt wurde, einen eindeutigen Vorteil. Wurde die letzte Referenz aufgelöst und der Referenzzähler stand auf 0, dann trat das Terminate-Ereignis ein, und das Objekt konnte zur »richtigen« Zeit die notwendigen Schritte einleiten, um sich zu entsorgen.
Normalerweise ist es gar kein Problem, dass ein Objekt nicht weiß, dass es entsorgt wird. Wenn es weg ist, dann ist es eben weg. Wichtig, den Zeitpunkt seiner Entsorgung zu kennen, wird es für ein Objekt erst dann, wenn es Aufräumarbeiten erledigen muss, und zwar nicht hinsichtlich der eigenen Speicherverwaltung (denn wenn es andere Objekte referenziert, sorgt der Garbage Collector ja ebenfalls für deren Entsorgung), sondern hinsichtlich der Freigabe von Ressourcen, auf die der Garbage Collector keinen Zugriff hat.
Dies wurde übrigens früher oder wird heute noch bei anderen OOP-Sprachen mit einem Destruktor erle-digt. Genau wie wir den Konstruktor als »Ereignisprozedur« kennen gelernt haben, die ausgeführt wird, wenn ein Objekt erstellt wird, so gelangt der Destruktor zur Ausführung, wenn das Objekt zerstört wird.
Das sind beispielsweise Fälle, in denen das Objekt ein HandleT T auf eine bestimmte Geräte- oder Betriebssys-temressource erhalten hat. Damit dieses Handle wieder freigegeben werden kann – eine geöffnete Datei beispielsweise sollte geschlossen werden –, muss das Objekt die dafür erforderlichen Aktionen spätestens kurz vor dem Zeitpunkt, an dem es vom Garbage Collector zerstört wird, durchführen.
Genau das geht nicht mehr in .NET. Denn Objekte können dort nicht voraussehen oder den genauen Zeitpunkt erfahren, wann sie entsorgt werden. Es gibt allerdings die Möglichkeit, dass Objekte Kenntnis darüber erhalten, dass sie entsorgt werden, und dann die notwendigen Schritte einleiten, um Ressourcen freizugeben, die sie belegen.
Daher spricht man in .NET übrigens auch von »nicht deterministischen Destruktoren«, es ist also nicht voraussagbar (determiniert), wann der Destruktor läuft. Er wird nämlich dann ausgeführt, wenn es dem GC passt, und wir wissen nicht, wann es ihm passt.
Die Lösung zu diesem Problem liegt in der Implementierung einer Schnittstelle, die sich IDisposable nennt. Aber wie gesagt: Sie benötigen das im Regelfall nur dann, wenn Sie andere als die .NET-Ressourcen freigeben müssen.
Die Geschwindigkeit der Objektbereitstellung
.NET bzw. die .NET-Infrastruktur hatte seit ihrer ersten Veröffentlichung mit dem Vorurteil der langsamen Geschwindigkeit zu kämpfen. Nun liegt es in der Natur von Vorurteilen, dass an einigen von ihnen immer auch ein Quäntchen Wahres dran ist. So bildet das On-Demand-Kompilieren durch den JITter der CLR zwar sicherlich eine Technik mit vielen Vorteilen, aber sicherlich nicht in Sachen Geschwindigkeit. Doch man sollte gerade in Sachen Geschwindigkeit auch einmal eine Lanze für die .NET-Infrastruktur brechen.
Denn es gibt es einige Vorteile der .NET-Infrastruktur in puncto Geschwindigkeit, und einer liegt beim Schaffen von Speicherplatz für ein neues Objekt. Und seien wir ehrlich: Es ist in fast jedem Szenario besser, Objekte so schnell wie möglich zu erstellen, als sie so schnell wie möglich zu entsorgen. Und warum ist das Erstellen der Objekte so viel schneller als sagen wir beim herkömmlichen Runtime Heap eines typischen C-basierten Systems?
Bei einem C-Runtime-Heap liegen alle speicherreservierten Objekte (man sagt auch allokierte Objekte) als verkettete Liste vor. Wenn nun Speicherplatz für ein neues Objekt benötigt wird, muss die C-Runtime durch diese Liste iterieren, und falls Sie zwischen zwei Objekten ausreichenden Speicherplatz findet, bricht sie die Kette an dieser Stelle auf und platziert das neue Objekt an dieser Stelle.
In .NET Framework funktioniert das Reservieren des Speichers anders. Es gibt einen Adresszeiger, der auf den Speicherplatz zeigt, an dem das jeweils nächste Objekt abgelegt werden soll; neue Objekte werden also nicht irgendwo mittendrin platziert, sondern immer am Ende der Liste der Objekte im Managed Heap. Dieser Adresszeiger stellt also den jeweils nächsten Speicherstart für ein Objekt jederzeit zur Verfügung. Das Reservieren des Speichers kostet also, anders als bei den gängigen C-Runtime-Systemen, in .NET Framework keine erwähnenswerte Zeit, und das ist ein klarer Vorteil, den die CLR geschwindigkeitstechnisch für sich verbuchen kann.
In C-Runtime-Systemen kann es aus diesem Grund auch passieren, dass Objekte, die nacheinander erstellt werden, speichertechnisch weit voneinander entfernt liegen. Auch das passiert bei .NET Framework nicht. Objekte, die nacheinander erstellt werden, liegen auch hintereinander im Speicher.
Das ist natürlich hinsichtlich der Wahrscheinlichkeit sinnvoll, wie und wann für Objekte Speicher erstellt wird. Wenn Sie Speicher für eine Bitmap reservieren, und lassen wir dabei jetzt mal die Tatsache außen vor, dass dafür systembedingt auch Speicher anderen Typs reserviert werden muss, dann ist es auch wahrscheinlich, dass im gleichen Kontext, auch zeitlich, Objekte für Fonts und Pinsel erstellt werden müssen. Und vor allen Dingen, dass der Speicher dieser Objekte physisch in einem der schnellen Caches des Prozessors liegt. Bislang scheint also die .NET Framework-Speicherverwaltung nur Positives zu bringen, wenn es da nicht ein kleines Problem geben würde: Uns steht zwar inzwischen sehr, sehr viel Speicher zur Verfügung, aber dennoch ist dieser Speicher nicht unerschöpflich. Irgendwann muss eben ein Aufräumprozess stattfinden − die Garbage Collection −, und der kostet Zeit.
Aber immerhin: Der Algorithmus arbeitet eben mit den schon angesprochenen Generationen, und der Generation 0-Algorithmus ist so ausgelegt, dass er in der Regel noch nicht »anspringt«, solange sich der benötigte Speicher noch in einer Größenordnung befindet, die eine Unterbringung im schnellen Cache des Prozessors ermöglicht.
Der Kompromiss, den wir Entwickler eingehen müssen, um in den Genuss der extrem schnellen Speicheranforderung zu kommen, gepaart mit dem Vorteil, dass die Objekte, die wir gleichzeitig benutzen, möglichst eng beieinander und damit mit großer Wahrscheinlichkeit wieder im Prozessorcache liegen, ist damit durchaus vertretbar, wie ich finde.
Mehr zu diesem Thema in: Visual Basic 2010 das Entwicklerbuch von Klaus Löffelmann, erhältlich bei Amazon auch auf Englisch, und bei Microsoft Press. Möchten Sie Klaus Löffelmann oder das ActiveDevelop-Team für professionelle Software-Entwicklungsprojekte (Migration, C#, Visual Basic.NET) buchen, informieren Sie sich auf www.activedevelop.de
klauslo am 02. Dezember 2011
3b315a56-5f40-4a34-a8e6-637463f67542|1|5.0
Tags: |
Categories: Garbage Collector