![]() |
GedächtnislückeHerausgeber: Java Magazin Ausgabe: Juni 2009 Autor: Mirko Novakovic |
Memory Leaks und andere Speicherprobleme gehören zu den beliebtesten Skalierbarkeits- und Performance-Killern im Java-Umfeld – hier lohnt sich eine genauere Analyse.
Lesen Sie nun den kompletten Artikel oder laden Sie sich die kostenlose Ausgabe als Download herunter.
Das Java Memory Management hat viele Speicherprobleme gelöst – aber auch neue geschaffen. Gerade bei JEE-Anwendungen mit vielen parallelen Benutzern kristallisiert sich der Speicher als eine sehr kritische Ressource heraus. Der Artikel zeigt einige der häufigsten Antipatterns im Umfeld des JMM. In der 2008 auf der JAX durchgeführten Performancestudie [1] werden Memory Leaks von fast der Hälfte aller Befragten als Ursache für Performance- und Skalierbarkeitsprobleme genannt. Immerhin jeder Dritte nennt Javas Garbage Collector als Performance Bottleneck. Im Zeitalter von günstigem RAM, 64 bit JVMs und modernen Garbage- Collector-Algorithmen erscheint es auf den ersten Blick seltsam, dass genau das Memory Management Ursache für mangelnde Skalierbarkeit und Performance von JEE-Anwendungen sein soll. Es gibt verschiedene Arten von Problemen, die im Bereich des Memory Managements auftreten können. Im Prinzip lassen sie sich in drei Kategorien einteilen:
Memory Leaks sind keine Speicherlecks im klassischen Sinne, sondern bezeichnen Objekte, die innerhalb der Anwendung noch referenziert, aber nicht mehr benötigt werden.
Unnötig hoher Speicherverbrauch wird dann zum Problem, wenn mehr Speicher benötigt wird als verfügbar ist. Dies kann beispielsweise durch große und nicht limitierte Caches oder bei zu vielen und zu großen parallelen Sitzungsobjekten permanent oder temporär auftreten.
Ineffiziente Objekterzeugung wird bei steigender Last schnell zu einem Performanceproblem, da der Garbage Collector ständig Objekte aufräumen muss. Dies führt auch dazu, dass unnötig viel CPU-Zeit vom Garbage Collector verbraucht wird. Dadurch können bereits unter geringer Last Antwortzeiten sehr schnell ansteigen. Dieses Verhalten – das ständige Erzeugen und Aufräumen von Objekten – wird auch als GC Trashing bezeichnet.
Inperformantes Verhalten des Garbage Collectors ist auf dessen mangelnde oder schlechte Konfiguration zurückzuführen. Der Garbage Collector kümmert sich zwar darum, dass Objekte aufgeräumt werden, wie dies geschehen soll, muss allerdings spezifiziert werden. Sehr häufig wird die Konfiguration und das Tunen des Heaps und der Garbage Collection einfach „vergessen“.
In der Regel wirken sich Probleme mit Speicher vor allem auf die Skalierbarkeit von Anwendungen aus. Je höher der Bedarf an Speicher pro Transaktion, Sitzung oder Benutzer, desto weniger parallele Aktionen können von der Anwendung verarbeitet werden. Im Extremfall kann nicht nur die Performance des Systems eingeschränkt werden, sondern auch die Verfügbarkeit – nämlich dann, wenn der JVM der Speicher ausgeht und Sie dies mit einem OutOfMemoryError quittiert. Bei unternehmenskritischen Anwendungen eine Höchststrafe, die dem Entwickler nicht selten den Besuch von irritierten Managern beschert. Die Schwierigkeit bei der Behebung von Memory- Problemen liegt zum einen an der teilweise komplexen und aufwändigen Analyse des Problems und zum anderen daran, dass die Memory-Probleme in der Architektur des Systems begründet liegen und mit einfachen Codeänderungen nicht zu beheben sind. Die Antipatterns in diesem Artikel sollen helfen, Memory-Probleme schon bei der Konzeption der Anwendung zu verhindern.
HTTP-Session als Cache
Das Session-Caching-Antipattern bezeichnet den Missbrauch der HTTPSession als Daten-Cache. Die Websession dient bei Webanwendungen dem Speichern von Daten des Benutzers, die einen einzelnen HTTP-Request „überleben“ sollen. Dies wird auch als „Conversational State“ bezeichnet. Sprich, es werden Daten über mehrere Interaktionen hinweg eingegeben und zwischengespeichert, bis sie letztendlich verarbeitet werden. Dieses Verhalten findet man bei so ziemlich jeder nicht trivialen Benutzerinteraktion. Da diese Daten in einer klassischen Webanwendung am Server gespeichert werden müssen, bietet sich hierfür die HTTP-Session an. Wichtig ist hier, im Sinne eines niedrigen Speicherverbrauchs, dass möglichst wenige Daten in der Session gehalten werden – und das auch nur so lange wie nötig. Nicht selten findet man aber Sessions mit mehreren Megabytes an Objektdaten. Diese Sitzungen, in Verbindung mit einigen parallelen Benutzern der Anwendung, führen sehr schnell dazu, dass der Heap stark ausgelastet und Speicher zu einem Engpass wird. Zudem ist natürlich die Anzahl der parallelen Benutzer limitiert, wenn man einem Out- OfMemoryError entgehen möchte. Bei Clustern mit Sessionreplikation wird, je nach eingesetzter Technologie, der Performanceverlust durch Serialisierung und Datenübertragung schnell zu einem zusätzlichen Problem. Einige Projekte helfen sich damit, neue Hardware und mehr Speicher anzuschaffen – gerade 64bit JVM erlauben Heap-Größen jenseits der einstelligen Gigabytes. Auf Dauer ist dies eine sehr teure und riskante Lösung, die die Probleme in der Praxis nur zeitlich nach hinten verschiebt. Zusätzlich wird es mit sehr großen JVM immer schwieriger, „echte“ Speicherprobleme zu finden. Memorydumps im hohen Gigabytebereich können nur schwer und von wenigen Tools verarbeitet werden. Dies ist ein Problem, dem sich JSR-326 – Post Mortem JVM Diagnosis API [2] – annimmt.
Session-Caches entstehen, weil in der Architektur der Anwendung nicht klar definiert wurde, welche Daten sitzungsabhängig und welche persistent sind. Während der Entwicklung werden dann schnell alle Daten in der Sitzung abgelegt, weil dies eine sehr komfortable Lösung ist. In vielen Fällen passiert das nach dem Motto: „Hinzufügen und vergessen“ – denn nur selten werden die Daten wieder explizit aus der Sitzung entfernt. Um solche vergessenen Daten sollte sich eigentlich der Session-Timeout kümmern. Bei ständiger intensiver Verwendung der Anwendung, wie das speziell bei webbasierten Enterprise- Anwendungen vorkommt, ist das aber häufig nicht der Fall. Außerdem wird oft der „Benutzerkomfort“ erhöht, indem mit sehr hohen Timeouts von bis zu 24 Stunden gearbeitet wird. Ein Beispiel aus der Praxis ist das Ablegen von Daten, die in HTML-Auswahlfeldern angezeigt werden (z. B. Länderlisten). Diese statischen Daten sind oft mehrere Kilobyte groß und werden so pro Benutzer im Heap vorgehalten. Besser ist es, solche Daten – die zudem nicht benutzerspezifisch sind – einmal in einem zentralen Cache zu hinterlegen. Ein weiteres Beispiel ist der Missbrauch der Hibernate- Session zum Managen von Conversational State. Die Hibernate-Session wird in der HTTP-Session abgelegt, um so schnell wieder auf Daten zugreifen zu können. Hier werden bei weitem mehr Zustandsdaten gespeichert als nötig, und der Speicherverbrauch steigt bei einigen Benutzern sofort stark an.
In modernen Ajax-Anwendungen kann eventuell auch der Conversational State auf den Client ausgelagert werden. Dies führt im Idealfall zu einer zustandslosen oder zustandsarmen Serveranwendung, die sehr viel besser skaliert.
ThreadLocal Memory Leak
ThreadLocal-Variablen werden in Java benutzt, um Variablen an einen Thread zu binden – jeder Thread bekommt eine eigene, unabhängige Instanz dieser Variable zugewiesen. Normalerweise werden diese Variablen genutzt, um Statusinformationen innerhalb eines Threads zu halten – z. B. die Benutzererkennung oder einen Mandanten. Der Lebenszyklus einer ThreadLocal-Instanz ist eng gekoppelt an den Lebenszyklus des entsprechenden Threads. Wird der Thread beendet und durch den Garbage Collector entfernt, sind auch die damit assoziierten ThreadLocal-Variablen Kandidaten für den Garbage Collector.
Die Memory-Probleme treten dann auf, wenn ThreadLocal-Variablen innerhalb eines Application Servers verwendet werden. Sie verwalten Threads in eigenen ThreadPools, um Ressourcen einzusparen. Wird z. B. ein Http- ServletRequest an die ServletEngine des Application Servers gesendet, wird für die Abarbeitung des Servlets ein freier, so genannter Worker Thread aus dem entsprechenden Servlet ThreadPool entnommen. Dieser Thread arbeitet dann die Programmlogik des Servlets ab. Wird im Servlet oder der aus dem Servlet aufgerufenen Java-Klassen eine ThreadLocal-Variable genutzt, werden diese mit dem aktuellen Thread assoziiert. Nachdem das Servlet vom Thread abgearbeitet und die Response geschrieben wurde, wird der Thread aber nicht beendet und vom Garbage Collector aufgeräumt, sondern wieder in den ThreadPool zurückgegeben, sodass mit demselben Thread eine neue Anfrage abgearbeitet werden kann. Die Thread- Local-Variablen bleiben also erhalten.
Je nach Größe des ThreadPools (in produktiven Systemen können das mehrere hundert Threads sein) und der Größe der Objekte in der ThreadLocal- Variablen kann das zu Problemen führen. Sind beispielsweise 200 Threads im ThreadPool aktiv und ist die ThreadLocal- Variable 5 MB groß, belegen diese Variablen im schlimmsten Fall ein GB Heap-Speicher. Dies führt schnell zu erhöhter GC-Aktivität und je nach Heap- Größe zu einem Abbruch der JVM. In einem konkreten Fall musste ein Server täglich neu gestartet werden, weil es zu einem OutOfMemoryError gekommen ist. Um das Problem zu analysieren, wurde zur Laufzeit ein Heapdump erzeugt. Die Analyse des Dumps hat schnell gezeigt, dass es sich um ein ThreadLocal- Problem handelt.
Die Details eines Threads (Abb.1) zeigen, dass der hohe Speicherverbrauch auf eine ThreadLocal-Variable zurückzuführen ist, die einen DOM Parser inklusive geparstem Dokument referenziert – insgesamt mehr als 14 MB groß. Anhand der Inhalte der Objekte konnte identifiziert werden, dass die Dokumente zu einem Web-Service-Aufruf gehören. Die Zugriffsklassen wurden mit JBoss-WS generiert. Eine Suche in Google führte schnell zu einem Bug in der eingesetzten jbossws-Version-1.2.0, der in der Version 1.2.1 gefixt wurde: „DOMUtils doesn’t clear thread locals“. Das Beispiel zeigt, dass man vorsichtig sein muss, wenn man ThreadLocal- Variablen innerhalb eines Application Servers verwendet (vor allem, wenn man Hersteller von Application Servern ist). Es ist wichtig dafür zu sorgen, dass der Inhalt der Variablen gelöscht wird, bevor der Thread wieder in den Thread- Pool zurückgestellt wird.
Große Temporäre Objekte
Auch temporäre Objekte können im Extremfall zu einem OutOfMemoryError oder aber zu erhöhter GC-Aktivität führen. Dies passiert beispielsweise, wenn sehr große Dokumente (XML, PDF etc.) eingelesen und weiterverarbeitet werden müssen. In einem konkreten Fall war eine Anwendung temporär für einige Minuten nicht verfügbar, bzw. war die Performance so eingeschränkt, dass ein Arbeiten nicht möglich war. Die Ursache konnte schnell auf Speicherengpässe und eine am Limit arbeitende Garbage Collection zurückgeführt werden. Bei einer detaillierten Analyse konnte die Ursache auf das Einlesen eines PDF-Dokuments eingeschränkt werden:
byte tmpData[] = new byte[1024];
int offs = 0;
do { int readLen = bis.read(tmpData, offs, tmpData.length – offs);
if(readLen == -1) break; offs += readLen;
if(offs == tmpData.length) { byte newres[] = new byte[tmpData.length + 1024];
System.arraycopy(tmpData, 0, newres, 0, tmpData.length);
tmpData = newres; } } while(true);
Die Druckdokumente, die in der entsprechenden Methode eingelesen wurden, hatten eine Größe von mehreren Megabyte, die in ein Byte-Array eingelesen und dann an den Browser des Benutzers verschickt wurden. Mehrere parallele Anfragen führten schnell zu einem Engpass im Heap. Verstärkt wurde das Problem durch den ineffizienten Algorithmus beim Einlesen des Dokuments. Angelegt wird ein Byte-Array mit einem KB Größe, das mit dem Inhalt des PDFDokuments gefüllt wird. Ist das Array komplett gefüllt, wird es um ein weiteres KB erweitert und der Inhalt des „alten“ Arrays in das neue kopiert. Dies bedeutet, dass temporär beim Einlesen des Dokuments Arrays in Kilobyteschritten erzeugt und kopiert werden. So entsteht zum einen viel Datenmüll, zum anderen wird der Speicherbedarf fast verdoppelt, da beim Kopieren zwei Arrays fast identischer Größe referenziert werden. Beim Arbeiten mit großen Dokumenten ist es daher sehr wichtig, die Verarbeitungslogik zu optimieren und zu verhindern, dass diese vollständig im Speicher gehalten werden.

Abb. 1: Details eines Threads
Schlechte Garbage-Collector- Konfiguration
In den bisher erwähnten Szenarien war das Problem immer im Anwendungscode zu finden. In vielen Fällen liegen die Ursachen für Probleme aber auch in der falschen – oder fehlenden – Konfiguration des Garbage Collectors. Oft wird darauf vertraut, dass dieser ordnungsgemäß funktioniert und die Standardeinstellungen von Application Servern schon optimal für die eigene Anwendung sind. Die Konfiguration der Garbage Collection und des Heaps sind allerdings stark Abb. 1: Details eines Threads von der Anwendung und der Nutzung der Anwendung abhängig – je nach Szenario müssen die Standardeinstellungen angepasst werden, um eine performante Konfiguration zu erhalten. Eine Anwendung, die viele kurze Anfragen abarbeitet, muss beispielsweise anders konfiguriert werden als eine Batch-Anwendung, die wenige langlaufende Tasks ausführt. Die tatsächliche Konfiguration hängt auch stark von der verwendeten JVM ab. Ein schlecht konfigurierter Garbage Collector ist ohne geeignete Toolunterstützung oft nicht als solcher zu erkennen. In der Praxis zeigt sich, dass sich zu hohe GC-Zeiten, nicht zuletzt wegen fehlendem Monitoring, ausschließlich in hohen Antwortzeiten der Anwendung manifestieren. Werden GC-Zeiten nicht zu Transaktionszeiten korreliert, kann das schnell dazu führen, dass auf Codeebene nicht existenten und sich ständig an anderen Stellen darstellenden Performance- und Skalierbarkeitsproblemen nachgejagt wird. In Abbildung 2 ist eine Transaktion mit zugehörigen GC-Zeiten zu sehen. Auf diese Weise können sie schnell als Verursacher von Performanceproblemen identifiziert werden. In der Praxis konnten durch Änderungen der Garbage-Collector Einstellungen schon massive Performanceverbesserungen von bis zu fünfundzwanzig Prozent erzielt werden.

Abb. 2: Runtime Suspension
Classloader Leaks
Bei Speicherproblemen denkt man vorrangig an Objekte, die im Heap liegen und den Speicher belegen. Neben Objekten werden aber auch Klassen und ihre konstanten Werte im Heap verwaltet. Je nach JVM liegen diese in speziellen Bereichen des Speichers. Bei der Sun JVM ist dies die so genannte Permanent Generation oder PermGen. Klassen liegen oft auch mehrfach im Heap, wenn sie von mehreren Classloadern geladen werden. Der Speicherbedarf für geladene Klassen kann bei moderenen Enterprise- Anwendungen schnell mehrere hundert MB betragen. Wichtig ist, die Größe von Klassen nicht unnötig zu erhöhen. Dies ist speziell dann der Fall, wenn Klassen sehr viele String-Konstanten enthalten, beispielsweise bei GUI-Anwendungen. Hier werden sämtliche Strings in Konstanten gehalten. Während es sich hierbei prinzipiell um einen guten Designansatz handelt, sollte man nicht vergessen, dass auch diese Konstanten Platz im Speicher benötigen. In einem konkreten Fall wurden für eine multilinguale Anwendung alle Konstanten in einer Klasse pro Sprache definiert. Durch einen nicht unmittelbar offensichtlichen Kodierfehler wurden alle Klassen auch in den Speicher geladen. Dies führte zum JVMCrash mit einem OutOfMemoryError im PermGen der Anwendung.
Speziell bei Applikationsservern ergibt sich eine weitere Problemquelle – Classloader Leaks. Darunter versteht man ein Memory Leak, das hervorgerufen wird, wenn ein nicht mehr benötigter Classloader nicht freigegben werden kann, weil irgendwo in der Anwendung noch ein Objekt einer Klasse dieses Classloaders referenziert wird. Somit können Klassen nicht freigegeben werden. Während dieses Problem in den meisten JEE-Anwendungen mittlerweile vom Applikationsserver vermieden wird, trifft man es in OSGI-basierten Anwendungen wieder öfter an.
Fazit
Memory-Probleme sind vielschichtig und führen schnell zu Skalierbarkeitsund Performanceproblemen. Gerade bei JEE-Anwendungen mit vielen parallelen Benutzern muss schon bei der Konzeption berücksichtig werden, dass Speicher nur limitiert vorhanden ist. Der Garbage Collector kümmert sich zwar um das Aufräumen nicht mehr benötigter Objekte, dies entbindet den Entwickler allerdings nicht davon, sich um das Speichermanagement zu kümmern. Neben dem Anwendungsdesign ist das auch zentraler Bestandteil der Anwendungskonfigurationen.
Links & Literatur
[1] Performancestudie: http://www.
codecentric.de/de/publikationen/
studien/
[2] Post Mortem JVM Diagnosis API:
http://jcp.org/en/jsr/detail?id=326
