Who’s keeping them? – Java-Objekte, Caching und OutOfMemory-Fehler

1999 hielt Ed Lycklama, damals CTO bei der KL Group, einen mittlerweile berühmten Vortrag auf der JavaOne darüber, wie Java-Anwendungen der Speicher ausgeht. Lycklama fasste die Unterschiede im Speichermanagement zwischen C++ und Java zusammen und klassifizierte die Fehlerarten, die Java-Programme aus dem Speicher laufen lassen. Für Objekte, die über ihre Nützlichkeit hinaus im Speicher bleiben, prägte er einen neuen Begriff: Loiterers.
Manche Referenzen, die ein Objekt nützlich erscheinen lassen, sind schlicht Bugs. Lycklama nennt mehrere solcher Versäumnisse von Entwicklern: Event Listener nicht wieder abzumelden, alten State in einem Setter nicht vollständig zu löschen, oder Referenzen in äußeren Scopes langlaufender Threads nicht loszulassen.
Aber Garbage Collectors nutzen die Liveness von Objekten, um zu bestimmen, was als Müll eingesammelt wird. Und es gibt Programmierstrategien, die Liveness bewusst manipulieren, als eine Art Wette.
Über Liveness lügen
Dynamic Programming ist so eine Strategie. Dynamic Programming tauscht Platz auf dem Heap gegen Ausführungszeit. Die Wette lautet: Ein berechneter Wert könnte sich schon bald wieder als nützlich erweisen. Denn wird dieser Wert in naher Zukunft erneut angefragt, zahlt es sich aus, ihn nicht sofort zu vergessen, weil man sich den Rechenaufwand spart.
Die bekannteste Umsetzung dieses Effekts ist, das teure Element in einer speziellen Datenstruktur abzulegen, dem Cache. Wie vorteilhaft das ist, zeigt sich daran, wie allgegenwärtig Caches in der gesamten Speicherhierarchie sind. CPUs, GPUs, Hauptspeicher, Festplatten, Netzwerkkarten, sie alle nutzen Caches. Und Caching ist nicht auf Hardware beschränkt. Betriebssysteme cachen Disk Blocks, Browser cachen häufig besuchte Inhalte. Damit sich die User Experience lebendig anfühlt, wird die Liveness des Objekts manipuliert.
In Java gibt es keine API, um dem Garbage Collector eine solche Absicht mitzuteilen. Es gibt andere APIs für Referenzen, Weak References etwa, sogar Soft References für Speicherengpässe, aber nichts, was die Wette einer gecachten Referenz abbildet. Also muss man den Garbage Collector glauben machen, der Wert sei für den Cache unmittelbar nützlich. Weil der Garbage Collector so ausgetrickst wurde, muss der Cache nun selbst entscheiden, wann die Nützlichkeit eines Objekts vorbei ist.
Und zu entscheiden, welche Wetten man eingeht, wenn dabei Begriffe wie "schon bald" im Spiel sind, ist ein wirklich schwieriges Problem, anders als Liveness, die eine deterministische Eigenschaft ist. Also muss der Cache heuristisch vorgehen. Seine Loyalität zu den gehaltenen Objekten muss begrenzt sein. Alte Informationen sind vielleicht nicht bloß uninteressant geworden, sie könnten inzwischen schlicht falsch sein.
Geteilte Responsiveness
Wenn Systeme auf Button-Klicks in unter 200 ms reagieren, auf Seitennavigation in unter 1 s, auf Suchergebnisse in 1–2 s und auf Report-Generierung in 5–8 s mit Fortschrittsbalken, empfinden Nutzer die Performance als angemessen und professionell. (UXUIPrinciples)
Nutzererlebnisse fühlen sich "angemessen und professionell" an, wenn alle Interaktionen in sehr engen Zeitfenstern ablaufen. Solche Erwartungen können direkt in Key Performance Indicators einfließen, an denen Entwickler gemessen werden. Einfache KPIs für Responsiveness führen zu flächendeckendem Caching auf allen Ebenen des Application Stacks. Das ist eine klassische Tragik der Allmende. Jedes Teammitglied hat den Anreiz, seine Responsiveness-Ziele zu erreichen. Der Heap-Speicher ist eine gemeinsame Ressource. Nimm an, jeder nimmt sich nur so viel, wie er braucht, um das gemeinsame Responsiveness-Ziel in allen gemeldeten Situationen zu erreichen. Selbst dann bleibt unklar, auf wen man zeigen soll, wenn die ersten java.lang.OutOfMemory-Fehler eintrudeln.
Bevor man aber zum Memory Profiler greift, sollte man Folgendes bedenken: Manche Out-of-Memory-Fehler sind ein Versagen von Anreizen, nicht von Software Engineering. Der KPI verlangte Responsiveness über alles, und genau das hat das Team geliefert. Die Lösungen sind daher Eingriffe auf Policy-Ebene, nicht zwingend im Code.
- Führe Sichtbarkeit ein, eine API, mit der man abfragen kann, wie groß alle Caches sind und wie groß sie werden dürfen. Achtung: Das ist ein Registrierungsmuster, es gelten also alle Bedenken von Ed Lycklama zum Loitering.
- Führe KPIs für den Cache-Verbrauch ein. Teams bekommen feste Budgets zugeteilt. Damit wird die Entscheidung, welche Objekte gecacht werden, zur Frage des Designs statt der Bequemlichkeit. Eine schnellere Implementierung zu finden, bringt jetzt Belohnung: Caching an einer Stelle zu entfernen, macht Budget frei, um an anderer Stelle zu cachen.
- Wenn die Änderung sofort kommen muss, mach manche Cache-Einstellungen global und nicht verhandelbar. Das ist gleichmäßig unfair und verschafft zugleich die Uptime, die man braucht, um sich auf die Verbesserung einzelner Teile des Systems zu konzentrieren.
Die Loiterers finden
Irgendwann kommt der Tag, an dem das Operations-Team meldet, dass von einer Produktionsinstanz nur noch ein Java Heap Dump übrig ist. Jetzt ist die Zeit gekommen, zum Memory Profiler zu greifen. Nehmen wir an, es ist VisualVM, wobei Eclipse MAT in der Community genauso beliebt ist.
Der Heap Dump liefert ein paar grundlegende Statistiken zum Speicher und eine Möglichkeit, durch den Objektgraphen zu surfen. Vielleicht ist es Glück, vielleicht ein Verdacht … irgendwann sticht ein Objekt heraus. Das sollte nicht mehr hier sein!
Zu fragen, was dieses Objekt festhält, ist sinnlos. Der Cache hat dem Garbage Collector per Design gesagt, er soll verschwinden und sich um seinen eigenen Kram kümmern. Die Frage lautet stattdessen: Welcher Cache verweist noch fälschlich auf diesen Loiterer? Oder auf sein umschließendes Objekt. Oder auf das umschließende Objekt jenes umschließenden Objekts?
Spätestens jetzt wirkt die UI für diese Aufgabe wenig geeignet. Sich durch alle Referrer eines Objekts zu klicken und durch die Referrer all dieser Objekte, klingt … nach der Arbeit, die der Garbage Collector macht. Was nicht überrascht, denn genau diese Rolle hat der fehlerhafte Cache vorgetäuscht.
Zurück zu den ersten Prinzipien. Was genau macht ein Garbage Collector? Ein Garbage Collector läuft durch einen Objektgraphen. Er startet bei bekannten Haltern, den sogenannten Roots wie Thread Stacks und globalen Variablen, und bewahrt die, die er erreicht. Alles andere, das Unerreichte, ist per Definition Müll.
Auf Implementierungsebene baut der Garbage Collector eine Queue der noch zu besuchenden Objekte auf. Die Queue wird zuerst mit den Roots gefüllt. Objekte werden herausgenommen, bis keine mehr übrig sind. Jedes Objekt wird analysiert, also geprüft, auf welche Objekte es zeigt. Da Objektgraphen legal Zyklen bilden können, muss der Collector sie deduplizieren, um zu terminieren. Alle noch nicht besuchten Objekte werden in die Queue gelegt und der Scan geht weiter.
Dieser grundlegende Algorithmus, Mark-and-Sweep, ist genau das richtige Werkzeug für dieses Problem, nur nach außen laufen gelassen. Füll die Queue mit dem loiternden Objekt. Für jedes entnommene Objekt überspringe die bereits analysierten. Bei den neu gesehenen Objekten prüfe, ob es Cache-Einträge sind. Wenn ja, Treffer, das ist der Schuldige. Wenn nein, leg ihre Referrer in die Queue, also die Objekte, die auf das entnommene Objekt zeigen, um sie als Nächstes zu prüfen.
Object Query Language (OQL)
Nach so einem Suchwerkzeug muss man in den Menüs nicht suchen. Es ist nicht da, und es ist auch gar nicht nötig. Denn es gibt OQL. Eines der mächtigsten Werkzeuge des Memory Profilers. Überrascht? Das liegt daran, dass OQL unter mieser Vermarktung leidet. Jedes Tutorial zum Heap-Debugging, das OQL erwähnt, bringt dieselbe Handvoll Beispiele, die schon seit Java 1.4 oder so in der Dokumentation stehen. Offenbar hat nie jemand nützlichere Beispiele gefunden?
Tatsächlich ist OQL, so wie es präsentiert wird, nicht besonders nützlich. Enorm nützlich ist dagegen die Möglichkeit, eigene JavaScript-Funktionen zu schreiben, die programmatisch auf den Heap Dump zugreifen, und sie in OQL auszuführen. Diese Möglichkeit reicht aus, um zwei Funktionen zu schreiben, die einen rückwärts laufenden Garbage Collector umsetzen. Sie finden alle Referrer eines Seed-Objekts, die zu einer bestimmten Klasse gehören, im Mark-and-Sweep-Verfahren.
objectnum (clazz, num) findet das Seed-Objekt der Klasse clazz, das in der visuellen UI mit der id num angezeigt wird.
find_enclosing(enclosing_class, seed, max_rounds) findet die Instanz der umschließenden Klasse, die der Vorfahren-Referrer des Seeds ist. Es kann mehrere davon zurückgeben oder nichts, wenn max_rounds vorher erreicht wird.
Der JavaScript-Code in diesen Screenshots liegt auch im Git-Repo, das am Ende des Textes verlinkt ist.
Werde, was du fürchtest
Als gemeinsame Ressource muss Heap-Speicher überwacht werden: durch Policy, durch Automatisierung oder durch übernommene Verantwortung. In Produktionssystemen halten diese drei Komponenten, das Zuteilen von Speicherbudgets, das Laufen der Garbage Collection und der Umgang mit Cache-Liveness, OOM zusammen aus der Uptime-Statistik heraus.
Wenn das Liveness-Management versagt, übernimm jetzt den Vertrag, der Garbage Collector zu sein. Finde einen Beispiel-Loiterer. Dann greif zum schärfsten Werkzeug im Kasten: Nimm OQL, verfolge die Liveness rückwärts und stell den Loiterer. Darin liegt der schnellste Weg zurück zur Stabilität.
Nachtrag: Nur noch eine Lüge
Es ist leicht zu behaupten, das Operations-Team habe in der Produktion einen Java Heap Dump gemeldet. Die Wahrheit ist: Das ist sehr unwahrscheinlich. Operations-Teams mögen Heap Dumps kein bisschen. Heap Dumps sind riesig. Sie festzuhalten braucht schnelle Disks, die Gigabytes zügig wegschreiben können. Aber genau diese schnelle, große Disk verkompliziert die Dockerisierung und andere VM-Deployment-Strategien.
Dabei zählt jede Sekunde, das Operations-Team will schnell eine neue Instanz hochziehen, die die alte ersetzt, der der Heap-Speicher ausgegangen ist.
Am ärgerlichsten aber ist vielleicht: Der Dump ist ein massives Sicherheitsrisiko. All die sorgfältig injizierten Secrets, verwaltet von einem Vault oder einem verschlüsselten Store? Im Heap Dump im Klartext lesbar, ihre UTF-8-Bytes frei zum Mitnehmen. Jene Zertifikate und Private Keys, die bewusst nicht auf der Disk gespeichert werden? Nach dem Dump sind sie es … und nichts schützt sie mehr.


