Ein Speicherleck (Memory Leak) ist Speicher, den ein Programm belegt, aber nie wieder freigibt, obwohl er nicht mehr benötigt wird. Der Verbrauch wächst mit Zeit oder Last, bis der Prozess durch Garbage-Collection-Druck langsam wird, auslagert oder vom Betriebssystem bzw. der Container-Runtime wegen Speichermangels beendet wird.
Was ist ein Speicherleck?
Ein Speicherleck (Memory Leak) entsteht, wenn ein Programm Speicher festhält, den es nie wieder nutzen wird. In Sprachen mit manueller Speicherverwaltung wie C und C++ ist der klassische Fall Speicher, der mit malloc oder new angefordert und nie freigegeben wird. In Runtimes mit Garbage Collector wie JVM, .NET, Node.js, Python oder Go werden nur unerreichbare Objekte freigegeben. Ein Leck bedeutet dort, dass noch irgendetwas eine Referenz auf fachlich längst tote Daten hält. Für den Collector sind diese Daten lebendig, also werden sie nie zurückgewonnen.
Speicherlecks führen selten zu einem sofortigen Ausfall. Sie zeigen sich als langsam steigender Speicherverbrauch, längere und häufigere Pausen der Garbage Collection und schließlich als Absturz oder OOM Kill, oft Stunden oder Tage nach dem Deployment, das sie verursacht hat.
Typische Ursachen für Speicherlecks
- Unbegrenzte Caches und Maps. Eine statische Map als Cache ohne Größenlimit oder Verdrängung wächst mit jedem neuen Schlüssel.
- Listener und Callbacks, die nie entfernt werden. Event-Handler, Observer oder Subscriptions halten ihre Besitzer am Leben.
- Thread-lokale und request-bezogene Daten, die beim Wiederverwenden gepoolter Threads nicht gelöscht werden.
- Closures in JavaScript, die große Objekte einfangen, oder Timer und Intervalle, die nie beendet werden.
- Goroutine- oder Thread-Lecks: blockierte Worker, die nie enden und jeweils ihren Stack und referenzierte Daten behalten.
- Nativer Speicher außerhalb des verwalteten Heaps, etwa Direct Buffers, native Bibliotheken oder nicht geschlossene Datei- und Socket-Handles.
- Prozessinterne Metriken mit hoher Kardinalität, bei denen jeder neue Label-Wert eine weitere Zeitreihe im Speicher anlegt.
Ein Speicherleck erkennen
Das entscheidende Signal ist nicht hoher Speicherverbrauch, sondern Speicher, der nicht wieder sinkt. Ein gesunder Prozess mit Garbage Collector zeigt ein Sägezahnmuster: Der Verbrauch steigt mit Allokationen und fällt nach jeder Collection. Bei einem Leck klettern die Tiefpunkte des Sägezahns mit der Zeit nach oben. Hilfreiche Signale sind:
- Heap-Belegung nach der Garbage Collection, etwa die OpenTelemetry-Metrik
jvm.memory.used_after_last_gcfür Java. - Resident Set Size des Prozesses und in Kubernetes das Working Set des Containers im Vergleich zu seinem Memory-Limit.
- GC-Zeit und -Häufigkeit: Je weniger freier Platz bleibt, desto öfter läuft der Collector und desto weniger gewinnt er zurück.
- Zusammenhang mit Deployments oder Last: Ein Leck pro Request skaliert mit dem Traffic, ein Leck pro Stunde deutet auf Hintergrundaufgaben.
Der Vergleich des aktuellen Verlaufs mit einer Baseline früherer Releases macht langsame Lecks sichtbar, lange bevor sie einen Ausfall verursachen.
Beispiel: Leck bestätigen und lokalisieren
Ein illustratives Szenario: Ein Java-Service wird alle paar Tage mit Exit-Code 137 neu gestartet. Das Dashboard zeigt, dass der Heap nach der GC innerhalb von 48 Stunden von etwa 300 MB auf 1,4 GB steigt, während der Traffic konstant bleibt. Last scheidet damit als Ursache aus, alles deutet auf ein Leck. Das Team geht so vor:
- Es erstellt im Abstand von einigen Stunden zwei Heap-Dumps (
jcmd <pid> GC.heap_dump) und vergleicht die dominierenden Objekttypen. - Es findet eine wachsende
HashMapmit der Session-ID als Schlüssel, die beim Login befüllt, beim Logout aber nie bereinigt wird. - Es ersetzt sie durch einen begrenzten Cache mit zeitbasiertem Ablauf und prüft, dass der Heap nach der GC nach dem Fix wieder flach verläuft.
In anderen Runtimes unterscheiden sich die Werkzeuge, etwa Heap-Snapshots in den Chrome DevTools für Node.js, tracemalloc für Python oder pprof-Heap-Profile für Go. Das Vorgehen bleibt gleich: Trend bestätigen, Snapshots vergleichen, den festhaltenden Referenzpfad finden.
Best Practices
- Geben Sie jedem Cache eine Maximalgröße und eine Verdrängungsstrategie.
- Stellen Sie jedem Subscribe ein Unsubscribe und jedem Öffnen ein Schließen gegenüber, idealerweise über Sprachmittel wie try-with-resources oder
defer. - Führen Sie Soak-Tests durch: Lange Lasttests bei konstantem Traffic decken Lecks auf, die kurze Tests übersehen. Vergleichen Sie dabei Heap-Profile vom Anfang und vom Ende des Tests.
- Alarmieren Sie auf den Trend des Speichers nach der GC oder auf die prognostizierte Zeit bis zum Limit, nicht nur auf einen statischen Schwellenwert.
- Verlassen Sie sich nicht auf regelmäßige Neustarts als Lösung. Sie verdecken das Leck und machen daraus einen wiederkehrenden Incident.
Wie Obseria hilft
Obseria erfasst Laufzeitmetriken wie Heap, GC und Container-Speicher über OpenTelemetry und kann steigende Speichertrends per Anomalieerkennung markieren. Continuous Profiling zeichnet Allokationsprofile in Produktion auf. So vergleichen Sie einen Flame Graph vor und nach einem Release und sehen, welcher Codepfad Speicher anlegt und festhält. JVM-spezifische Details finden Sie unter Java-Monitoring.