Historie: Vor Java 9 basierte das Management nativer Ressourcen in Klassen wie Inflater und Deflater auf Object.finalize(). Dieses Verfahren wurde aufgrund seiner Unvorhersagbarkeit, erheblichem Leistungsaufwand und dem Risiko der Objekt-Wiederbelebung, das die Garbage Collection verzögerte, als veraltet erklärt. Java 9 führte die Cleaner-API als moderne Alternative ein, die PhantomReference und ReferenceQueue nutzt, um die Bereinigungslogik vom Lebenszyklus des Objekts zu entkoppeln, während sichergestellt wird, dass das Objekt während der Bereinigung unerreichbar bleibt.
Problem: In der Implementierung von Inflater muss die zugrunde liegende native z_stream-Struktur explizit über die Methode end() freigegeben werden, um Speicherlecks bei nativem Speicher zu verhindern. Wenn ein Anwendungs-Thread end() explizit aufruft, während der Cleaner-Thread gleichzeitig versucht, die registrierte Bereinigungsaktion auszuführen, entsteht eine Wettlaufbedingung. Ohne angemessene Synchronisation könnten beide Threads versuchen, denselben nativen Zeiger freizugeben, was zu einem Double-Free-Fehler führen kann, oder ein Thread könnte auf die Ressource zugreifen, nachdem der andere sie freigegeben hat (Use-After-Free), was zu JVM-Abstürzen (SIGSEGV) in der nativen zlib-Bibliothek führen kann.
Lösung: Die Lösung verwendet ein AtomicBoolean-Statusflag, um sicherzustellen, dass die native Bereinigung genau einmal ausgeführt wird, unabhängig davon, welcher Thread sie initiert. Sowohl die explizite Methode end() als auch die Bereinigungsaktion des Cleaners führen eine Compare-and-Set (CAS)-Operation auf diesem Flag durch. Nur der Thread, der das Flag erfolgreich von false auf true umschaltet, fährt fort, die native Freigabeverfahren aufzurufen. Dieser lockfreie Ansatz gewährleistet die Threadsicherheit, während die hohe Leistung für Komprimierungsoperationen erhalten bleibt.
Ein Hochdurchsatz-Logkompressionsdienst verarbeitet täglich Millionen von Logeinträgen und nutzt dabei gepoolte Deflater-Instanzen, um den Zuweisungsaufwand zu minimieren. Um die Ressourcennutzung zu optimieren, implementierten die Entwickler ein Rückgabe-zur-Pool-Muster, das end() explizit auf Deflater-Instanzen aufruft, bevor sie wieder in den Pool zurückgegeben werden, und sich gleichzeitig auf die Garbage Collection verlässt, um Instanzen zurückzugewinnen, die aufgrund unbehandelter Ausnahmen in der Verarbeitungspipeline verloren gingen.
Das System erlebte sporadische, aber kritische JVM-Abstürze (SIGSEGV) unter hoher Last, wobei Kern-Dumps auf Speicherbeschädigung innerhalb der nativen zlib-Bibliothek hinwiesen. Untersuchungen ergaben, dass, als eine Deflater-Instanz in den Pool zurückgegeben wurde, der Anwendungs-Thread end() aufrief, aber wenn die Instanz gleichzeitig garbage collection-fähig wurde, der Cleaner-Thread auch versuchte, denselben nativen z_stream-Handle zu bereinigen. Dieser unsynchronisierte Zugriff auf die native Ressource führte dazu, dass der Prozess unvorhersehbar abstürzte.
Die erste in Betracht gezogene Lösung war, jeden Zugriff auf die Deflater-Instanz mithilfe von synchronized-Blöcken oder -Methoden zu synchronisieren. Dieser Ansatz hätte die Wettlaufbedingung effektiv verhindert, indem er gegenseitige Ausschluss sicherstellte. Allerdings führte dies zu erheblichen Verlangsamungen im Hochfrequenz-Komprimierungs-Pipeline und brachte das Risiko von Deadlocks mit sich, wenn das Objekt gleichzeitig von mehreren Threads fehlerhaft zugegriffen wurde, was den Thread-Sicherheitsvertrag der Klasse verletzte.
Der zweite Ansatz umfasste die Verwendung eines AtomicBoolean, um den Bereinigungsstatus zu verfolgen. Sowohl die explizite Methode end() als auch die Cleaner-Aktion würden atomar dieses Flag überprüfen und setzen, bevor sie auf die native Ressource zugreifen. Dies bot lockfreie Sicherheit mit minimalen Leistungseinbußen, obwohl es eine sorgfältige Implementierung erforderte, um sicherzustellen, dass das native Handle nicht nach dem atomaren Check, aber vor dem nativen Aufruf zugegriffen wurde.
Die dritte Option war, die expliziten end()-Aufrufe vollständig zu entfernen und sich ausschließlich auf den Cleaner für das Ressourcenmanagement zu verlassen. Dies hätte die Wettlaufbedingung vollständig beseitigt, hätte jedoch Unvorhersehbarkeit beim Timing der Freigabe nativer Speicherressourcen eingeführt, was während der Garbage Collection-Pausen, wenn die GC-Zyklen hinter der Zuweisungsrate der nativen Strukturen zurückblieben, erheblichen Speicher Druck verursachen könnte.
Das Team wählte den AtomicBoolean-Ansatz (Lösung 2), da er eine deterministische sofortige Bereinigung ermöglichte, wenn möglich (expliziter Aufruf), während er Sicherheit gewährte, wenn der Cleaner später lief. Sie modifizierten die Wrapper-Klasse, um AutoCloseable zu implementieren, und stellten sicher, dass die atomare Statusprüfung die native Freigabe schützte. Dies beendete die Abstürze vollständig und hielt den erforderlichen Durchsatz aufrecht, wodurch native speicherbezogene Abstürze in der Produktion behoben wurden.
Wie verhindert die Cleaner-API das Objekt-Wiederbelebungsproblem, das in Object.finalize() angelegt ist?
In Object.finalize() ist das Objekt noch erreichbar, wenn die finalize()-Methode ausgeführt wird, weil die this-Referenz weiterhin gültig ist, was es dem Objekt ermöglicht, sich selbst durch das Speichern einer Referenz auf sich selbst in einem statischen Feld wiederzubeleben. Diese Wiederbelebung verzögert die Garbage Collection unbegrenzt, wenn das Objekt sich wiederholt wiederbelebt. Die Cleaner-API verhindert dies durch die Verwendung von PhantomReference. Wenn die Bereinigungsaktion des Cleaners ausgeführt wird, befindet sich der Referent (das zu bereinigende Objekt) bereits im phantom erreichbaren Zustand, was bedeutet, dass es nicht wiederbelebt werden kann, da keine starken, schwachen oder weichen Referenzen auf es existieren. Die Bereinigungsaktion ist ein separates Runnable, keine Methode des Objekts selbst, was sicherstellt, dass das Objekt während des gesamten Bereinigungsprozesses unerreichbar bleibt.
Warum ist Thread.interrupt() ineffektiv, um einen Cleaner-Thread während des JVM-Shutdowns zu stoppen, und was sind die Folgen?
Der Cleaner-Thread ist ein Daemon-Thread, der kontinuierlich auf ReferenceQueue.remove() blockiert, während er darauf wartet, dass phantom Referenzen verfügbar werden. Während ReferenceQueue.remove() auf Interrupts reagiert, indem es InterruptedException auslöst, fängt die Implementierung des Cleaners diese Ausnahme ab und setzt ihre Endlosschleife fort, wodurch sie effektiv Interrupts ignoriert. Dieses Design stellt sicher, dass die kritische Ressourcensäuberung selbst während der Shutdown-Sequenzen abgeschlossen wird. Wenn jedoch eine registrierte Bereinigungsaktion unendlich lange hängt (z.B. auf einen Netzwerk-Timeout wartet oder in einer Endlosschleife feststeckt), wird der Cleaner-Thread niemals beendet. Dies kann verhindern, dass die JVM ordnungsgemäß heruntergefahren wird, wenn andere Nicht-Daemon-Threads auf Ressourcen warten, die der Cleaner freigeben soll.
Welcher katastrophale Speicherleck tritt auf, wenn eine Bereinigungsaktion des Cleaners eine starke Referenz auf das zu bereinigende Objekt erfasst?
Wenn das Runnable, das an Cleaner.register() übergeben wird, eine starke Referenz auf das Objekt erfasst (z.B. durch this::cleanupMethod oder ein Lambda, das this referenziert), entsteht ein fataler Referenzzyklus. Der Cleaner hält eine interne Menge von Cleanable-Objekten, von denen jedes eine Referenz auf das Bereinigungs-Runnable hält. Wenn dieses Runnable das ursprüngliche Objekt referenziert, bleibt das Objekt stark erreichbar vom Cleaner-Thread selbst. Folglich wird das Objekt niemals phantom erreichbar, die PhantomReference gelangt niemals in die Warteschlange und die Bereinigungsaktion wird niemals ausgeführt. In der Zwischenzeit kann das Objekt nicht garbage collected werden, was zu einem schwerwiegenden Speicherleck führt, das unbegrenzt mit jedem Objekt, das beim Cleaner registriert wird, wächst und schließlich zu einem OutOfMemoryError führt.