Die Geschichte reicht zurück bis zur Einführung von java.lang.ref.Cleaner in Java 9 als Ersatz für die veraltete Methode Object.finalize(), die unter unvorhersehbarer Ausführungszeit, Leistungsnachteilen und Sicherheitsanfälligkeiten, einschließlich Wiederbelebungsangriffen, litt. Das Kernproblem entsteht, weil finalize() es der überschriebenen Methode erlaubte, eine Referenz auf this zurück in den lebenden Objektgraph zu speichern, wodurch ein Objekt, das der Garbage Collector bereits als unerreichbar erachtet hatte, „wiederbelebt“ wurde, was das Invariant der einmaligen Erstellung und einmaligen Zerstörung verletzte und potenziell native Ressourcen in einem inkonsistenten Zustand zurückließ.
Die Lösung nutzt die Semantik von PhantomReference innerhalb der Implementierung des Cleaners: Die Reinigungsaktion erhält nur das Runnable oder das Reinigungsaktionsobjekt, nicht das Referenzobjekt selbst, und das Referenzobjekt ist garantiert im Zustand der phantomatischen Erreichbarkeit—was bedeutet, dass es bereits finalisiert wurde und nicht wiederbelebt werden kann—und stellt sicher, dass die Bereinigungslogik auf einem unwiderruflich unerreichbaren Objekt arbeitet.
public class NativeResource { private static final Cleaner cleaner = Cleaner.create(); private final long nativeHandle; public NativeResource() { nativeHandle = allocateNative(); cleaner.register(this, new CleanupAction(nativeHandle)); Reference.reachabilityFence(this); } private static class CleanupAction implements Runnable { private final long handle; CleanupAction(long handle) { this.handle = handle; } @Override public void run() { releaseNative(handle); } } private native long allocateNative(); private static native void releaseNative(long handle); }
Unser Team verwaltete eine Hochdurchsatz-Bildverarbeitungs-Pipeline, bei der ImageProcessor-Objekte native OpenCV-Puffer umhüllten, die über JNI zugewiesen wurden. Anfänglich verließen wir uns auf finalize() um cvReleaseImage() aufzurufen, aber wir stießen sporadisch auf native Speicherauslastung, trotz stabilem Java-Heap, begleitet von intermittierenden Speicherzugriffsfehlern, die auf Fehler nach einer Freigabe in nativen Speicher hinwiesen.
Der erste in Betracht gezogene Ansatz war, finalize() beizubehalten, aber einen Wiederbelebungsschutz hinzuzufügen, bei dem wir auf einer statischen Map synchronisierten, um "tote" Objekte zu verfolgen. Dies leidet unter unvorhersehbarer Latenz—die Finalisierung könnte Minuten nach Beginn des Heap-Drucks auftreten—und die Schutzlogik selbst führte zu Speicherlecks, indem sie starke Referenzen auf "tote" Objekte in der Verfolgungs-Map hielt, was ironischerweise deren Sammlung vollständig verhinderte, während sie gleichzeitig Wiederbelebungsrennen erlaubte.
Der zweite Ansatz bestand darin, java.lang.ref.Cleaner naiv zu verwenden, indem wir den nativen Zeiger in einem Lambda innerhalb des Instanzkonstruktors erfassten: cleaner.register(this, () -> free(pointer)). Obwohl dies die Verzögerungen der Finalisierung vermied, riskierte es eine vorzeitige Sammlung, wenn this während der Konstruktion entkam, und kritisch, wenn das Lambda versehentlich über die ImageProcessor-Instanz und nicht nur den pointer-Wert abschloss, würde es einen starken Referenzzyklus erzeugen, der die Garbage Collection des Referenten verhinderte, obwohl es immer noch die Wiederbelebung an sich verhinderte.
Wir wählten den dritten Ansatz: Implementierung einer statischen inneren Klasse CleaningAction, die nur den nativen Zeiger (als long) hielt und Runnable implementierte, völlig vom äußeren ImageProcessor-Instanz entkoppelt. Wir registrierten die Reinigungsaktion sofort nach erfolgreicher nativer Zuweisung und riefen explizit Reference.reachabilityFence(this) am Ende des Konstruktors auf, um sicherzustellen, dass das Objekt bis zur Fertigstellung der Registrierung erreichbar blieb. Dies beseitigte Wiederbelebungsrisiken und native Lecks, wobei die Inzidenzen von Speicherdruck von täglichen Vorkommen auf null über sechs Monate reduziert wurden.
Warum verwendet der Cleaner PhantomReference anstelle von WeakReference oder SoftReference, und wie verhindert dies, dass die Reinigungsaktion auf den Zustand des Referenten zugreift?
PhantomReference wird verwendet, weil sie dem Garbage Collector ermöglicht, wiederverwendbare Objekte zu identifizieren, ohne dass der Programmcode auf die Felder oder Methoden des Referenten zugreifen kann, nachdem dieser in den Zustand der phantomatischen Erreichbarkeit eingetreten ist. Im Gegensatz zu WeakReference, das die Wiedererlangung des Referenten über get() bis zur Sammlung zulässt, gibt PhantomReference immer null von get() zurück, sodass die Reinigungsaktion davon ausgeht, dass das Objekt logisch zerstört ist, und verhindert jegliche Wiederbelebungsversuche oder Zustandinspektionen, die die postmortalen Invarianten verletzen könnten.
Was ist der "Wiederbelebungs"-Angriff im Kontext von Object.finalize(), und warum verletzt er die Sicherheitsgarantien des Typsystems?
Wiederbelebung tritt auf, wenn die Methode finalize() die Referenz this in einem statischen Feld oder im lebenden Objektgraph speichert, wodurch das Objekt wieder erreichbar wird, nachdem der Garbage Collector es zur Rückführung markiert hatte. Dies verletzt das Invariant, dass der Konstruktor eines Objekts genau einmal und sein Finalizer höchstens einmal ausgeführt wird, wodurch böswilliger oder fehlerhaft kodierter Code ein Objekt in einem teilweise zerstörten Zustand beobachten kann, in dem native Ressourcen freigegeben werden, aber Java-Felder weiterhin zugänglich sind, was zu Verwendung nach Freigabe-Sicherheitsanfälligkeiten und inkonsistentem Objektverhalten führt.
Wie interagiert Reference.reachabilityFence mit den Optimierungen der Just-In-Time-Kompilierung der JVM, und wann ist es unbedingt notwendig, es bei Cleaner-Registrierungen zu verwenden?
Reference.reachabilityFence fungiert als Compiler-Barriere, die verhindert, dass der Optimierer der JVM Objektreferenzen umordnet oder entfernt, bevor ein kritischer Abschnitt abgeschlossen ist, insbesondere unverfrühte Veröffentlichungen, bei denen ein Objekt unerreichbar wird, während sein Konstruktor noch ausgeführt wird. Es ist unbedingt erforderlich, wenn Objekte während der Konstruktion mit Cleaner registriert werden, denn ohne es könnte die JVM feststellen, dass this nach der Zuweisung der nativen Ressource nicht mehr benötigt wird, jedoch vor dem Registrierungsaufruf, wodurch der Cleaner ausgeführt wird und die Ressource freigibt, während der Konstruktor weiterhin das Objekt initialisiert, was zu Ressourcenkorruption führt.