Die Speicherverwaltung erfolgt in .NET automatisch. Für uns Entwickler ist dies eine sehr gute Lösung da uns dieser fehlerträchtige Programmieraspekt abgenommen wird. Die Kehrseite der Medaille bekommen zeigt sich aber dann wenn die Ausführung der eigenen Anwendung zu Speicherengpässen führt. In solchen Fällen stellt sich für uns Entwickler immer wieder die Frage: Wie kann ich in einem System mit automatischer Speicherverwaltung Objekte selbst freigeben?
In diesem Crashkurs möchte ich Ihnen daher einen kurzen Überblick über die .NET Garbage Collection geben und Ihnen Möglichkeiten aufzeigen, wie Sie Speicherengpässe in Ihren Anwendungen vermeiden oder behandeln können.
Garbage Collection
Das Freigeben von Speicher erfolgt in .NET durch die Garbage Collection. Dieser automatisch laufende Thread prüft welche Objekte noch in Gebrauch sind und gibt den Speicher nicht mehr benötigter Objekte frei. Dabei prüft die Garbage Collection direkte und indirekte Referenzen innerhalb der eigenen Anwendung. Nicht referenzierte Objekte werden nicht genutzt und daher wird ihr Speicher entsprechend freigegeben. Im Umkehrschluss müssen Sie als Entwickler aber dafür sorgen dass es keine ungenutzten, verwaisten oder unnötige lang gehaltenen Referenzen auf Objekte gibt.
Der Prüfmechanismus der Garbage Collection ist optimiert und arbeitet relativ schnell. Trotzdem benötigt eine Prüfung aller Objektreferenzen einige Zeit. Daher arbeitet die Garbage Collection nicht ständig sondern kommt erst zum Einsatz wenn der Speicher knapp wird oder gewisse Grenzwerte unterschreitet. Diese nicht deterministische Arbeitsweise führt dazu, dass Sie als Entwickler nicht vorhersagen können zu welchen Zeitpunkten Speicher in Ihrer Anwendung freigegeben wird.
Eine weitere Optimierung der Garbage Collection besteht darin, dass nicht jedes Mal alle Objekte durchlaufen werden. Dazu ordnet die Garbage Collection die Objekte in drei Generationen ein. Neue Objekte werden der Generation 0 zugeordnet. Diese wird jedes Mal durchlaufen. Objekte die sich dabei nicht freigegeben lassen werden Generation 1 zugeordnet. Die Garbage Collection prüft Objekte der Generation 1 nur wenn bei der Prüfung der Objekte der Generation 0 nicht genügend Speicher freigegeben werden konnte. Für Objekte der Generation 1 wird das gleiche Prinzip angewendet. Können diese nicht freigegeben werden wandern sie in Generation 2. Diese wiederrum wird nur geprüft wenn die Bereinigung der Generation 0 und 1 nicht genügend freien Speicher erzeugt hat. Objekte in Generation 2 sind somit langlebig.
Statische Klassen
Statische Klassen können per Definition nicht freigegeben werden. Objekte die von statischen Klassen referenziert werden überleben somit dauerhaft und setzen sich in Generation 2 der Garbage Collection fest. Sie sollten daher darauf achten das statische Klassen keine grossen Objekte referenzieren.
Finalizer und nicht-verwaltete Ressourcen
Wie beschrieben prüft die Garbage Collection ob Objekte referenziert werden. Dies ist aber nur für verwaltete Objekte möglich. Um die Freigabe von nicht-verwalteten Ressourcen müssen Sie sich somit selbst kümmern. Bevor die Garbage Collection ein Objekt entsorgt gibt sie Ihnen aus diesem Grund die Möglichkeit interne Aufräumarbeiten auszuführen. Dazu prüft die Garbage Collection ob das freizugebende Objekt die Methode Finalize() implementiert und ruft diese auf.
Das nachfolgende Beispiel zeigt eine Klasse welche die Finalize Methode implementiert.
1 public class Foo
2 {
3 protected override void Finalize()
4 {
5 //free unmanaged objects
6
7 //call base class finalizer
8 //base.Finalize();
9 }
10 }
Im Finalizer darf nur auf nicht-verwaltete Ressourcen zugegriffen werden da verwaltete Objekte bereits von der Garbage Collection gelöscht sein könnten. Wenn die Klasse eine Basisklasse besitzt muss zudem immer der Finalizer der Basisklasse aufgerufen werden.
C# bietet Ihnen eine gekürzte Syntax für den Finalizer an, bei welcher automatisch der Finalizer der Basisklasse aufgerufen wird. Der nachfolgende Quellcode zeigt die dementsprechende alternative Implementierung der vorhergehenden Klasse.
1 public class Foo
2 {
3 ~Foo()
4 {
5 //free unmanaged objects
6 }
7 }
Diese Kurzschreibweise in Form eines Destruktors ist dem Finalizer vorzuziehen, da im Destruktor der Aufruf des Finalizers der Basisklasse automatisch erfolgt und somit die Gefahr von Programmierfehlern verringert wird.
Die Verwendung eines Finalizers hat aber auch Nachteile. Diese ergeben sich aus der aufwendigeren Freigaberoutine für ein Objekt. Trifft die Garbage Collection auf ein verwaistes Objekt welches einen Finalizer aufweist, erzeugt sie einen neuen Objektverweis und stellt danach das Objekt in eine Finalisierungswarteschlange. Ein separater Thread arbeitet diese Warteschlange ab, ruft die Finalize Methode oder den Destruktor auf und markiert das Objekt entsprechend. Durch diesen Zusatzschritt kann das Objekt erst beim nächsten Speicherbereinigungsprozess komplett entfernt und dessen Speicherplatz freigegeben werden. Objekte die einen Finalizer implementieren werden daher immer erst beim zweiten Lauf der Garbage Collection entfernt. Ausserdem benötigt die Garbage Collection etwas mehr Zeit durch die aufwendigere Freigabeprozedur. Finalizer sollten daher nur zum Einsatz kommen wenn nicht-verwaltete Objekte genutzt werden.
CriticalFinalizerObject
Der Finalizer wird nicht in jedem Fall ausgeführt. Bricht die CLR (common language runtime) die Anwendung in einem Ausnahmefall frühzeitig ab, dann ist es nicht sichergestellt das der Finalizer ausgeführt werden kann. Ist es aber zwingend nötig dass ein Finalizer auch in diesem Fall ausgeführt wird, dann muss die jeweilige Klasse von der Basisklasse CriticalFinalizerObject erben. Der nachfolgende Quellcode zeigt ein entsprechendes Beispiel.
1 public class Foo : CriticalFinalizerObject
2 {
3 ~Foo()
4 {
5 //free unmanaged objects
6 }
7 }
Dispose
Die Freigabe nicht-verwalteter Ressourcen in einem Finalizer hat einen weiteren Nachteil: Durch die nicht-deterministische Arbeitsweise der Garbage Collection erfolgt die Speicherfreigabe zu irgendeinem Zeitpunkt. In manchen Situationen ist es aber wünschenswert dass nicht-verwaltete Ressourcen gezielt freigegeben werden können. Wenn zum Beispiel eine speicherintensive Ressource nicht mehr benötigt wird, dann kann es sinnvoll sein eine explizite Speicherfreigabe durchzuführen. Zu diesem Zweck sollte die Schnittstelle IDisposable implementiert und somit eine Dispose Methode angelegt werden.
Die Dispose Methode kann im Gegensatz zum Finalizer jederzeit aufgerufen werden. Daher bietet sich diese Methode auch zum Aufräumen von verwalteten Objekten an, beispielsweise zum Abmelden von Ereignisbehandlungsroutinen.
Ein Aufruf der Dispose Methode ist nicht zwingend vorgegeben und kann daher vom Entwickler vergessen werden. Müssen nicht-verwaltete Ressourcen freigegeben werden, sollte daher auch weiterhin ein Finalizer implementiert werden. Im Finalizer kann dabei die Dispose Methode aufgerufen werden. Diese beiden Aufrufmöglichkeiten der Dispose Methode müssen aber zwingend unterschieden werden. Wie zuvor bereits gelernt darf im Finalizer nicht auf verwaltete Objekte zugegriffen werden da diese bereits durch die Garbage Collection gelöscht sein könnten. Die gleiche Einschränkung gilt auch für die Dispose Methode sobald sie aus dem Finalizer heraus aufgerufen wird.
Das folgende Pattern zeigt eine praxistaugliche Implementierung der Dispose Methode im Zusammenspiel mit einem Finalizer. Dieses Pattern können Sie somit als Grundgerüst für Ihre eigenen Klassen einsetzen.
1 public class Foo : IDisposable
2 {
3 ~Foo()
4 {
5 Dispose(false);
6 }
7
8 public void Dispose()
9 {
10 if (_isDisposed == false)
11 {
12 Dispose(true);
13 GC.SuppressFinalize(this);
14 }
15 }
16
17 protected virtual void Dispose(bool isDisposing)
18 {
19 if (isDisposing == true)
20 {
21 //free managed objects
22 }
23
24 //free unmanaged objects
25
26 _isDisposed = true;
27 }
28
29 public void SomeMethod()
30 {
31 if (_isDisposed == true)
32 {
33 throw new ObjectDisposedException("Foo");
34 }
35 }
36
37 private bool _isDisposed;
38 }
Bei Verwendung dieses Patterns kommt es nur zum einmaligen Aufruf der Dispose Methode. Entweder erfolgt dieser aus der Anwendung heraus durch den Entwickler oder automatisch durch den Finalizer. Mittels des Methodenaufrufs GC.SuppressFinalize(this) wird verhindert dass der Finalizer ausgeführt wird, wenn zuvor bereits die Dispose Methode durch den Entwickler aufgerufen wurde. Ausserdem wird innerhalb der Dispose Methode unterschieden ob der Aufruf aus der Anwendung heraus oder durch den Finalizer erfolgt. Je nachdem werden nur die nicht-verwalteten Ressourcen oder zusätzlich die verwalteten Objekte freigegeben.
Sie sollten beachten, dass der Entwickler das Objekt nach einem manuellen Aufruf von Dispose das Objekt weiter nutzen kann. Um dieses fehlerhafte Vorgehen zu erkennen sollte die Variable _isDisposed genutzt und ausgewertet werden. Jede Methode der Klasse sollte daher als Erstes die Variable _isDisposed prüfen und wenn nötig eine ObjectDisposedException erzeugen.
Weak Reference
Anwendungen nutzen teilweise grosse Objekte welche viel Speicher benötigen. Wenn diese Objekte mehrfach während der Programmausführung benötigt werden dann werden sie zumeist einmalig angelegt und mehrfach genutzt. Erfolgt diese Nutzung aber sehr unregelmässig, belegt dieses grosse Objekt viel Speicher zu Zeiten in denen es eigentlich nicht benötigt wird. Um dieses Dilemma zu lösen existiert die Klasse WeakReference.
Eine WeakReference dient zur Verwaltung eines Objektes. Das Objekt kann dabei jederzeit von der Garbage Collection gelöscht werden. Andererseits wird es durch die WeakReference aber so Lange am Leben gehalten bis es die Garbage Collection wirklich löscht. In der Anwendung muss daher bei jedem Zugriff auf das Objekt geprüft werden ob es noch existiert und gegebenenfalls muss es neu erzeugt werden. Eine WeakReference bietet somit einen guten Mittelweg zwischen Performance und Speicherauslastung da das Objekt wenn möglich am Leben gehalten wird und somit bei Zugriffen nicht neu erzeugt werden muss und zusätzlich bei Speicherproblemen die Möglichkeit zur Freigabe des Objektes gegeben ist.
Das nachfolgende Beispiel zeigt wie ein Objekt mittels WeakReference verwaltet und genutzt wird.
1 public class Foo
2 {
3 public void DoSomething()
4 {
5 MyHugeClass myObject = null;
6
7 //try to get object from weak reference
8 if (_myWeakObject != null)
9 {
10 myObject = _myWeakObject.Target as MyHugeClass;
11 }
12
13 //create new object if it was removed by GC
14 if (myObject == null)
15 {
16 myObject = new MyHugeClass();
17 _myWeakObject = new WeakReference(myObject);
18 }
19
20 // work with myObject
21 }
22
23 WeakReference _myWeakObject;
24 }
Die Klasse GC
Die Garbage Collection lässt sich über der Klasse GC ansprechen. Mittels der Methode GC.Collect() kann ein Lauf der Gabage Collection veranlasst werden. In vielen Artikeln wird deshalb der Ratschlag erteilt genau diese Funktion aufzurufen um Speicherengpässe in eigenen Anwendungen zu beheben. Oftmals wird dabei sogar dazu geraten GC.Collect() zweimal aufzurufen, da Objekte mit einem Finalizer erst nach zwei Läufen der Garbage Collection aus dem Speicher entfernt werden.
Im ersten Moment hört sich diese Lösung schlüssig an. Bei genauerer Betrachtung kommt aber schnell die Kehrseite der Medaille zum Vorschein. Der explizite Aufruf von GC.Collect() führt nicht nur zur Freigebe nicht mehr benötigter Objekte sondern unweigerlich auch dazu, das alle Objekte die nicht entfernt werden können um eine Generation erhöht werden. Somit werden zwar einige Objekte aus dem Speicher entfernt aber der Grossteil der Objekte kann meist nicht entfernt werden. Diese werden durch den doppelten Aufruf von GC.Collect() in die Generation 2 gehoben und somit noch fester im Speicher verankert. Der Aufruf von GC.Collect() bewirkt somit meist das Gegenteil und kann Speicherprobleme noch vergrössern.
Fazit
In .NET kümmert sich die Garbage Collection automatisch um die Freigabe des Speichers verwalteter Ressourcen. Der Entwickler muss sich in diesem Zusammenhang darum kümmern Objektreferenzen nur so lange zu behalten wie diese benötigt werden. Ausserdem muss er nicht-verwaltete Ressourcen selbst freigegeben. Das vorgestellte Dispose Pattern bietet dafür eine entsprechende Grundstruktur.
Die Funktion GC.Collect() sowie die Finalizer Methode sollten möglichst nicht genutzt werden. Es gibt Ausnahmefälle von dieser Regel, beispielsweise bei Anwendungen die ein hoch optimiertes Speichermanagement erfordern. In solchen Fällen ist aber eine detaillierte Einarbeitung in das Thema Speichermanagement dringend anzuraten.