JPA bietet viel Komfort beim Laden und Bearbeiten von Daten aus einer relationalen Datenbank. Nur darf man bei allem Komfort nicht vergessen, dass bei jeder Operation hinter den Kulissen viel geschieht. Am Beispiel von EclipseLink in einer Wicket-Anwendung zeigt sich, dass man beim Thema Speicherverbrauch auf größere Überraschungen stoßen kann.
Der Ausgangspunkt dieser Geschichte sind OutOfMemoryException
s, die eine Web-Anwendung nach einigen Tagen Betriebsdauer warf. Ein Blick per JConsole in das Innere der Anwendung zeigte schnell, dass die durchschnittliche Session eines Besuchers knapp ein Megabyte groß war: bei vielen Besuchern ein extrem hohen Wert. Wie konnte das geschehen?
Was war passiert?
Das Web-Framework Apache Wicket verbraucht viel Speicher, da es für jede aufgerufene Seite den vollständigen Komponentenbaum im Arbeitsspeicher hält. Den Arbeitsspeicherverbrauch kann man reduzieren, wenn man eine Reihe von Best-Practices befolgt. Unsere Anwendung hält sich jedoch an einigen Stellen nicht konsequent an diese Richtlinien: Beispielsweise zeigen manche UI-Komponenten direkt auf Domänenobjekte, die per JPA aus der Datenbank geladen werden. Somit werden diese Domänenobjekte ebenfalls in der Session gespeichert.
Das System ist dabei in mehrere Schichten aufgeteilt: Eine Web-Schicht auf Basis von Wicket ruft eine Service-Schicht auf (EJB 3.1 Session-Beans mit @Local
-Semantik). Dort holen Datenzugriffsobjekte per JPA 2.0 die Daten aus der Datenbank. Wir verfolgen den Ansatz, Domänenobjekte zu annotieren und im gesamten System zu verwenden. Die von EclipseLink erstellten Datenobjekte wandern also bis in die Web-Schicht.
Um zu verstehen, warum unsere Sessions so groß wurden, haben wir die Anwendung mit einem Profiler untersucht. Das Produkt JProfiler ist dazu eine sehr gute Wahl. Mit etwas Erfahrung lassen sich Speicherprobleme schnell aufdecken. Wie man dabei am besten vorgeht, beschreibt beispielsweise mein Paper „Using a Profiler Efficiently„.
In unserem Fall wurde schnell klar, dass weder die Wicket-Komponenten noch unsere eigenen Klassen viel Arbeitsspeicher verbrauchten. Die Verursacher waren vielmehr UnitOfWork-Objekte von EclipseLink. UnitOfWork-Objekte dienen in EclipseLink dazu, während einer Transaktion eine konsistente Sicht auf alle an der Transaktion beteiligten Datenobjekte zu gewährleisten. Insbesondere werden von allen geladenen Datenobjekten Kopien gehalten, um anhand dieser Originaldaten Datenveränderungen automatisch zu erkennen. Zusätzlich benötigt EclipseLink für die Umsetzung von Lazy Loading eine Vielzahl sogenannter ValueHolder-Objekte.
Bei jedem transaktionalen Aufruf zur Datenbank legt EclipseLink ein UnitOfWork-Objekt an. Da die UnitOfWork-Objekte aus den geladenen Domänenobjekten heraus referenziert werden, vervielfacht sich der Speicherverbrauch eines geladenen Objektgraphen gegenüber den eigentlichen Nutzdaten. Das folgende Bild zeigt als Beispiel die Gesamtgröße einer Reihe von UnitOfWork-Objekten, die in einer einzigen Web-Session referenziert werden: zusammen belegen sie fast 1 MB Speicher!
Was lässt sich tun?
Zunächst einmal macht es Sinn, den Umfang des Page-Caches von Wicket zu prüfen und zu justieren. Wicket speichert standardmäßig die vollständigen Komponentenbäume der letzten besuchten Seiten, um vergangene Zustände wiederherstellen zu können. Über den Sinn und Unsinn dieses Verfahrens lässt sich ein eigener Artikel füllen. Als nächstes kann man durch Detachable Models verhindern, dass Datenobjekte im Komponentenbaum gespeichert werden.
Solche Maßnahmen führen meistens jedoch zu größeren Umbauten, teilweise auch der Anwendungsarchitektur. Daher lohnt es sich, die Frage zu stellen, warum die Web-Schicht überhaupt Objekte aus der Tiefe des O/R-Mappers erhält.
Die Ursache liegt in der Verwendung von lokalen Session-Beans, also solchen, die mit der Annotation @Local
gekennzeichnet sind. Auf den ersten Blick macht es viel Sinn, standardmäßig lokale EJBs zu verwenden. Denn dadurch kann die Web-Schicht direkt auf die Services zugreifen, ohne Performance-Einbußen z.B. durch Objekt-Serialisierung hinnehmen zu müssen. Der Nachteil besteht darin, dass ohne Serialisierung Objektgraphen ungefiltert durch das System transportiert werden.
Um einen Objektgraphen von den EclipseLink-internen Objekten zu befreien, muss man die aus der Datenbank geladenen Objekte jedoch einmal serialisieren und deserialisieren (EclipseLink unterstützt leider kein Verfahren, einen Objektgraphen von nicht mehr benötigten Objekten zu bereinigen). Nach einer solchen Behandlung bleiben nur wenige EclipseLink-Objekte übrig, entsprechend reduziert sich ein Objektgraph (fast) auf seine Nutzdaten.
Der einfachste Weg, Objekte zu serialisieren und zu deserialisieren, besteht darin, Service-Beans aus der Web-Schicht per @Remote
-Annotation aufzurufen. Da man eine einzelne Session-Bean als @Local und als @Remote kennzeichnen kann, sind keine speziellen Fassaden notwendig.
Die Umstellung auf Remote-Aufrufe führte in unserem Fall zum gewünschten Ergebnis: die Web-Sessions wurden erheblich schlanker. Der Preis dafür waren jedoch viele Datentransformationen, die ansonsten nicht nötig gewesen wären. Und ein weiterer Teufel liegt im Detail: Die Semantik der Schnittstellen hat sich geändert: von Call by Reference zu Call by Value! Wo immer die Web-Schicht nach einem Service-Aufruf noch mit den alten Domänenobjekten weiterarbeitet, lauern schwer zu findende Bugs.
Was sind die Lehren?
Es gibt gleich mehrere davon:
- Der schnellste Weg, die Ursachen von Speicherproblemen herauszufinden, führt über einen Profiler. Die kommerziellen Produkte sind ihren Preis wert!
- Schlanke Systemarchitekturen, deren Schichten direkt miteinander kommunizieren und Domänenobjekte austauschen, bieten zwar eine hohe Performance, jedoch können sich dadurch die Internas der einen Schicht unangenehm auf die anderen Schichten auswirken.
- Die naheliegenden ersten Ideen (hier z.B.: alle Wicket-Komponenten auf Detachable Models umzustellen), sollte man immer in Frage stellen. Andere Wege führen eventuell schneller zum Ziel.