1. Grundlagen des Speichers: Stack und Heap (Stack & Heap)
Wenn dein Programm startet, bekommt es Zugriff auf Speicher. Im .NET-Kontext (CLR — Common Language Runtime) wird dieser Speicher automatisch verwaltet und in zwei Hauptbereiche aufgeteilt: Stack und Heap. Zu wissen, wo und wie Daten liegen, ist kritisch, um effizienten und stabilen Code zu schreiben.
Stack
Stell dir den Stack wie einen Stapel Teller vor: du legst immer eine neue Teller oben drauf und nimmst auch von oben. Das ist das Prinzip LIFO (Last In, First Out) — „zuletzt rein, zuerst raus“. Der Stack ist sehr schnell, hat aber begrenzte Größe.
Was auf dem Stack gespeichert wird:
- Value types: die tatsächlichen Werte von direkt deklarierten Variablen.
- Referenzen auf Objekte: bei reference types liegt auf dem Stack nur die Adresse zum Objekt im Heap.
- Methodenparameter: Werte, die an Funktionen übergeben werden.
- Lokale Variablen: Variablen, die innerhalb von Methoden deklariert sind.
- Rücksprungadressen: wohin nach der Ausführung der Methode zurückgesprungen wird.
Speicher auf dem Stack wird automatisch und sehr schnell alloziert und freigegeben, sobald die entsprechende Methode beendet ist oder die Variable aus dem Scope geht.
Beispiel: Value types auf dem Stack
void MyMethod()
{
int a = 10; // 'a' und der Wert 10 - auf dem Stack
bool flag = true; // 'flag' und der Wert true - auf dem Stack
char initial = 'Z'; // 'initial' und der Wert 'Z' - auf dem Stack
// ...
} // Wenn MyMethod beendet ist, werden 'a', 'flag', 'initial' vom Stack entfernt.
Beispiel: Referenzen auf dem Stack
class MyObject { }
void AnotherMethod()
{
MyObject objRef; // 'objRef' (Referenz) - auf dem Stack. Das Objekt selbst ist noch nicht erstellt.
// ...
} // Wenn AnotherMethod beendet ist, wird 'objRef' (Referenz) vom Stack entfernt.
Heap
Der Heap ist deutlich größer und flexibler. Hier gibt es kein striktes LIFO; Daten können an beliebigen freien Stellen liegen. Der Heap wird für größere und/oder langlebige Objekte genutzt.
Was im Heap gespeichert wird:
- Objekte von reference types: Instanzen von Klassen, Arrays, strings — erstellt mit new.
- Eingebettete value types: wenn ein value type (struct) ein Feld eines reference type (class) ist, liegt er innerhalb des Objekts im Heap.
Speicherverwaltung im Heap geschieht automatisch durch den GC (Garbage Collector).
Beispiel: Klassenobjekte im Heap
class Person { public string Name; public int Age; }
void CreatePerson()
{
Person p = new Person(); // Person-Objekt - im Heap. 'p' (Referenz) - auf dem Stack.
p.Name = "Alice"; // String "Alice" (auch Objekt) - im Heap.
p.Age = 30; // 30 (int, value type) - innerhalb des Person-Objekts im Heap.
// ...
} // Wenn CreatePerson beendet ist, wird 'p' (Referenz) vom Stack entfernt.
// Das Person-Objekt im Heap wird unreachable und ist ein Kandidat für GC.
Beispiel: Arrays im Heap
void ProcessArray()
{
int[] numbers = new int[5]; // Array mit 5 ints - im Heap. 'numbers' (Referenz) - auf dem Stack.
numbers[0] = 10; // Element 10 - Teil des Arrays im Heap.
// ...
} // Das Array wird vom GC gereinigt, wenn keine Referenzen mehr darauf existieren.
2. Garbage Collector (Garbage Collection)
Die Speicherverwaltung im Heap erfolgt automatisch dank des GC. Das ist eine Kernfunktion von .NET, die das manuelle Freigeben von Speicher überflüssig macht (eine typische Fehlerquelle in Sprachen ohne GC).
Zweck und Arbeitsweise
Das Hauptziel des GC ist es, automatisch Speicher freizugeben, der von Objekten im Heap belegt wird, wenn diese vom Programm nicht mehr genutzt werden.
- Objekterstellung: bei new reserviert die CLR Platz im Heap.
- Verfolgen von Referenzen: der GC verfolgt aktive Referenzen; ein Objekt ist „reachable“, wenn eine Referenz von Stack, static Feld oder einem anderen erreichbaren Objekt existiert.
- Bestimmung von "Garbage": wenn keine Referenzen existieren — ist das Objekt „unreachable“ und gilt als garbage.
- Sammlung: bei Bedarf markiert der GC erreichbare Objekte und gibt den Speicher unreachable Objekten frei.
- Kompaktion: zur Verringerung von Fragmentierung können verbleibende Objekte verschoben werden, um zusammenhängende Bereiche zu bilden.
Generationen im GC (Generational GC)
Der GC nutzt Generationen, weil die meisten Objekte „jung sterben“:
- Generation 0 (Gen 0): neue Objekte; Sammlungen sind häufig und schnell.
- Generation 1 (Gen 1): Objekte, die Gen 0 überlebt haben; Sammlungen seltener und länger.
- Generation 2 (Gen 2): langlebige Objekte; Sammlungen am seltensten und teuersten.
Wann läuft der GC?
- Wenn nicht genug freier Speicher für eine neue Allokation vorhanden ist.
- Regelmäßige Checks in Leerlaufphasen.
- Expliziter (meist nicht empfohlener) Aufruf GC.Collect().
Performance des GC
Ein GC-Lauf kann kurze Pausen verursachen, da die Ausführung des User-Codes angehalten wird. Zu viele kurzlebige Objekte im Heap führen zu häufigeren Sammlungen und schlechterer Performance.
Beispiel: Objekt wird unreachable
class DataBlock { public byte[] Data; public DataBlock() => Data = new byte[1024 * 1024]; } // 1 MB Daten
void AllocateAndLose()
{
DataBlock block1 = new DataBlock(); // block1-Objekt im Heap, Referenz 'block1' auf dem Stack
// ... Code-Block ...
block1 = null; // Jetzt gibt es keine aktiven Referenzen auf das DataBlock-Objekt. Es ist unreachable.
// An dieser Stelle hat der GC ES NOCH NICHT GELÖSCHT, aber es ist ein Kandidat zur Freigabe.
// Aufruf von GC.Collect() hier (nur zu Demo-Zwecken, nicht in Produktion!)
Console.WriteLine("Calling GC.Collect()");
GC.Collect(); // GC kann (aber nicht garantiert) den Speicher jetzt freigeben
}
AllocateAndLose();
// Wenn AllocateAndLose beendet ist, wird die Referenz 'block1' vom Stack entfernt,
// und das DataBlock-Objekt im Heap wird unreachable und für die Garbage Collection vorgesehen.
3. Verwaltung von unmanaged resources
Der GC verwaltet nur managed memory von .NET. Es gibt aber unmanaged resources, die außerhalb der Kontrolle des GC liegen und explizit freigegeben werden müssen:
- File handles (geöffnete Dateien).
- Network sockets.
- Window/Graphics Handles (z. B. GDI+).
- Speicher, der vom OS angefordert wurde (z. B. via P/Invoke).
Finalizer
Ein Finalizer ist eine spezielle Methode, die finale Bereinigung von unmanaged resources vor der Entfernung des Objekts durch den GC durchführt. Die Syntax ähnelt dem Konstruktor, aber mit einer Tilde:
class MyClassWithFinalizer
{
~MyClassWithFinalizer() { /* Freigabe von unmanaged resources */ }
}
Der Aufruf durch den GC ist unvorhersehbar, verzögert und läuft im Finalizer-Thread. Nachteile: Unvorhersehbarkeit, Overhead (Objekte mit Finalizer benötigen in der Regel zwei GC-Pässe). Deshalb sind Finalizer eher eine „Versicherung“ als der primäre Bereinigungsmechanismus.
Beispiel: Finalizer als "Versicherung"
class UnmanagedResourceHolder
{
private bool _resourceReleased = false;
// Simulation eines unmanaged resources
public UnmanagedResourceHolder() => Console.WriteLine("Ressource erstellt.");
public void ReleaseResource()
{
if (!_resourceReleased)
{
Console.WriteLine("Ressource EXPLIZIT freigegeben.");
_resourceReleased = true;
}
}
// Finalizer - wird vom GC als letzte Maßnahme aufgerufen
~UnmanagedResourceHolder()
{
Console.WriteLine("Finalizer AUFGERUFEN (Ressource wurde NICHT explizit freigegeben).");
ReleaseResource(); // Versuch, die Ressource freizugeben
}
}
Das Interface IDisposable
IDisposable ist der bevorzugte Mechanismus zur expliziten Freigabe von Ressourcen. Es enthält eine Methode: void Dispose(). Häufig wird ein Pattern verwendet, bei dem Dispose() mehrfach-sicher ist und der Finalizer unterdrückt wird via GC.SuppressFinalize(this).
Beispiel: Implementierung von IDisposable
using System.IO;
class MyFileWriter : IDisposable
{
private StreamWriter _writer;
private bool _disposed = false;
public MyFileWriter(string path)
{
_writer = new StreamWriter(path, true);
Console.WriteLine($"Datei '{path}' geöffnet.");
}
public void WriteLog(string message) => _writer.WriteLine(message);
// IDisposable-Implementierung
public void Dispose()
{
Dispose(true); // Hauptlogik von Dispose aufrufen
GC.SuppressFinalize(this); // Dem GC sagen, dass der Finalizer nicht nötig ist
_disposed = true;
}
// Geschützte virtuelle Methode für das gängige Dispose-Pattern
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
// Managed resources freigeben
_writer?.Dispose();
}
// Unmanaged resources freigeben (falls vorhanden)
Console.WriteLine("Datei ÜBER Dispose geschlossen.");
}
}
// Optionaler Finalizer als "Versicherung"
~MyFileWriter()
{
Console.WriteLine("Datei ÜBER Finalizer geschlossen (Dispose() wurde nicht aufgerufen).");
Dispose(false); // Dispose aufrufen und kennzeichnen, dass es kein expliziter Aufruf war
}
}
Der Operator using
Der using-Operator ist syntactic sugar, der garantiert, dass Dispose() aufgerufen wird, wenn das Objekt den Gültigkeitsbereich verlässt — auch bei Exceptions. Das ist die empfohlene Art, mit Objekten zu arbeiten, die IDisposable implementieren.
Beispiel: automatische Bereinigung mit using
// Nutzung von MyFileWriter aus dem Beispiel oben
void ProcessFile(string fileName)
{
// Das MyFileWriter-Objekt wird nach dem using-Block automatisch disposed
using (var writer = new MyFileWriter(fileName))
{
writer.WriteLog("Erste Zeile.");
writer.WriteLog("Zweite Zeile.");
// Selbst wenn hier eine Exception passiert, wird Dispose() trotzdem aufgerufen
} // Hier wird writer.Dispose() automatisch aufgerufen
Console.WriteLine("Using-Block beendet, Datei geschlossen.");
}
ProcessFile("testlog.txt");
// In diesem Fall wird der Finalizer von MyFileWriter nicht aufgerufen, weil Dispose() explizit aufgerufen wurde.
Das Verständnis dieser Konzepte ist der Schlüssel zum Schreiben performanter, robuster und skalierbarer Anwendungen in C#.
GO TO FULL VERSION