Speichermanagement und Ressourcenerschöpfung
Problem
Dein Dienst startet schnell, wird aber nach mehreren Stunden träge. Der Speicherverbrauch wächst weiter, obwohl die CPU-Auslastung gering ist. Ein Neustart hilft vorübergehend, obwohl die Anwendung eine Sprache mit automatischer Speicherverwaltung verwendet.
Lösung — Speicher- und Ressourcenmanagement
1. Stack: aktive Funktionsaufrufe verfolgen
Der Aufrufstapel, auch Stack genannt, hält fest, welche Funktionen gerade aktiv sind und wohin die Ausführung zurückkehren soll. Jeder gewöhnliche Funktionsaufruf fügt einen Stackframe hinzu: einen kleinen Arbeitsbereich mit Rückkehrinformationen und einigen temporären Werten wie lokalen Variablen oder Argumenten. Manche Werte hält der Compiler in CPU-Registern.
Ruft HandleRequest die Funktion BuildProfile auf, ist BuildProfile der zuletzt hinzugekommene Aufruf. Bei ihrer Rückkehr wird ihr Frame entfernt und HandleRequest läuft weiter. Kehrt auch HandleRequest zurück, wird dessen Frame ebenfalls entfernt. Der Platz dieser Frames kann wiederverwendet werden. Diese Reihenfolge heißt „zuletzt hinein, zuerst hinaus“, auf Englisch last in, first out.
HandleRequest startet → eigenen Frame hinzufügen BuildProfile startet → weiteren Frame darüber hinzufügen BuildProfile kehrt zurück → eigenen Frame entfernen HandleRequest kehrt zurück → eigenen Frame entfernen
In einer typischen Laufzeitumgebung mit Threads besitzt jeder Thread einen eigenen Aufrufstapel. Hier geht es um gewöhnliche synchrone Aufrufe; den Zustand pausierter Async-Methoden können Laufzeitumgebungen separat aufbewahren.
2. Heap: Daten mit eigener Lebensdauer speichern
Der Heap stellt Speicher für dynamisch angelegte Daten bereit, etwa für Objekte, die auch nach der Rückkehr ihrer erzeugenden Funktion noch gebraucht werden. Ihre Lebensdauer wird unabhängig vom Stackframe dieser Funktion verwaltet.
In einer Laufzeitumgebung mit Garbage Collection wie .NET bleibt ein über Referenzen erreichbares Objekt verfügbar. Wird es unerreichbar, kann eine spätere Garbage Collection seinen Speicher freigeben. Die Rückkehr aus einer Funktion löscht nicht automatisch jedes Objekt, das sie erzeugt hat.
Eine Referenz und das Objekt sind zwei unterschiedliche Dinge. Eine Funktion kann eine lokale Referenz halten, über die sie auf ein Objekt im Heap zugreift. Gibt sie diese Referenz zurück, kann der Aufrufer auf dasselbe Objekt zugreifen. Das Objekt selbst muss dabei nicht erst vom Stack in den Heap verschoben werden.
Wo Werte genau liegen, hängt von Sprache, Laufzeitumgebung und Compileroptimierungen ab. Entscheidend für das Verständnis ist der Unterschied zwischen der Lebensdauer eines Aufrufs und der eines Objekts.
3. Beispiel: Ein Profil überlebt seine erzeugende Funktion
Stell dir eine gewöhnliche C#/.NET-Methode namens BuildProfile vor. Sie erzeugt eine Instanz der Klasse Profile und gibt sie an HandleRequest zurück. Die Speicherzuweisungen sind hier vereinfacht dargestellt.
Schritt 1 — HandleRequest ruft BuildProfile auf. Beide Aufrufe sind auf dem Stack aktiv.
Schritt 2 — BuildProfile erzeugt das Profile-Objekt auf dem verwalteten Heap. Über seine lokale Referenz kann es auf dieses Objekt zugreifen.
Schritt 3 — BuildProfile gibt die Referenz zurück. Sein Stackframe wird freigegeben. HandleRequest kann das Profile-Objekt weiterhin über die zurückgegebene Referenz verwenden.
Schritt 4 — HandleRequest legt die Referenz in einem anwendungsweiten Cache ab und kehrt zurück. Sein eigener Frame wird freigegeben. Über den Cache bleibt das Profile-Objekt erreichbar und damit verfügbar.
Cache → Referenz → Profile-Objekt im Heap
Wird der Cacheeintrag entfernt und hält keine andere erreichbare Referenz das Objekt am Leben, kommt es für die Garbage Collection infrage. Die Bereinigung kann später erfolgen; das Entfernen aus dem Cache bedeutet keine sofortige Speicherfreigabe.
4. Wie beide Speicherbereiche erschöpft werden können
Tief verschachtelte Aufrufe können den Aufrufstapel erschöpfen, etwa bei unkontrollierter Rekursion, wenn eine Funktion sich immer wieder selbst aufruft. Das ist ein Stackoverflow.
Ein Cache, der ständig weitere Heap-Objekte festhält, kann den Speicherverbrauch wachsen lassen. Der Garbage Collector erkennt nicht, dass du Objekte nicht mehr brauchst, solange der Cache noch Referenzen darauf hält. So kann trotz automatischer Speicherbereinigung ein Speicherleck entstehen.
Unter Speicherdruck verschieben manche Systeme Speicherseiten zwischen RAM und Festplatte. Häufiges Paging kann Anfragen verlangsamen. Wird ein Speicherlimit erreicht, können Speicherzuweisungen fehlschlagen oder der Prozess beendet werden.
Auch andere Ressourcen sind begrenzt. Anfragen warten, wenn alle Datenbankverbindungen oder Worker-Threads belegt sind. Speicherwachstum, Garbage Collection, Paging und Pool-Auslastung zeigen Engpässe. Begrenzte Caches, rechtzeitige Ressourcenfreigabe und begrenzte Nebenläufigkeit halten den Verbrauch kontrollierbar.
Kerngedanke: Stackframes folgen aktiven Funktionsaufrufen; Heap-Objekte haben eine unabhängig verwaltete Lebensdauer. Klare Zuständigkeiten, begrenztes Wachstum und rechtzeitige Ressourcenfreigabe bleiben notwendig.