La storia risale all'introduzione di java.lang.ref.Cleaner in Java 9 come sostituto del metodo obsoleto Object.finalize(), che soffriva di tempi di esecuzione imprevedibili, penalità di prestazioni e vulnerabilità di sicurezza, tra cui attacchi di resurrezione. Il problema centrale deriva dal fatto che finalize() consentiva al metodo sovrascritto di memorizzare un riferimento a this all'interno del grafo degli oggetti vivi, "resuscitando" un oggetto che il garbage collector aveva già considerato irraggiungibile, violando l'invariante di singola costruzione e singola distruzione e potenzialmente lasciando le risorse native in uno stato incoerente.
La soluzione sfrutta la semantica di PhantomReference all'interno dell'implementazione del Cleaner: l'azione di pulizia riceve solo l'oggetto Runnable o l'azione di pulizia, non il referente stesso, e il referente è garantito di trovarsi nello stato di raggiungibilità fantasma, il che significa che è già stato finalizzato e non può essere resuscitato, assicurando che la logica di pulizia operi su un oggetto irrevocabilmente irraggiungibile.
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); }
Il nostro team gestiva una pipeline di elaborazione delle immagini ad alta capacità in cui gli oggetti ImageProcessor avvolgevano i buffer nativi di OpenCV allocati tramite JNI. Inizialmente, ci siamo affidati a finalize() per invocare cvReleaseImage(), ma abbiamo sporadicamente incontrato esaurimento di memoria nativa nonostante la stabilità dell'heap Java, accompagnato da difetti di segmentazione intermittenti che suggerivano errori di uso dopo la liberazione nella memoria nativa.
Il primo approccio considerato è stato mantenere finalize() ma aggiungere una guardia di resurrezione in cui ci sincronizzavamo su una mappa statica per tenere traccia degli oggetti "morti". Ciò soffriva di latenza imprevedibile: la finalizzazione potrebbe avvenire minuti dopo l'inizio della pressione dell'heap—e la logica di guardia stessa creava perdite di memoria mantenendo riferimenti forti agli oggetti "morti" nella mappa di tracciamento, ironicamente impedendo la loro raccolta del tutto mentre permetteva comunque condizioni di corsa per la resurrezione.
Il secondo approccio prevedeva di utilizzare java.lang.ref.Cleaner in modo ingenuo catturando il puntatore nativo in una lambda all'interno del costruttore dell'istanza: cleaner.register(this, () -> free(pointer)). Sebbene questo evitasse ritardi di finalizzazione, rischiava la raccolta prematura se this fosse sfuggito durante la costruzione, e criticamente, se la lambda avesse chiuso accidentalmente sull'istanza ImageProcessor piuttosto che solo sul valore pointer, avrebbe creato un ciclo di riferimento forte impedendo la raccolta dei rifiuti del referente, sebbene prevenendo ancora la resurrezione di per sé.
Abbiamo scelto il terzo approccio: implementare una classe statica annidata CleaningAction che conteneva solo il puntatore nativo (come long) e implementava Runnable, completamente scorporata dall'istanza esterna ImageProcessor. Abbiamo registrato l'azione di pulizia immediatamente dopo un'allocazione nativa riuscita e abbiamo esplicitamente invocato Reference.reachabilityFence(this) alla fine del costruttore per garantire che l'oggetto rimanesse raggiungibile fino al completamento della registrazione. Questo ha eliminato i rischi di resurrezione e le perdite native, riducendo gli incidenti di pressione della memoria da zero a nessuno in sei mesi.
Perché il Cleaner utilizza PhantomReference anziché WeakReference o SoftReference e come ciò impedisce all'azione di pulizia di accedere allo stato del referente?
PhantomReference è utilizzato perché consente al garbage collector di identificare gli oggetti recuperabili senza consentire al codice del programma di accedere ai campi o ai metodi del referente dopo che è entrato nello stato di raggiungibilità fantasma. A differenza di WeakReference, che consente di recuperare il referente tramite get() fino alla raccolta, PhantomReference restituisce sempre null da get(), assicurando che l'azione di pulizia operi sull'assunzione che l'oggetto sia logicamente distrutto e impedendo qualsiasi tentativo di resurrezione o ispezione dello stato che potrebbe violare le invarianti post-morte.
Cos'è l'attacco di "resurrezione" nel contesto di Object.finalize() e perché viola le garanzie di sicurezza del sistema di tipi?
La resurrezione si verifica quando il metodo finalize() memorizza il riferimento this in un campo statico o nel grafo degli oggetti vivi, rendendo nuovamente raggiungibile l'oggetto dopo che il garbage collector lo ha contrassegnato per la reclamazione. Ciò viola l'invariante secondo cui il costruttore di un oggetto viene eseguito esattamente una volta e il suo finalizzatore viene eseguito al massimo una volta, consentendo a codice dannoso o con bug di osservare un oggetto in uno stato parzialmente distrutto in cui le risorse native vengono rilasciate ma i campi Java sono ancora accessibili, portando a vulnerabilità di uso dopo la liberazione e a comportamenti incoerenti dell'oggetto.
Come interagisce Reference.reachabilityFence con le ottimizzazioni di compilazione just-in-time della JVM e quando è strettamente necessario usarlo con le registrazioni di Cleaner?
Reference.reachabilityFence funge da barriera per il compilatore che impedisce all'ottimizzatore della JVM di riordinare o eliminare riferimenti a oggetti prima che una sezione critica sia completata, prevenendo specificamente la "pubblicazione anticipata" in cui un oggetto diventa irraggiungibile mentre il suo costruttore sta ancora eseguendo. È strettamente necessario quando si registrano oggetti con Cleaner durante la costruzione perché senza di esso, la JVM potrebbe determinare che this non è più necessario dopo l'allocazione della risorsa nativa ma prima della chiamata di registrazione, consentendo al cleaner di avviarsi e rilasciare la risorsa mentre il costruttore continua a inizializzare l'oggetto, portando a una corruzione delle risorse.