Java Memory Leaks: Ursachen und Erkennung
Deine Java-Anwendung startet schnell, aber über Stunden oder Tage verlangsamt sie sich bis zum Stillstand, wirft OutOfMemoryError oder wird vom Container beendet. Du startest sie neu, und der Zyklus wiederholt sich. Das ist die klassische Signatur eines Java Memory Leaks: Objekte, die nicht mehr benötigt werden, aber weiterhin erreichbar sind, sodass der Garbage Collector sie nicht freigeben kann. Anders als in C/C++ übernimmt Javas GC den Großteil der Speicherverwaltung automatisch, aber Leaks treten dennoch auf, wenn Referenzen unbeabsichtigt gehalten werden. Dieser Artikel erklärt die häufigsten Ursachen und gibt dir einen praktischen Workflow zur Erkennung und Behebung.
Was ist ein Java Memory Leak?
Ein Memory Leak tritt auf, wenn Objekte von der Anwendung nicht mehr verwendet werden, aber weiterhin referenziert werden, was die Garbage Collection verhindert. Mit der Zeit wächst die Heap-Nutzung, der GC läuft häufiger, und schließlich wirft die JVM OutOfMemoryError: Java heap space. Leaks können klein und langsam sein (ein paar KB pro Anfrage) oder groß und schnell (Caching ganzer Resultsets).
Häufige Ursachen von Java Memory Leaks
1. Statische Collections, die ewig wachsen
Statische Felder leben für die gesamte Lebensdauer der JVM. Eine statische Map, die als Cache ohne Eviction verwendet wird, ist ein klassisches Leak. Zum Beispiel:
public class UserCache {
private static final Map<Long, User> CACHE = new HashMap<>();
public static void put(Long id, User user) {
CACHE.put(id, user); // never removed
}
}
Jeder hinzugefügte Benutzer bleibt für immer. Lösung: Verwende einen begrenzten Cache wie Caffeine oder Guava mit Größenbeschränkungen und Ablaufzeiten.
2. Nicht geschlossene Ressourcen
Streams, Verbindungen und Reader müssen geschlossen werden. Wenn du das vergisst, leaken native Ressourcen und ihre Java-Wrapper. Verwende immer try-with-resources:
try (InputStream in = new FileInputStream(file)) {
// use in
} // automatically closed
Dies gilt auch für Datenbankverbindungen, HTTP-Clients und Thread-Pools.
3. Nicht entfernte Listener und Callbacks
Wenn du einen Listener registrierst (z. B. addListener), ihn aber nie entfernst, hält der Publisher eine Referenz auf den Listener, der wiederum eine Referenz auf deinen gesamten Objektgraphen halten kann. Dies ist häufig in GUIs, Event-Bussen und langlebigen Kontexten der Fall.
4. ThreadLocal-Variablen
ThreadLocal-Werte werden in der ThreadLocalMap des Threads gespeichert. In Thread-Pools werden Threads wiederverwendet. Wenn du also nicht remove() aufrufst, bleibt der Wert am Thread hängen und leakt. Räume immer auf:
try {
threadLocal.set(context);
// work
} finally {
threadLocal.remove();
}
5. Innere Klassen, die äußere Referenzen halten
Nicht-statische innere Klassen (einschließlich anonymer Klassen) halten eine implizite Referenz auf die äußere Instanz. Wenn eine Instanz einer inneren Klasse das äußere Objekt überlebt (z. B. in einer statischen Liste gespeichert), kann das äußere Objekt nicht gesammelt werden. Mache innere Klassen statisch, wenn sie die äußere Instanz nicht benötigen.
6. Unsachgemäße equals() und hashCode()
Wenn du Objekte als Schlüssel in einer HashMap verwendest, aber equals() ohne hashCode() überschreibst (oder umgekehrt), schlagen Lookups fehl und Einträge sammeln sich an. Überschreibe immer beide konsistent.
7. String Interning und Substrings
Vor Java 7 teilte String.substring() das ursprüngliche char-Array, sodass ein kleiner Substring ein großes Array halten konnte. Modernes Java kopiert, aber das Internen vieler einzigartiger Strings (z. B. String.intern()) kann den String-Pool dennoch füllen.
Wie man Java Memory Leaks erkennt
Die Erkennung folgt einem systematischen Prozess. Hier ist ein schrittweiser Ansatz:
- Überwache die Heap-Nutzung über die Zeit. Verwende JVisualVM, JConsole oder dein APM. Eine gesunde App zeigt ein Sägezahnmuster: Heap wächst, GC gibt frei, Heap fällt. Ein Leak zeigt eine steigende Grundlinie nach jedem GC.
- Aktiviere GC-Logging. Füge
-Xlog:gc*:file=gc.log:time,uptime,level,tags(Java 9+) oder-XX:+PrintGCDetails(Java 8) hinzu. Analysiere die Logs mit Tools wie GCeasy, um zu sehen, ob die Old Generation weiter wächst. - Erstelle einen Heap Dump. Wenn der Heap hoch ist, führe
jmap -dump:live,format=b,file=heap.hprof <pid>aus oder verwendejcmd <pid> GC.heap_dump heap.hprof. Du kannst auch einen Dump bei OutOfMemoryError mit-XX:+HeapDumpOnOutOfMemoryErrorauslösen. - Analysiere den Heap Dump. Öffne ihn in Eclipse MAT oder VisualVM. Suche nach dem Dominator Tree, um Objekte zu finden, die den meisten Speicher zurückhalten. Prüfe auf verdächtige Collections mit großen retained sizes.
- Erstelle einen zweiten Dump und vergleiche. Wenn dieselben Objekte zwischen den Dumps wachsen, hast du das Leak gefunden.
- Verwende einen Profiler für Live-Analysen. Tools wie YourKit, JProfiler oder async-profiler können Allokationsstellen und Referenzketten verfolgen, ohne die App zu stoppen.
Hier ist ein kurzer Vergleich gängiger Tools:
| Tool | Typ | Am besten für |
|---|---|---|
| Eclipse MAT | Heap-Dump-Analyzer | Dominatoren und Leak-Verdächtige finden |
| VisualVM | Monitoring + Heap Dump | Schnelle Live-Checks und grundlegende Analysen |
| JProfiler / YourKit | Profiler | Allokationsverfolgung und Referenzketten |
| async-profiler | Profiler mit geringem Overhead | Produktions-Profiling mit Flame Graphs |
Beheben und Vorbeugen von Leaks
Sobald du das Leak identifiziert hast, behebe es, indem du die unbeabsichtigte Referenz entfernst. Häufige Lösungen:
- Ersetze unbegrenzte Caches durch begrenzte (Caffeine, Ehcache).
- Verwende try-with-resources für alle Closeable-Ressourcen.
- Deregistriere Listener in einem finally-Block oder verwende Weak References.
- Rufe immer
ThreadLocal.remove()auf. - Mache innere Klassen wenn möglich statisch.
- Überschreibe equals() und hashCode() zusammen.
Vorbeugen ist besser als heilen. Füge Speicher-Monitoring in deine CI/CD-Pipeline ein, führe Lasttests mit Heap-Checks durch und setze -Xmx angemessen. Verwende statische Analysetools wie SpotBugs, um gängige Muster zu erkennen.
FAQ
Was ist der Unterschied zwischen einem Memory Leak und einem Memory Spike?
Ein Memory Spike ist ein vorübergehender Anstieg der Heap-Nutzung, den der GC freigibt. Ein Leak ist ein stetiger Anstieg über die Zeit, weil Objekte erreichbar bleiben und nicht gesammelt werden können.
Kann ein Java Memory Leak OutOfMemoryError verursachen, auch wenn der Heap groß ist?
Ja. Wenn die Leak-Rate die Fähigkeit des GC zur Freigabe übersteigt, füllt sich der Heap schließlich unabhängig von seiner Größe. Eine Vergrößerung des Heaps verzögert nur das Scheitern.
Wie finde ich ein Memory Leak in der Produktion ohne Ausfallzeit?
Verwende Tools mit geringem Overhead wie async-profiler oder JFR (Java Flight Recorder), um Allokationen und Heap-Statistiken aufzuzeichnen. Du kannst auch einen Heap Dump mit jcmd auslösen, während die App läuft, obwohl dies kurz pausieren kann.
Musst du GC-Logs oder andere textbasierte Logs analysieren? Probiere unseren Nginx Log Analyzer, um Log-Muster schnell zu parsen und zu visualisieren.