Auf der Jagd nach dem verlorenen Speicher

Herausgeber: Java Magazin

Ausgabe: Juli 2009

Autor: Mirko Novakovic

Oft gestaltet sich die Suche nach Memory Leaks als echtes Abenteuer und man muss sich durch einen Dschungel an Objekten und Referenzen kämpfen. Speziell wenn Produktivsysteme
betroffen sind, heißt es, schnell handeln. So wie Indiana Jones immer wieder auf verblüffende Art Hinweise interpretiert und Rätsel löst, um verlorene Schätze zu finden, wollen wir uns auf die Suche nach Memory Leaks begeben.

 

Lesen Sie nun den kompletten Artikel oder laden Sie sich die kostenlose Ausgabe als Download herunter.

 

Im letzten Artikel haben wir bereits die drei vorkommenden Typen von Speicherproblemen – Memory Leaks, ineffiziente Objekterzeugung und schlechte Garbage-Collector- Konfiguration – angesprochen. Zusätzlich wurden anhand einiger Beispiele typische Probleme aufgezeigt und erläutert. Obwohl Speicherprobleme neben klassischen Laufzeitproblemen die zweithäufigste Ursache für Performanceprobleme sind, bereitet deren Analyse oft Kopfzerbrechen. In diesem Artikel fokussieren wir daher auf die Analyse von Speicherproblemen, indem wir einerseits methodisches Vorgehen zum Finden der Probleme und andererseits Details zum Speichermanagement in Java als Verständnisgrundlage erklären.

Wir packen in unseren Koffer …

… einen Heap Analyzer und eine Konsole zur Analyse von Laufzeitperformancewerten Laufzeitperformancewerten. Mit diesen Werkzeugen sind wir bestens für die Speicheranalyse gerüstet. Welches Werkzeug man konkret wählt, hängt vom persönlichen Geschmack und der eigenen Geldbörse ab. Der Open-Source-Bereich bietet hier bereits einiges an und es mangelt
auch nicht an professionellen Werkzeugen.

Heap Dump

Ein Heap Dump ermöglicht uns, ein Abbild des Hauptspeichers als Snapshot zu ziehen und dann zu analysieren. Man kann ihn über Funktionen der JVM erzeugen oder über spezielle Tools, die das JVM Tool Interface (JVMTI) nutzen. Der integrierte Heap Dump der JVM ist leider nicht standardisiert und je nach JVM-Hersteller unterschiedlich. Der im letzten Artikel angesprochene JSR-326 beschäftigt sich auch in diesem Umfeld mit einer Standardisierung. Zwar sollen hier nicht die Datenstrukturen, sondern die APIs standardisiert werden, was einen einheitlichen Zugriff auf diese Daten ermöglicht. In den meisten Fällen beinhaltet der Dump aber die Objekte auf dem Heap inklusive der eingehenden und ausgehenden Referenzen. Neben diesen enthalten die Dumps oft auch noch Informationen über die Größe des Objekts und den Inhalt der primitiven Java-Typen. Gerade die Referenzen der Objekte untereinander sind unerlässlich, um ein Memory Leak zu identifizieren, denn nur wenn man die Struktur des Speichers versteht, kann man herausfinden, wo und wodurch ein Speicherproblem aufgetreten ist. Werkzeuge bieten hier idealerweise noch die Möglichkeit, mehrere Dumps zu vergleichen. Damit können schnell jene Klassen gefunden werden, deren Objekte kontinuierlich ansteigen. Zusätzlich bieten einige Tools noch die Möglichkeit, die Anzahl der überlebten Garbage Collections pro Objekt anzuzeigen. Dadurch kann schnell herausgefunden werden, welche Objekte nicht freigegeben werden. Optimal ist es, wenn man zu jedem Objekt auch den Java Call Stack sehen kann, der dieses Objekt erzeugt hat. Identifiziert man erst einmal das Objekt, das ein Kandidat für ein Memory Leak ist, erhält man so direkt die Information darüber, wo das Objekt im Code instanziert wurde.

JVM-Metriken

Mithilfe der integrierten JVM-Metriken, die seit Java 5 standardisiert über JMX bereitgestellt werden, können wir den Heap zur Laufzeit beobachten. Anhand der Auslastung des Speichers können wir erkennen, ob wir überhaupt ein Speicherproblem haben. Dieses ist allerdings bei schleichenden Memory Leaks, die sehr langsam entstehen, oft über lange Zeit nur sehr schwer zu erkennen. Neben einem Überblick über das Speicherverhalten im Groben sind wir auch an Details zu den einzelnen Speichergenerationen interessiert. Auch wenn wir kein Memory Leak haben, können wir hier sehr schön erkennen, ob unsere Anwendung eine schlechte Konfiguration der Speicherparameter oder ineffiziente Objekterzeugung aufweist. Aus Laufzeitsicht sind wir zudem an der Zeit, die im Garbage Collector verbracht wird, interessiert. Hierbei interessieren uns die Anzahl der Collections und auch deren Zeit sowie Typ. Mehr Details dazu später. Mit diesen Werkzeugen gerüstet, können wir uns auf die Jagd nach Speicherproblemen machen.

Ich habe da etwas vergessen

Wie schon im letzten Artikel beschrieben, handelt es sich bei Speicherproblemen in Java nicht um Leaks im klassischen Sinne. Also um Speicher, der allokiert wurde, aber obwohl er nicht mehr referenziert wird, noch nicht freigegeben wurde. In Java kümmert sich die JVM um die Freigabe, wir – die Entwickler – müssen nur alle Referenzen auf ein Objekt freigeben. Das bedeutet, dass Speicherprobleme in Java immer dann entstehen, wenn Objekte außerhalb des Scopes der aktuellen Abarbeitung referenziert werden. Die Servlet-Session und den Thread Local Storage haben wir als Beispiele im letzten Artikel bereits erwähnt. Hinzu kommen noch statische Variablen und Felder von Objekten, die im Wesentlichen nie aufgeräumt werden. Im Zusammenhang mit Memory-Management sind GC Roots ein zentrales Konzept, das man verstehen muss, wenn man die kritischen Referenzen auf ein Objekt identifizieren möchte. Garbage Collector Roots sind Objekte, auf die es keine eingehenden Referenzen gibt und die dafür verantwortlich sind, dass referenzierte Objekte im Speicher gehalten werden. Wird ein Objekt weder direkt noch indirekt von einem GC Root referenziert, wird es als unreachable gekennzeichnet und zur Garbage Collection freigegeben. Es gibt vereinfacht drei Arten von Garbage Collection Roots:

  • Temporäre Variablen auf dem Stack eines
  • Threads Statische Variablen von Klassen
  • Spezielle native Referenzen in JNI

Ein Objekt allein macht noch kein Memory Leak aus. Wenn der Speicher sich immer weiter füllt, müssen kontinuierlich immer mehr von diesen Objekten hinzukommen. Collections sind hier also besonders kritisch, da diese zur Laufzeit praktisch unbegrenzt wachsen und dadurch auch viele Referenzen halten, die das Aufräumen von Objekten verhindern können. Daraus können wir ableiten, dass sich die meisten Speicherprobleme in großen Collections, die direkt oder indirekt statisch referenziert werden, manifestieren. Sehen wir uns das am Beispiel der HTTP-Session an.

Abb. 1: Root-Pfad eines HTTP-Session-Objekts in Tomcat

In Abbildung 1 sehen wir ganz oben die HTTPSession oder besser gesagt deren Implementierung in Tomcat. Die Session wird in einer ConcurrentHashmap abgespeichert, die über den ThreadLocal des Servlet Threads referenziert wird. Dieser ist selbst wieder in einem Thread Array abgelegt, das Teil einer ThreadGroup ist. Diese ThreadGroup wird dann weiter von einem Thread referenziert.

Das zeigt, dass die meisten Speicherprobleme auf ein bestimmtes Objekt im Heap zurückgeführt werden können – sozusagen die Wurzel allen Übels. Des Weiteren wird im Zusammenhang mit der Memory-Leak-Analyse oft von so genannten Dominatoren oder Dominator Trees gesprochen. Das Konzept der Dominatoren kommt aus der Grafentheorie und definiert einen Knoten als Dominator eines anderen Knotens, wenn er nur durch diesen erreicht werden kann. Auf das Speichermangement umgesetzt, ist also jenes Objekt Dominator eines anderen, wenn es kein zweites Objekt gibt, das darauf Referenzen hält. Ein Dominator Tree ist dann ein Teilbaum, in dem diese Bedingung vom Wurzelknoten aus für alle Kinder gilt. Wird also die Wurzelreferenz freigegeben, wird auch der ganze Dominator Tree freigegeben. Sehr große Dominatorenbäume sind also sehr gute potenzielle Kandidaten bei der Memory-Leak-Suche.


Abb. 2: Symbolisierter Speichergraf mit Dominator

Abbildung 2 zeigt einen symbolisierten Speicherbaum mit einem Dominator-Baum. Einige Tools verwenden die Dominator-Tree-Analyse zum Erkennen von Memory Leaks [2]. Andere wiederum verlassen sich auf die simulierte Gargabe-Collection-Größe von Objekten – also die Menge an Speicher, die bei Freigabe eines Objekts freigegeben werden kann.

Post mortem vs. Laufzeitanalyse

Bei der Memory -Leak-Suche kann man im Wesentlichen zwei Analyseverfahren anwenden. Das hängt auch von der Situation ab. Wenn sich die Anwendung bereits mit einem Out of Memory Error verabschiedet hat, bleibt nur die Möglichkeit einer Post-mortem-Analyse. Um diese durchzuführen, ist es allerdings notwendig, die Sun JVM mit der Option +HeapDumpOnOutOfMemoryError zu starten – bei anderen JVM-Herstellern kann der Parameter abweichen. Dann wird automatisch versucht, einen Heap Dump zu erstellen, bevor die JVM den Dienst quittiert. Diese Option sollte grundsätzlich in jeder produktiven Anwendung verwendet werden. Obwohl sie erst mit Java 6 eingeführt wurde, haben die JVM-Hersteller diese Option auch in frühere JVMs rückportiert. Verwendet man also eine ältere JVM, empfiehlt es sich zu überprüfen, ob der verwendete Patch-Level diese unterstützt.
Post-mortem-Dumps haben den großen Vorteil, dass sie das Memory Leak bereits enthalten und das Speicherproblem nicht erst aufwändig reproduziert werden muss. Gerade bei schleichenden Memory Leaks oder bei Problemen, die nur in ganz speziellen Anwendungsfällen und mit ganz besonderen Datenkonstellationen auftreten, kann es sehr schwer sein, diese zu reproduzieren, und man ist im Prinzip auf die Generierung des Dumps im Fehlerfall angewiesen. Der Nachteil ist, dass jegliche Laufzeitinformationen, also z. B. welche Methode das Objekt erzeugt hat, verloren sind. Dafür kann man aber meist über den Dominator-Baum sehr schnell erkennen, welche Objekte für das Memory Leak verantwortlich sind, und mit den entsprechenden Entwicklern sollte man anhand der Objektnamen und des Inhalts der Objekte schnell erkennen, um welche konkreten Instanzen es sich handelt. So kann man dann die Codestellen identifizieren, die die Objekte erzeugen, und entsprechend für eine Dereferenzierung an der richtigen Stelle sorgen.
Alternativ kann es auch sein, dass man aufgrund des zunehmenden Speicherverbrauchs schon zur Laufzeit erkennt, dass ein Memory Leak aufgetreten ist. Natürlich ändert dies nichts daran, dass auch hier letztendlich die JVM abstürzen wird. Allerdings können hier schon während der Laufzeit Speicher-Snapshots gezogen werden. Da während dieser Zeit laufende Threads angehalten werden, empfiehlt es sich, in produktiven Umgebungen den Server zuvor aus dem Load Balancer eines Clusters zu entfernen. Der oder die anderen Knoten sollten allein schon aus Ausfallgründen in der Lage sein, die zusätzliche Last übernehmen zu können. Oft werden die gesammelten Daten für eine Analyse ausreichen. Zudem kann obiger Ansatz auch mehrfach wiederholt werden, um so die erzeugten Snapshots zu vergleichen und unmittelbar zu erkennen, welche Objekte kontinuierlich anwachsen. Kombiniert man dieses Verfahren mit der Überwachung von Collection-Größen, kann man viele Memory Leaks ohne aufwendige Analysen identifizieren. Kennt man den Inhalt der betreffenden Collections, ist dem Entwickler oft schon klar, wo das Problem liegt. Hat man zusätzlich noch Informationen, wo diese Objekte angelegt wurden, ist die Ursache unmittelbar identifiziert.

Auf die Größe kommt es an

Von zentraler Bedeutung bei der Memory-Dump-Analyse ist die Größe des Heaps. Größer bedeutet nicht besser. Speziell 64 bit JVMs stellen hier eine Herausforderung dar. Aufgrund der größeren Anzahl an Objekten müssen hier mehr Daten gedumpt werden, was unmittelbar zu höherem Platzbedarf und auch längerer Dump-Generierungszeit führt. Berechnungen auf diesen Dumps dauern natürlich ebenfalls länger. Speziell Algorithmen zur Berechnung der Garbage-Collection-Größe sowie der dynamischen Größe von Objekten weisen naturgemäß eine schlechtere Laufzeitperformance auf. Aus der Erfahrung der Autoren scheitern die meisten Tools bereits daran, größere Heap Dumps (ab 6 GB) überhaupt öffnen zu können. Zusätzlich ist auch noch der Laufzeit-Overhead während der Dump-Generierung zu beachten. Die Erstellung des Heap Dumps erfordert innerhalb der JVM Speicher. Dies kann dazu führen, dass die Erstellung eines Dumps unter Umständen gar nicht mehr möglich ist. Der Grund dafür liegt in der Implementierung der Heap-Dump-Methoden in JVMTI. Hierbei wird für jedes Objekt ein Tag vergeben. Dieses Tag identifiziert anschließend das Objekt. Um herauszufinden, welche Objekte sich referenzieren, ist es notwendig, zuerst alle Objekte zu taggen. Dieser Tag ist durch den JNI-Datentyp jlong repräsentiert und nimmt pro Objekt bereits 8 Byte Speicher in Anspruch. Hinzu kommt noch der Speicherbedarf für die Verwaltungsstrukturen im Hintergrund. Diese sind natürlich von der JVM-Implementierung abhängig und betragen dann insgesamt bis zu 40 Byte pro Objekt.
Es empfiehlt sich also grundsätzlich, mit kleineren Heaps zu arbeiten. Sie sind leichter zu managen und im Fehlerfall leichter zu analysieren. Speicherprobleme sind hier oft auch schneller ersichtlich. Ist eine Anwendung sehr speicherintensiv, sollte man mit mehreren JVM-Instanzen arbeiten. Mit diesem Ansatz erhält man zusätzliche Ausfallsicherheit fast geschenkt. Kann man – aus welchen Gründen auch immer – nicht auf einen großen Heap verzichten, ist es unabdingbar, vorab zu testen, ob es im Problemfall auch analysiert werden kann. Sonst findet man sich schnell in einer Situation wieder, in der man ernsthaften Speicherproblemen machtlos – oder besser ohne Werkzeugunterstützung – gegenüber steht.

Vorbeugende Memory-Leak-Suche in der Entwicklung

Die besten Memory Leaks sind natürlich jene, die man gar nicht hat. Deshalb sollte man schon während der Entwicklung durch geeignete Tests nach potenziellen Memory Leaks suchen. Hierfür eignen sich langlaufende Lasttests am besten. Da es uns in diesem Fall nicht um Performancetests, sondern um die Fehlersuche geht, können wir mit einer kleinen Lasttestumgebung arbeiten. Oft reicht hier auch ein einziger Rechner aus, auf dem Anwendung  und Lasttreiber laufen. Mit etwas Glück zeigen sich dann Memory Leaks als Out of Memory Errors. Wie schon angesprochen, kommt es aber oft auf die Konstellation der Benutzertransaktionen an. In diesem Fall kann es passieren, dass gerade schleichende Leaks nicht unmittelbar auftreten. Durch Vergleichen mehrerer Heap Dumps kann man auch hier schnell erkennen, welche Objekte kontinuierlich anwachsen. Durch eine Heap-Analyse lassen sich dann Programmierfehler schneller finden.

Fazit

Memory Leaks führen in Anwendungen zwangsläufig zu Anwendungsproblemen. Anfangs kommt man sich bei der Fehlersuche oft überfordert vor. Gutes Verstädnis über die Struktur des Java Heaps hilft allerdings, Probleme schnell einzugrenzen und effektiv zu beseitigen. Langzeittests helfen, diese bereits während der Entwicklung zu finden. Dennoch sind die notwendigen Vorbereitungen, um eine effiziente Memory-Analyse durchführen zu können, ein Kernbestandteil jedes produktiven Anwendungs-Deployments. Einfach den Heap zu vergrößern, stellt keine Alternative bei hohem Speicherverbrauch dar. Das kann sogar noch zu zusätzlichen Problemen führen. Ob man eher zur Domintor-Tree-Analyse oder Laufzeitanalyse tendiert, hängt vom persönlichen Geschmack und nicht zuletzt von der Komplexität der Analyse ab. Für größere Heaps ist die Laufzeitanalyse zu bevorzugen.

Links & Literatur

[1] Dominator and Dominator Tree, Wikipedia: http://en.wikipedia.org/wiki/Dominator_(graph_theory )
[2] Automated Heap Dump Analysis for Developers, Testers, and Support Employees @ JavaOne: http://developers.sun.com/learning/javaoneonline/j1sessn.jsp?sessn=TS5729&yr=2008&track=tools
[3] Java Virtual Machine Tool Interface: http://java.sun.com/j2se/1.5.0/docs/guide/jvmti/jvmti.html
[4] JSR 326 Post Mortem JVM Diagnosis API: http://jcp.org/en/jsr/detail?id=326
[5] The Truth about Garbage Collection: http://java.sun.com/docs/books/performance/1st_edition/html/JPAppGC.fm.html
[6] Java Heap Dumps erzeugen: http://blog.codecentric.de/2008/07/memory-analyse-teil-1-java-heapdumps-erzeugen/

Zurück zu den Publikationen