De geschiedenis gaat terug naar de introductie van java.lang.ref.Cleaner in Java 9 als vervanging voor de verouderde Object.finalize() methode, die leed onder onvoorspelbare uitvoeringstiming, prestatiepenalties en beveiligingskwetsbaarheden, waaronder herrijzen-aanvallen. Het kernprobleem ontstaat omdat finalize() de overschreven methode toestond om een referentie naar this terug te slaan in de actieve objectgrafiek, waardoor een object dat de garbage collector al onbereikbaar had verklaard, "herrezen" werd, wat in strijd was met de invariant van een enkele constructie en een enkele vernietiging en mogelijk native bronnen in een inconsistente staat achterliet.
De oplossing maakt gebruik van PhantomReference semantiek binnen de Cleaner implementatie: de reinigingsactie ontvangt alleen de Runnable of het reinigingsactie-object, niet het referent zelf, en de referent is gegarandeerd in de Phantom bereikbare staat - wat betekent dat het al is genezen en niet kan worden herrezen - waardoor wordt verzekerd dat de reinigingslogica opereert op een onherroepelijk onbereikbaar object.
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); }
Ons team beheerde een hoogdoorvoersnelheid beeldverwerkingspijplijn waar ImageProcessor objecten native OpenCV buffers omhulden die via JNI waren toegewezen. Aanvankelijk vertrouwden we op finalize() om cvReleaseImage() aan te roepen, maar we stuitten sporadisch op uitputting van native geheugen ondanks de stabiliteit van de Java-heap, vergezeld van intermitterende segmentatiefouten die gebruik-na-vrij fouten in native geheugen suggereerden.
De eerste benadering die we overwogen was het behouden van finalize(), maar met een herrijzenheidsbeschermer waar we synchroniseerden op een statische kaart om "dode" objecten bij te houden. Dit leed onder onvoorspelbare latentietijden - finalisatie kon minuten plaatsvinden nadat de druk op de heap was begonnen - en de logica van de beschermer zelf creëerde geheugentekorten door sterke referenties naar "dode" objecten in de bijhoudkaart te behouden, ironisch genoeg het verzamelen volledig te verhinderen terwijl het nog steeds herrijzen-race-voorwaarden toestond.
De tweede benadering betrof het eenvoudig gebruiken van java.lang.ref.Cleaner door de native pointer in een lambda binnen de instantieconstructor vast te leggen: cleaner.register(this, () -> free(pointer)). Hoewel dit de vertragingen bij finalisatie vermijdde, liep het risico van premature verzameling als this tijdens de constructie ontsnapte, en kritiek was dat als de lambda per ongeluk over de ImageProcessor instantie sloot in plaats van alleen de pointer waarde, het een sterke referentiecyclus zou creëren die de garbage collection van de referent verhinderde, hoewel het nog steeds herleving op zich verhinderde.
We kozen voor de derde benadering: het implementeren van een statische geneste CleaningAction klasse die alleen de native pointer bevatte (als een long) en Runnable implementeerde, volledig ontkoppeld van de buitenste ImageProcessor instantie. We registreerden de reinigingsactie onmiddellijk na succesvolle native toewijzing en riepen expliciet Reference.reachabilityFence(this) aan het einde van de constructor aan om ervoor te zorgen dat het object bereikbaar bleef totdat de registratie was voltooid. Dit elimineerde herrijzen risico's en native lekken, en verminderde geheugen drukincidenten van dagelijkse gebeurtenissen tot nul over een periode van zes maanden.
Waarom gebruikt de Cleaner PhantomReference in plaats van WeakReference of SoftReference, en hoe voorkomt dit dat de reinigingsactie toegang heeft tot de staat van de referent?
PhantomReference wordt gebruikt omdat het de garbage collector in staat stelt om terugvorderbare objecten te identificeren zonder dat de programmatuur toegang heeft tot de velden of methoden van de referent nadat deze in de phantom bereikbare staat is gekomen. In tegenstelling tot WeakReference, die het ophalen van de referent via get() tot aan de verzameling toestaat, retourneert PhantomReference altijd null van get(), waardoor de reinigingsactie opereert op de veronderstelling dat het object logisch is vernietigd en het voorkomt iedere poging tot herrijzen of staatinspectie die de post-mortem invarianten zou kunnen schenden.
Wat is de "herrijzen" aanval in de context van Object.finalize(), en waarom schendt deze de veiligheidsgaranties van het typesysteem?
Herrijzen vindt plaats wanneer de finalize() methode de this referentie opslaat in een statisch veld of actieve objectgrafiek, waardoor het object weer bereikbaar wordt nadat de garbage collector het voor terugvordering had gemarkeerd. Dit schendt de invariant dat de constructor van een object exact één keer wordt uitgevoerd en zijn finalizer hooguit één keer wordt uitgevoerd, waardoor kwaadaardige of foutieve code een object in een gedeeltelijk vernietigde staat kan observeren waar native bronnen zijn vrijgegeven maar Java velden nog steeds toegankelijk zijn, wat leidt tot gebruik-na-vrij kwetsbaarheden en inconsistent objectgedrag.
Hoe interageert Reference.reachabilityFence met de just-in-time compilatie-optimalisaties van de JVM, en wanneer is het strikt noodzakelijk om het te gebruiken met Cleaner-registraties?
Reference.reachabilityFence fungeert als een compiler-barrière die voorkomt dat de optimizer van de JVM objectreferenties herordent of elimineert voordat een kritieke sectie is voltooid, specifiek om "vroegtijdige publicatie" te voorkomen waarbij een object onbereikbaar wordt terwijl zijn constructor nog steeds wordt uitgevoerd. Het is strikt noodzakelijk wanneer objecten worden geregistreerd bij Cleaner tijdens de constructie, want zonder het kan de JVM bepalen dat this niet langer nodig is na de native resource allocatie maar vóór de registratie-aanroep, waardoor de cleaner kan draaien en de bron kan vrijgeven terwijl de constructor doorgaat met het initialiseren van het object, wat leidt tot bronnen-corruptie.