Java Memory Leaks zur Laufzeit aufspüren (5. Akt)

1 Kommentar

Der Akt 4 unserer Serie über OutOfMemoryError schloss mit dem Ausblick auf bessere Verfahren zum Aufspüren von Memory Leaks. Wir haben beschrieben, dass man in HeapDumps zwar große Objekte finden kann, die Aussagekraft über Leaks aber oft nur dann hoch ist, wenn ein OutOfMemoryError aufgetreten ist. Um dann in einer post-mortem Analyse etwas finden zu können sollte unbedingt der Parameter -XX:+HeapDumpOnOutOfMemoryError verwendet werden.

Doch dieses Verfahren ist nicht immer gut geeignet um Memory Leaks zu finden. Zum einen muss man warten bis ein Fehler auftritt, zum anderen führt ein sehr langsam wachsendes Memory Leak vielleicht gar nicht zu einem OutOfMemoryError, weil die Server, und damit die JVMs, sowieso regelmäßig für Deployments oder gerade zur Bekämpfung von Speicherproblemen neu gestartet werden.

Das Verfahren solche „schleichenden“ Memory Leaks zu finden ist deutlich komplizierter und aufwändiger als eine post-mortem Analyse. Mittels mehrerer, zeitlich versetzten, Dumps ist es zwar möglich wachsende Strukturen über Zeit zur erkennen, in der Praxis ist dies aber häufig sehr mühselig und oft kaum informativer als einzelne Dumps, da die Vielzahl von normalen Schwankungen zwischen Dumps die wirklich interessanten Delta doch deutlich überlagert. Zudem muss man zur Analyse auch die das Memory Leak verursachenden Use Cases kennen, um das Problem gezielt nachstellen zu können. Ferner ist es in Produktion zudem fast unmöglich einen vollen Heapdump zu ziehen, da das System, je nach Heap Größe, mehrere Sekunden bis Minuten still steht.

Besser ist es zur Laufzeit des Programms den Heap und die darin enthaltenen relevanten Objekte dauerhaft zu überwachen. So kann herausgefunden werden welche Objektstrukturen über eine längere Zeit weiter wachsen. Und da das Programm ja noch läuft, kann ferner auch ermittelt werden welcher Code mit dieser Objektstruktur interagiert. Dies ist mit HeapDumps nicht möglich, da keinerlei Codeinformationen enthalten sind.

Das Konzept

Das bei der Laufzeitanalyse durchzuführende Muster ist vergleichsweise simpel:

  • Ermittle alle vom Programm angelegten Objekte.
  • Beobachte diese Objekte und vermerke ihre Größe.
  • Alarmiere bei „verdächtigem“ Verhalten.
  • Liefere zu Diagnosezwecken Inhalt und Codestellen.

Leider bietet jeder der Punkte für die Praxis eine Vielzahl von Tücken, so dass es kaum Implementierungen einer derartigen Speicherüberwachung gibt. Schon das Ermitteln aller Programmobjekte ist nicht einfach. In Akt 4 habe ich empfohlen sich bei der Suche auf eigene Packages, wie z.B. de.codecentric.memoryleak zu konzentrieren. Doch was machen wir wenn es Standardklassen sind, die den Platz verbrauchen? Während eine Testanwendung noch überschaubar ist, bieten echte Anwendungen mehrere Millionen Objekte. Wie können sinnvoll Daten über diese komplexen Strukturen gespeichert werden? Und was ist überhaupt ein verdächtiges Verhalten? Bis zu welcher Größe und Lebensdauer sind Objekte „normal“?

Die Implementierung

Als Beispiel für dieses Verfahren wird hier das Leak Detection Feature der APM Lösung AppDynamics vorgestellt. Es handelt sich dabei neben dem Introscope Leak Hunter um die einzige mir bekannte Implementierung einer Leakerkennung, welche keine Art von Heap Dumps verwendet. Sollte einem Leser eine andere ähnlich funktionierende Lösung bekannt sein, so würde ich mich über Kommentare freuen!

Annahmen

Wie schon zu erahnen ist, ist die oben skizzierte Lösung nicht realistisch umsetzbar. Es müssen vereinfachende Annahmen getroffen werden. Für Java Programme gelten eine Reihe von Merkmalen welche man nutzen kann. Ein Merkmal, nämlich die Altersverteilung von Objekten, machen sich zum Beispiel wie in Akt 3 beschrieben, die Garbage Collectoren zu Nutze indem sie Generationen verwalten.

AppDynamics trifft folgende Annahmen:

  • Alle Objekte zu überwachen ist sinnlos. Die Erfahrung zeigt, dass ein Großteil der Memory Leaks darauf zurückzuführen ist, dass Objekte in Collection Strukturen wie Listen und Maps (z.B. Caches) abgelegt und dort vergessen werden.
    Deshalb überwacht AppDynamics nur diese Klassen.
  • Es müssen nur die Collections überwacht werden mit denen der laufende Code überhaupt interagiert. Collections die z.B. einmalig vom Application Server angelegt und seitdem nicht wieder genutzt werden fallen so heraus.
  • Von den aktiven Collections müssen nur diejenigen überwacht werden, welche eine Mindestanzahl von Objekten enthalten. Dies folgt aus der Eigenschaft eines Leaks stetig zu wachsen.
  • Die großen, aktiven Collections können bereits ein Leak sein, wirklich relevant für die Stabilität wird es jedoch erst wenn der belegte Speicher eine gewisse Größe einnimmt.
  • Die vorgenannten Kriterien treffen dauerhaft auf die Collections zu.

Dabei geht AppDynamics bei der Suche nach Leaks in dieser Reihenfolge vor. So wird erreicht, dass die zusätzliche Belastung für die JVM reduziert wird. Zudem hat AppDynamics Verfahren entwickelt, um die Größe von Objektbäumen sehr schnell und mit sehr wenig Overhead zu berechnen, auch bei hoher Last.
Trotzdem ist bei der Speicheranalyse immer mit höherem Overhead zu rechnen.

Mein Open Source Collection Analyzer

Da es sich bei AppDynamics um eine kommerzielle Lösung handelt, wollte ich mich selber mal an einer Implementierung eines derartigen Verfahrens machen.
Meine Implementierung eines rudimentären Java Memory Analyzers findet sich auf Github.

Das Grundprinzip ist einfach realisiert. Der Analyzer besteht nur aus 2 Java Klassen.

CollectionAnalyzerAspect

Ich treffe die gleiche Annahme wie AppDynamics und interessiere mich hier für Collections. Ich könnte auch andere Klassen nehmen die ich für Leakverdächtig halte:

@Before("   call(* java.util.Map.put(..)) &&
            !this(de.codecentric.performance.memory.CollectionAnalyzerAspect)")
public void trackMapPuts(final JoinPoint thisJoinPoint) {
	Map target = (Map) thisJoinPoint.getTarget();
	CollectionStatistics stats = getStatistics(target);
	stats.recordWrite(getLocation(thisJoinPoint));
	stats.evaluate(target.size());
}

Dieser Pointcut fügt meinen Code also vor allen Aufrufe von Map.put() ein. Da ich selber aber auch eine Map verwende um mir gewisse Daten zu speichern, muss ich mich selbst ausnehmen um Rekursionen vorzubeugen. Als Nächstes hole ich mir das Statistikobjekt für diese Collection um dann den schreibenden Zugriff zu protokollieren und eine Auswertung durchzuführen.
Hier bin ich schon gleich den ersten Kompromiss eingegangen. Sinnvoller wäre es die Auswertung periodisch nebenläufig durchzuführen anstelle die Ausführung dafür zu pausieren.
Ein interessantes Problem habe ich noch: Wie kann mich mir merken welche Collection das ist, die ich mir da gerade anschaue? Ich verwende den „identityHashCode“, wohlwissentlich, dass dies nicht 100% zuverlässig ist.

int identityHashCode = System.identityHashCode(targetCollection);

CollectionStatistics

Nun, da ich für jede Collection weiß, wie oft welche Methoden aufgerufen werden, was mach ich damit?

public void evaluate(int size) {
	if (size >= DANGEROUS_SIZE) {
		System.out.printf("\nInformation for Collection %s (id: %d)\n", className, id);
		System.out.printf(" * Collection is very long (%d)!\n", size);
		if (reads == 0)	System.out.printf(" * Collection was never read!\n");
		if (deletes == 0) System.out.printf(" * Collection was never reduced!\n");
		System.out.printf("Recorded usage for this Collection:\n");
		for (String code : interactingCode) {
			System.out.printf(" * %s\n", code);
		}
	}
}

Ich konnte leider nicht die Frage beantworten wann die Größe einer Collection eigentlich „schlimm“ ist. Also verwende ich der Einfachheit halber erst mal eine hartkodierte Länge der Collection. Es währe sicherlich auch eine gute Idee über eine WeakReference in dem DominatorTree der Collection die Deep Size zu berechnen, aber das ist leider recht aufwändig.

Neben der Größe finde ich auch zwei weitere Dinge interessant:

  • Wurde diese Collection ausgelesen?
  • Wurde etwas aus dieser Collection gelöscht?

Beides sind typische Antipattern für Caches. Wenn niemand aus einer großen Liste löscht oder liest, dann warne ich deswegen. Zuletzt gebe ich noch alle Aufrufstellen aus. Eine nützliche Information!

Ein Testlauf

Information for Collection java.util.ArrayList (id: 1813612981)
 * Collection is very long (5000)!
 * Collection was never reduced!
Recorded usage for this Collection:
 * de.codecentric.performance.LeakDemo:19
 * de.codecentric.performance.LeakDemo:17
 * de.codecentric.performance.LeakDemo:18
 
Information for Collection java.util.ArrayList (id: 1444378545)
 * Collection is very long (5000)!
 * Collection was never read!
 * Collection was never reduced!
Recorded usage for this Collection:
 * de.codecentric.performance.LeakDemo:18
 
Information for Collection java.util.HashMap (id: 515060127)
 * Collection is very long (5000)!
 * Collection was never read!
 * Collection was never reduced!
Recorded usage for this Collection:
 * de.codecentric.performance.LeakDemo:19
 
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at de.codecentric.performance.DummyData.(DummyData.java:5)
	at de.codecentric.performance.LeakDemo.runAndLeak(LeakDemo.java:17)
	at de.codecentric.performance.DemoRunner.main(DemoRunner.java:12)

Es funktioniert!

Overhead

Zu jeder Collection alle Zugriffspfade zu speichern ist enorm Speicherintensiv. Wahrscheinlich sollte ich diese nur sammeln, wenn die CollectionStatistics auf ein Leak schließen lassen. Durch AspectJ werden meine Pointcuts auch immer angewandt. In echten Umgebungen mit hunderttausenden solcher Collections ist das sicher nicht gut. Hier müsste dynamische Bytecodeinstrumentierung verwandt werden. Auch führe ich keine zeitliche Betrachtung durch.

Wie man gut sehen kann ist die Idee einfach umgesetzt, benötigt aber für Produktionstauglichkeit einiges an Gehirnschmalz. Wer Lust hat kann gerne via GitHub Patches senden.

Memoryanalyse einer Beispielanwendung mit AppDynamics

Schauen wir uns nun am Beispiel von AppDynamics an wie eine etwas professionellere Lösung arbeitet.

10:43 – Application Server Restart

Startet man die AppDynamics Leak Detection ist nicht unmittelbar ein Ergebnis sichtbar. Im Hintergrund werden die Collections analysiert. Nach einiger Zeit tauchen jedoch möglicherweise leakende Collections auf.

11:00 – Collection erkannt

Die java.util.LinkedList hier erachtet AppDynamics als interessant. Mit 56.881 Einträgen ist sie in der Tat schon recht umfangreich. Da es aber noch keine Langzeitinformationen gibt ist sie nicht als „potentially leaking“ markiert.

11:10 – Collection potentially leaking

Nach etwas Zeit ist diese Liste weiter gewachsen. Mit 98.850 Einträgen ist sie schon fast doppelt so groß wie auf dem letzten Bild. Die internen Heuristiken markieren sie jetzt als „potentially leaking“.

11:17 – Das Leak wächst weiter

Die Übersichtsansicht zeigt, dass das Leak weiter wächst. Garbage Collections würden hier ebenfalls eingetragen um Effekte von SoftReferences sehen zu können.

11:30 – Ein Speicherleck enthüllt

Die Content Inspection zeigt den Inhalt der Collection. In diesem Fall sind es 118.990 java.lang.String Objekte mit einer Gesamtgröße von 20MB.
AppDynamics erlaubt es auch diese Collection und ihren Inhalt als Dump zur genaueren Analyse wegzuschreiben.

11:38 – Der Schuldige wird identifiziert

Mittels einer Access Tracking Session stellt AppDynamics fest, wer das Speicherleck verursacht. Während man bis hier hin auch irgendwie mittels Heap Dumps kommen könnte, ist diese Auflistung der Aufrufhierarchien etwas Besonderes. LinkedLists mit Strings hätten viele Stellen im Code nutzen können, aber diese leakende LinkedList wird in der „newbookmark“ Business Transaktion verwendend.
Die BookmarkDaoImpl fügt in Zeile 50 Strings in diese Liste ein. Lesenden, oder gar Code der Objekte aus der Liste entfernt, konte AppDynamics nicht feststellen.

Somit liefert AppDynamics alle Informationen die man für ein Analyse und Behebung von Memory Leaks benötigt:

  • Potentiellen Memory Leak Objektstrukturen werden angezeigt.
  • Man wird über die Gefahr eines Memory Leaks automatisch informiert.
  • Die Inhalte der Objektstrukturen können analysiert werden.
  • Die Business Transaktion(en) (Use Case) die für das Leak verantwortlich ist (sind), werden identifiziert.
  • Alle Zugriffsmethoden auf das potentielle Leak werden angezeigt.

Die Entscheidung, ob etwas tatsächlich ein Memory Leak ist, liegt aber weiterhin beim Entwickler.

Fazit

Es ist möglich Java Memory Leaks zur Laufzeit ohne die Erzeugung von Heap Dumps zur Laufzeit zu identifizieren und zu beheben. Die Information über die Codepfade ist äußerst wertvoll bei der Korrektur eines Leaks. Leider gibt es keine frei verfügbaren Produkte die bei der Leaksuche auf diese Art helfen. Eine Eigenimplementierung ist wie beschrieben nicht zu empfehlen.
Allerdings bietet AppDynamics eine 30 Tage Testversion an, so dass jeder die beschriebenen Funktionen selber testen kann.

Fabian Lange ist Lead Agent Engineer bei Instana und bei der codecentric als Performance Geek bekannt. Er baut leidenschaftlich gerne schnelle Software und hilft anderen dabei, das Gleiche zu tun.
Er kennt die Java Virtual Machine bis in die letzte Ecke und beherrscht verschiedenste Tools, die JVM, den JIT oder den GC zu verstehen.
Er ist beliebter Vortragender auf zahlreichen Konferenzen und wurde unter anderem mit dem JavaOne Rockstar Award ausgezeichnet.

Share on FacebookGoogle+Share on LinkedInTweet about this on TwitterShare on RedditDigg thisShare on StumbleUpon

Kommentare

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.