OutOfMemoryError in Java: Heap, Metaspace und was der Dump verrät
Die häufigste verlorene Zeit entsteht dadurch, dass die Meldung nicht zu Ende gelesen wird. Danach geht es um eine einzige Unterscheidung: Wächst etwas, das nicht wachsen sollte, oder ist die Einstellung schlicht zu knapp für das, was die Anwendung tut.
KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Der Heap wird erhöht, und drei Wochen später steht dieselbe Meldung wieder da
Der Ablauf ist in fast jedem Haus derselbe. Eine Anwendung bricht nachts mit einem OutOfMemoryError ab, jemand setzt die maximale Heap-Größe hoch, der Neustart läuft, und die Sache gilt als erledigt. Beim nächsten Mal dauert es etwas länger, bis es wieder passiert, und die Erhöhung wird wiederholt. Irgendwann geht das nicht mehr, weil der Rechner nicht mehr hergibt, und dann beginnt die eigentliche Suche unter Zeitdruck und ohne die Daten, die man beim ersten Mal hätte sammeln können.
Die zweite verlorene Stunde entsteht dadurch, dass die Meldung nicht zu Ende gelesen wird. Ein OutOfMemoryError ist keine einzelne Fehlersituation, sondern eine Familie. Der Text hinter dem Doppelpunkt benennt den Bereich, der ausgegangen ist, und die Bereiche haben nichts miteinander zu tun. Wer bei Metaspace am Heap dreht oder bei unable to create native thread nach großen Objekten sucht, arbeitet an der falschen Stelle, und zwar mit voller Überzeugung.
Die dritte Falle ist der Container. Ein Java-Prozess belegt neben dem Heap noch eine ganze Reihe weiterer Bereiche: Metaspace, Code-Zwischenspeicher, Thread-Stacks, Direktpuffer, Verwaltungsstrukturen des Garbage Collectors und alles, was native Bibliotheken sich selbst nehmen. Die Speichergrenze des Containers gilt für die Summe. Wird sie überschritten, beendet das Betriebssystem den Prozess, und zwar ohne Java-Meldung, ohne Stacktrace und ohne Heap-Dump. Wer in diesem Fall nach einem OutOfMemoryError in den Protokollen sucht, findet nichts und schließt daraus das Falsche.
KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Die Fälle, die im Betrieb tatsächlich auftreten
Sechs Meldungen decken fast alles ab, was in einer Java-Anwendung an Speicherproblemen sichtbar wird. Zu jeder steht hier, woran du sie erkennst und in welcher Reihenfolge du vorgehst. Für jede steht hier, woran du sie erkennst, was sie in der Regel verursacht und in welcher Reihenfolge du vorgehst.
Symptom
Die Meldung lautet java.lang.OutOfMemoryError: Java heap space. Die Anwendung läuft nach dem Start tagelang unauffällig und bricht dann ab, und zwar immer nach ungefähr derselben Laufzeit.
Ursache
Ein Leck im klassischen Sinn: Objekte bleiben erreichbar, obwohl sie fachlich nicht mehr gebraucht werden. Der Sammler kann sie deshalb nicht entfernen. Typische Quellen sind statische Sammlungen, die nur wachsen, Caches ohne Verdrängung, Listener und Callbacks, die nie abgemeldet werden, und Objekte, die in einer ThreadLocal-Variablen liegen, während der Thread aus einem Vorrat stammt und ewig lebt.
Lösung
In den Protokollen des Garbage Collectors prüfen, ob der belegte Speicher nach jeder vollständigen Sammlung wieder auf denselben Wert zurückfällt oder ob der Boden ansteigt. Steigt er, den Heap-Dump im Memory Analyzer öffnen und über den Dominatorbaum gehen: Er zeigt, welche wenigen Objekte den Großteil des Speichers festhalten. Von dort führt der Pfad zur Wurzel meist in wenigen Schritten zur verantwortlichen Stelle im Code. Eine Erhöhung der Heap-Größe verschiebt hier nur den Termin.
Symptom
Dieselbe Meldung, aber der Abbruch tritt unter Last auf und nicht nach einer bestimmten Laufzeit. Nach dem Neustart läuft die Anwendung wieder normal, auch ohne Änderung.
Ursache
Keine dauerhaft wachsende Struktur, sondern eine einzelne Operation, die kurzfristig sehr viel belegt. Häufige Auslöser sind eine Datenbankabfrage ohne Begrenzung der Ergebnismenge, das vollständige Einlesen einer hochgeladenen Datei in den Speicher, das Zusammenbauen einer großen Antwort als Zeichenkette und Serialisierungsvorgänge, die eine tiefe Objektstruktur vollständig aufbauen.
Lösung
Im Heap-Dump nach dem größten einzelnen Objektbaum suchen, nicht nach der Summe vieler kleiner. Meist steht dort ein Feld oder eine Liste mit sehr vielen Einträgen, und der Stacktrace des Threads, der den Fehler ausgelöst hat, zeigt die Operation. Die Lösung liegt im Code und nicht in der Konfiguration: Ergebnismengen begrenzen, im Datenstrom verarbeiten statt vollständig zu laden und Obergrenzen für Nutzlasten setzen.
Symptom
Die Meldung lautet java.lang.OutOfMemoryError: Metaspace. Der Heap ist laut Überwachung unauffällig. Auffällig ist, dass der Fehler erst nach mehreren Auslieferungen ohne Neustart auftritt.
Ursache
Im Metaspace liegen die Metadaten der geladenen Klassen. Er wächst, wenn Klassen geladen, aber ihre Klassenlader nicht freigegeben werden. Der klassische Fall ist das wiederholte Ausrollen einer Anwendung in einen laufenden Application Server: Jede Auslieferung bringt einen neuen Klassenlader mit, und wenn irgendetwas noch eine Referenz darauf hält, kann keiner davon abgeräumt werden. Die zweite Quelle sind Bibliotheken, die zur Laufzeit Klassen erzeugen, etwa für dynamische Proxies, Ausdrucksauswertung oder Objektabbildung.
Lösung
Prüfen, wie viele Klassenlader im Dump vorhanden sind, denn der Memory Analyzer zeigt sie als eigene Gruppe. Sind es mehrere Generationen derselben Anwendung, ist die Ursache das Ausrollen im laufenden Betrieb, und der Ausweg ist der Neustart des Prozesses bei jeder Auslieferung, was im Container ohnehin der Normalfall ist. Wachsen dagegen erzeugte Klassen einer Bibliothek, gehört die Zwischenspeicherung dieser Bibliothek überprüft. Eine Obergrenze für den Metaspace zu setzen, ist sinnvoll, um den Fehler früh und mit Dump statt spät und als Container-Abbruch zu bekommen.
Symptom
Die Meldung lautet java.lang.OutOfMemoryError: unable to create native thread. Der Heap ist weit von seiner Grenze entfernt, und der Fehler tritt auf, wenn Last dazukommt.
Ursache
Hier ist nicht der Heap ausgegangen, sondern die Möglichkeit, einen weiteren Betriebssystem-Thread anzulegen. Entweder ist eine Grenze des Betriebssystems erreicht, etwa die zulässige Anzahl an Prozessen oder Threads für den Benutzer, oder der Speicher außerhalb des Heaps reicht nicht mehr, weil jeder Thread einen eigenen Stack belegt. Die häufigste Ursache im Code ist ein Thread-Pool ohne Obergrenze oder eine Stelle, an der pro Anfrage ein neuer Thread entsteht und nie endet.
Lösung
Zuerst die Anzahl der Threads im laufenden Prozess ermitteln und die Grenzen des Betriebssystems dagegen halten. Steigt die Zahl dauerhaft, ist es kein Kapazitätsproblem, sondern ein Leck: Ein Thread-Dump zeigt die Gruppen mit gleichlautenden Namen, und daraus lässt sich die erzeugende Stelle ablesen. Ist die Zahl stabil und schlicht hoch, hilft entweder eine kleinere Stackgröße pro Thread oder ein Umbau auf einen begrenzten Vorrat. Bei blockierenden Aufrufen sind Virtual Threads ab Java 21 die strukturelle Antwort, weil sie keinen Betriebssystem-Thread belegen, solange sie warten.
Symptom
Die Meldung lautet java.lang.OutOfMemoryError: Direct buffer memory. Betroffen ist ein Dienst mit viel Netzwerkverkehr oder mit intensivem Dateizugriff.
Ursache
Direktpuffer liegen außerhalb des Heaps und werden erst freigegeben, wenn das zugehörige Java-Objekt eingesammelt wurde. Wird auf dem Heap wenig Druck erzeugt, läuft der Sammler selten, und die Puffer stauen sich an, obwohl im Heap reichlich Platz ist. Verstärkt wird das durch Netzwerkbibliotheken, die eigene Puffervorräte verwalten, und durch eine Obergrenze für Direktpuffer, die niemand bewusst gesetzt hat.
Lösung
Die Obergrenze für Direktpuffer ausdrücklich setzen, damit der Fehler früh und benannt auftritt, statt den Container zu sprengen. Danach mit Native Memory Tracking prüfen, wie groß der Anteil außerhalb des Heaps tatsächlich ist. Im Code sind zwei Dinge zu prüfen: ob Puffer aus einem Vorrat wieder zurückgegeben werden und ob Ressourcen wie Kanäle und Ströme zuverlässig geschlossen werden. Bei Netzwerkbibliotheken mit eigenem Puffervorrat lohnt sich deren Leckerkennung im Testbetrieb.
Symptom
Es steht überhaupt kein OutOfMemoryError im Protokoll. Der Container verschwindet ohne Vorwarnung, die Plattform startet ihn neu, und die letzte Protokollzeile ist eine ganz gewöhnliche.
Ursache
Das ist kein Java-Fehler, sondern das Betriebssystem: Die Summe aus Heap, Metaspace, Code-Zwischenspeicher, Thread-Stacks, Direktpuffern, Verwaltungsstrukturen des Sammlers und nativen Bibliotheken hat die Speichergrenze des Containers überschritten. Die virtuelle Maschine bekommt davon nichts mit, deshalb gibt es weder Meldung noch Heap-Dump. Häufigste Konstellation: Die maximale Heap-Größe wurde von Hand auf einen Wert nahe der Containergrenze gesetzt, ohne Luft für alles andere.
Lösung
Erst prüfen, ob es wirklich ein Abbruch durch das Betriebssystem war, denn der Beendigungscode des Containers und die Ereignisse eurer Plattform zeigen das an. Danach die Heap-Größe über die prozentualen Optionen statt über einen festen Wert setzen und den Rest der Grenze als Reserve behandeln. Wie viel Reserve nötig ist, sagt Native Memory Tracking. Und richtet die Überwachung so ein, dass sie den gesamten Speicherbedarf des Prozesses zeigt und nicht nur den Heap, sonst sieht die Kurve bis zum Abbruch unauffällig aus.
Vom Text der Meldung zur richtigen Baustelle
- 01 Steht überhaupt ein OutOfMemoryError im Protokoll? Wenn nicht, war es der Container.
- 02 Java heap space: erreichbare Objekte, also entweder ein Leck oder ein zu kleiner Heap.
- 03 Metaspace: Klassen, fast immer ein Klassenlader, der nicht freigegeben wird.
- 04 Unable to create native thread: Threads, nicht Speicher im Heap.
- 05 Direct buffer memory: Puffer außerhalb des Heaps, meist Netzwerk oder Dateizugriff.
Was du einrichtest, bevor es das nächste Mal passiert
Sechs Einstellungen und Gewohnheiten machen aus einem nächtlichen Abbruch einen auswertbaren Vorfall. Fünf davon sind Startoptionen, die einmal gesetzt werden. Die sechste ist eine Entscheidung darüber, wer den Dump anschaut.
Den Heap-Dump automatisch schreiben lassen
Mit -XX:+HeapDumpOnOutOfMemoryError schreibt die virtuelle Maschine beim Auftreten des Fehlers einen Heap-Dump, und mit -XX:HeapDumpPath legst du fest, wohin. Beides gehört in die Standard-Startoptionen jeder Anwendung, nicht erst in die Fehlersuche. Achte darauf, dass der Zielort außerhalb des Containers liegt oder auf einem Datenträger, der den Neustart überlebt, sonst ist die Datei zusammen mit dem Prozess weg.
Nach dem Fehler kontrolliert beenden
Eine Anwendung, die nach einem OutOfMemoryError weiterläuft, ist in einem Zustand, über den niemand mehr eine Aussage treffen kann: Ein beliebiger Thread ist mitten in einer Operation abgebrochen. Mit -XX:+ExitOnOutOfMemoryError beendet sich der Prozess stattdessen, und die Neustartlogik eurer Plattform übernimmt. Das ist fast immer die bessere Wahl als ein halb funktionierender Dienst.
Die Protokolle des Garbage Collectors mitschreiben
Ohne diese Protokolle lässt sich die wichtigste Frage nicht beantworten: Läuft der belegte Speicher nach jeder Sammlung wieder auf denselben Wert zurück, oder steigt der Boden langsam an? Der erste Fall ist normaler Betrieb, der zweite ist ein Leck. Die Aufzeichnung kostet kaum Leistung und liefert genau die Kurve, die man im Nachhinein braucht.
Native Memory Tracking einschalten, wenn der Heap unauffällig ist
Mit -XX:NativeMemoryTracking=summary und dem passenden Unterbefehl von jcmd bekommst du eine Aufstellung darüber, wie sich der Speicherbedarf des Prozesses auf Heap, Klassen, Threads, Code und interne Strukturen verteilt. Das ist der Weg, wenn der Heap laut Überwachung stabil aussieht und der Prozess trotzdem wächst.
Container-Grenze und Heap-Grenze getrennt betrachten
Ein JDK ab Version 10 erkennt die Speichergrenze des Containers von selbst und leitet daraus die maximale Heap-Größe ab, standardmäßig ein Viertel davon. Diese Vorgabe ist für einen Dienst, der wenig außerhalb des Heaps braucht, zu vorsichtig, und für einen Dienst mit vielen Threads und Direktpuffern zu großzügig. Setz den Wert bewusst über die prozentualen Optionen und lass Luft für alles, was nicht im Heap liegt.
Den Dump mit einem Werkzeug auswerten, nicht mit Augenmaß
Ein Heap-Dump von mehreren Gigabyte lässt sich nicht lesen, er lässt sich nur auswerten. Werkzeuge wie der Memory Analyzer bauen daraus einen Dominatorbaum und zeigen, welche wenigen Objekte den Großteil des Speichers festhalten. Diese Auswertung dauert Minuten und ersetzt Tage an Vermutungen über den Quelltext.
KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Der Speicher eines Java-Prozesses besteht aus mehr als dem Heap
Die Überwachung zeigt in den meisten Häusern den Heap, und der Heap ist auch der Bereich, über den am meisten geschrieben wird. Daneben stehen aber mehrere Bereiche, die zusammen erheblich ins Gewicht fallen. Der Metaspace hält die Metadaten der geladenen Klassen und hat standardmäßig keine feste Obergrenze. Der Code-Cache enthält den Maschinencode, den der JIT-Compiler erzeugt hat. Jeder Thread hat seinen eigenen Stack. Direktpuffer für Netzwerk und Dateizugriff liegen außerhalb des Heaps. Und der Garbage Collector selbst braucht Verwaltungsstrukturen, deren Größe mit dem Heap wächst.
Daraus folgt eine Faustregel für Container: Die maximale Heap-Größe darf nie nahe an der Speichergrenze des Containers liegen. Wie viel Abstand nötig ist, hängt von der Anwendung ab, und genau dafür gibt es Native Memory Tracking. Es liefert eine Aufstellung nach Bereichen und beantwortet die Frage mit Zahlen statt mit einer Vermutung. Der Aufwand dafür ist ein Neustart mit einer zusätzlichen Startoption und ein Aufruf von jcmd.
Zwei Standardeinstellungen sind dabei wichtig zu kennen. Ein JDK ab Version 10 erkennt die Speichergrenze des Containers und leitet daraus die maximale Heap-Größe ab. Der Anteil beträgt standardmäßig ein Viertel des verfügbaren Speichers, was für einen Dienst mit wenig Bedarf außerhalb des Heaps sehr vorsichtig ist. Wer diesen Wert anhebt, sollte das über die prozentuale Option tun und nicht über einen festen Wert, weil sich die Konfiguration dann bei einer Änderung der Containergrenze mitbewegt.
Wie du ein Leck von einer zu knappen Einstellung unterscheidest
Die Frage lässt sich mit einer einzigen Kurve beantworten, und die steht in den Protokollen des Garbage Collectors. Trag den belegten Heap nach jeder vollständigen Sammlung über die Zeit auf. Bei gesundem Betrieb schwankt der Wert, kehrt aber nach jeder Sammlung auf ungefähr dasselbe Niveau zurück. Bei einem Leck steigt dieses Niveau langsam an, und zwar unabhängig von der Last. Diese eine Unterscheidung erspart die meisten Diskussionen, weil sie die Frage beantwortet, ob mehr Speicher überhaupt helfen kann.
Ein zweites Muster ist ebenfalls verräterisch. Wenn der Sammler kurz vor dem Abbruch immer häufiger und immer länger läuft und dabei immer weniger zurückgewinnt, ist der Heap voll mit Objekten, die noch erreichbar sind. Die virtuelle Maschine meldet das in manchen Fällen ausdrücklich mit dem Hinweis, dass zu viel Zeit in der Speicherbereinigung verbracht wird. Wenn ihr dieses Muster seht, ist die Suche nach der Referenzkette im Heap-Dump der richtige nächste Schritt und nicht die Erhöhung einer Zahl.
Es gibt einen dritten Fall, der beides nicht ist: eine Anwendung, deren Bedarf durch eine fachliche Änderung tatsächlich gewachsen ist, etwa weil ein Zwischenspeicher bewusst vergrößert oder eine neue Datenmenge verarbeitet wird. Auch das sieht in der Kurve nach einem Anstieg aus, aber der Anstieg hat eine Erklärung, und die Antwort ist dann tatsächlich mehr Speicher. Der Unterschied zum Leck ist, dass die Kurve nach der Änderung auf einem neuen, höheren Niveau stabil bleibt und nicht weiter steigt.
Einen Heap-Dump auswerten, ohne zu raten
Ein Heap-Dump ist eine vollständige Momentaufnahme aller Objekte im Heap samt ihrer Verweise aufeinander. Man kann ihn nicht lesen, aber man kann ihn befragen. Das erste Werkzeug dafür ist der Dominatorbaum: Er zeigt für jedes Objekt, wie viel Speicher freigegeben würde, wenn es verschwände. Damit findet man in wenigen Klicks die kleine Anzahl von Objekten, die den Großteil des Heaps festhalten, ohne vorher eine Theorie über die Ursache zu brauchen.
Der zweite Schritt ist der Pfad zur Wurzel. Für ein verdächtiges Objekt zeigt das Werkzeug, über welche Kette von Verweisen es noch erreichbar ist. Genau dort steht die Antwort: eine statische Sammlung, ein Cache ohne Verdrängung, eine Listener-Liste, aus der sich niemand abgemeldet hat, oder eine ThreadLocal-Variable an einem Thread aus einem Pool. Diese Kette lässt sich in den Quelltext übersetzen, und ab da ist es eine gewöhnliche Änderung.
Wenn kein Dump vorliegt, geht es auch mit Bordmitteln. jcmd kann von einem laufenden Prozess einen Heap-Dump anfordern, ebenso eine Aufstellung der Objekte nach Klasse und Anzahl. Diese Aufstellung ist deutlich schneller erzeugt als ein vollständiger Dump und beantwortet oft schon die Frage, welche Klasse ungewöhnlich häufig vorkommt. Zwei solche Aufstellungen im Abstand von einer Stunde zeigen die Wachstumsrichtung, und das reicht in vielen Fällen für die Eingrenzung.
Was ihr einmal einrichtet und danach nie wieder braucht
Der größte Hebel liegt nicht in der Fehlersuche, sondern in der Vorbereitung. Vier Startoptionen gehören in die Grundausstattung jeder Java-Anwendung im Betrieb: das automatische Schreiben eines Heap-Dumps beim Fehler, ein Ablageort, der den Neustart überlebt, das kontrollierte Beenden nach dem Fehler und die Aufzeichnung der Protokolle des Garbage Collectors. Zusammen kosten sie fast keine Leistung und verwandeln den nächsten nächtlichen Abbruch in einen Vorfall mit Beweismaterial.
Dazu kommt eine Entscheidung, die eher organisatorisch ist: Wohin gehen die Dumps, und wer sieht sie an? Eine Datei von mehreren Gigabyte auf einem Container, der nach dem Neustart verschwindet, hilft niemandem. Legt einen Ablageort fest, der eingebunden bleibt oder auf den geschrieben werden kann, und legt fest, wer nach einem solchen Vorfall den Dump herunterlädt. Ohne diese Festlegung sammelt ihr Daten, die nie jemand ansieht.
Der letzte Baustein ist die Überwachung. Wenn eure Kurven nur den Heap zeigen, sieht ein Prozess, der außerhalb des Heaps wächst, bis zum Abbruch unauffällig aus. Nehmt den gesamten Speicherbedarf des Prozesses und die Speichergrenze des Containers mit in dasselbe Bild. Dann seht ihr den Abstand zwischen beiden schrumpfen, und zwar Tage bevor der Container abgeräumt wird.
Dazu passende Kurse
Wenn die Fehlersuche im Speicher bisher an einer Person hängt, verteilen Java-Trainings mit Schwerpunkt auf Laufzeitverhalten dieses Wissen im Team.
Damit ihr den Anstieg seht, bevor der Container abgeräumt wird, findest du bei cmt Kurse zur Überwachung von Anwendungen im Betrieb .
Wie sicher bist du beim Thema wirklich?
Lesen fühlt sich schnell nach Können an. Ein kurzer Test zeigt dir, was davon schon sitzt und wo sich ein Kurs lohnt. Kostenlos, ohne Anmeldung, mit einer Erklärung zu jeder Antwort.
Wo du genau das übst
Sehr umfangreicher Überblick über Zusammenhänge und Anwendungsbereiche. Sehr zu empfehlen.
Effektives, gemeinsames Erarbeiten der Aufgaben mit vielen praktischen Tipps.
Sehr guter Trainer und gute Aufbereitung der Inhalte und auch eingehen auf Fragen der Beteiligten.
Häufige Fragen
Hilft es, einfach mehr Heap zu geben?
Warum brauchen wir einen Heap-Dump, wenn wir doch eine Überwachung haben?
Warum sehen wir gar keinen OutOfMemoryError, obwohl der Dienst neu startet?
Wie finden wir heraus, wie viel Speicher außerhalb des Heaps gebraucht wird?
Wie oft sollten wir einen Dienst vorsorglich neu starten?
Passt thematisch dazu
Steht im Protokoll gar kein OutOfMemoryError und verschwindet der Prozess trotzdem, hat das Betriebssystem eingegriffen, und ab dort hilft die Suche nach dem Speicherfresser mit vmstat und dem Kernel-Protokoll weiter.
Deine Ansprechpartner
Du bist dir nicht sicher, welcher Kurs oder welches Level zu dir passt? Wir beraten dich persönlich und kostenlos.
Yves Hoppe
Weiterbildung & Beratung
Hilft dir, aus dem Programmierung-Programm den passenden Kurs für deinen Stand zu finden.
Norbert Jansen
Beratung & Inhouse
Plant mit dir Inhouse-Trainings, die auf eure Abläufe und euren Datenbestand zugeschnitten sind.
Speicherprobleme an einer laufenden Anwendung untersuchen
Bei cmt arbeitest du im Java-Aufbaukurs mit Heap-Dump, Thread-Dump und den Protokollen des Garbage Collectors an echten Beispielen und siehst dabei, wie sich ein Leck von einer zu knappen Einstellung unterscheidet.
Passt dazu
- Java 17, 21 oder 25: welche LTS-Version euer Projekt tragen sollte
- Von Java 8 wegkommen: was der Sprung auf eine aktuelle LTS wirklich verlangt
- Spring Boot oder Quarkus: was Startzeit und Speicher im Betrieb kosten
- Goroutines, die nie enden: Leaks in Go finden und vermeiden