Storia: Prima di Java 9, la gestione delle risorse native in classi come Inflater e Deflater si basava su Object.finalize(). Questo meccanismo è stato deprecato a causa dell'imprevedibilità, del grave sovraccarico di prestazioni e del rischio di resurrezione dell'oggetto che ritardava la raccolta dei rifiuti. Java 9 ha introdotto l'API Cleaner come alternativa moderna, utilizzando PhantomReference e ReferenceQueue per disaccoppiare la logica di pulizia dal ciclo di vita dell'oggetto, garantendo che l'oggetto rimanga irraggiungibile durante la pulizia.
Problema: Nell'implementazione di Inflater, la struttura nativa sottostante z_stream deve essere deallocata esplicitamente tramite il metodo end() per prevenire perdite di memoria native. Quando un thread dell'applicazione chiama end() esplicitamente mentre il thread Cleaner tenta simultaneamente di eseguire l'azione di pulizia registrata, emerge una condizione di competizione. Senza una sincronizzazione adeguata, entrambi i thread potrebbero tentare di liberare lo stesso puntatore nativo, portando a un errore di double-free, oppure un thread potrebbe accedere alla risorsa dopo che l'altro l'ha liberata (use-after-free), con conseguente arresto anomalo della JVM (SIGSEGV) nella libreria nativa zlib.
Soluzione: La soluzione utilizza un flag di stato AtomicBoolean per garantire che la pulizia nativa venga eseguita esattamente una volta indipendentemente da quale thread la avvii. Sia il metodo esplicito end() che l'azione di pulizia del Cleaner eseguono un'operazione di confronto e impostazione (CAS) su questo flag. Solo il thread che riesce a passare il flag da false a true procede a invocare la routine di deallocazione nativa. Questo approccio senza lock garantisce la sicurezza del thread mantenendo l'alta prestazione richiesta per le operazioni di compressione.
Un servizio di compressione di log ad alta capacità elabora milioni di voci di log al giorno utilizzando istanze di Deflater in pool per ridurre al minimo il sovraccarico di allocazione. Per ottimizzare l'uso delle risorse, gli sviluppatori hanno implementato un modello di ritorno al pool che chiama esplicitamente end() sulle istanze di Deflater prima di restituirle al pool, mentre si basano anche sulla raccolta dei rifiuti per recuperare le istanze che sono state perse a causa di eccezioni non gestite nella pipeline di elaborazione.
Il sistema ha subito arresti anomali sporadici ma critici della JVM (SIGSEGV) sotto carico massimo, con i dump di core che indicavano corruzione di memoria all'interno della libreria nativa zlib. Un'indagine ha rivelato che quando un'istanza di Deflater veniva restituita al pool, il thread dell'applicazione chiamava end(), ma se l'istanza diventava idonea per la raccolta dei rifiuti simultaneamente, il thread Cleaner avrebbe tentato di pulire lo stesso handle nativo z_stream. Questo accesso non sincronizzato alla risorsa nativa ha causato l'arresto imprevedibile del processo.
La prima soluzione considerata è stata quella di sincronizzare ogni accesso all'istanza di Deflater utilizzando blocchi o metodi synchronized. Questo approccio avrebbe effettivamente impedito la condizione di competizione garantendo l'esclusione mutua. Tuttavia, ha introdotto un notevole sovraccarico di contesa nel pipeline di compressione ad alta frequenza e rischiava deadlock se l'oggetto era accessibile in modo errato da più thread contemporaneamente, violando il contratto di sicurezza del thread della classe.
Il secondo approccio prevedeva l'uso di un AtomicBoolean per tracciare lo stato di pulizia. Sia il metodo esplicito end() che l'azione del Cleaner avrebbero controllato e impostato questo flag in modo atomico prima di toccare la risorsa nativa. Questo offre sicurezza senza lock con una penalizzazione minima delle prestazioni, anche se richiedeva una implementazione attenta per garantire che l'handle nativo non fosse accessibile dopo il controllo atomico ma prima della chiamata nativa.
La terza opzione era rimuovere completamente le chiamate esplicite a end() e fare affidamento esclusivamente sul Cleaner per la gestione delle risorse. Questo ha eliminato completamente la condizione di competizione ma ha introdotto imprevedibilità nei tempi di rilascio della memoria nativa, causando potenziale pressione di memoria severa durante le pause della raccolta dei rifiuti se i cicli GC ritardavano rispetto al tasso di allocazione delle strutture native.
Il team ha selezionato l'approccio AtomicBoolean (Soluzione 2) perché forniva una pulizia immediata deterministica quando possibile (chiamata esplicita) garantendo al contempo la sicurezza se il cleaner veniva eseguito in seguito. Hanno modificato la classe wrapper per implementare AutoCloseable, garantendo che il controllo dello stato atomico proteggesse la deallocazione nativa. Questo ha risolto completamente gli arresti anomali mantenendo il throughput richiesto, eliminando gli arresti anomali legati alla memoria nativa in produzione.
**Come fa l'API Cleaner a prevenire il problema della resurrezione degli oggetti intrinseco a Object.finalize()?
In Object.finalize(), l'oggetto è ancora raggiungibile quando il metodo finalize() viene eseguito perché il riferimento this rimane valido, consentendo all'oggetto di resurrezionarsi memorizzando un riferimento a se stesso in un campo statico. Questa resurrezione ritarda indefinitamente la raccolta dei rifiuti se l'oggetto si resurreziona ripetutamente. L'API Cleaner previene ciò utilizzando PhantomReference. Quando l'azione di pulizia del Cleaner viene eseguita, il referente (l'oggetto da pulire) è già nello stato di accessibilità fantasma, il che significa che non può essere resurrezionato poiché non esistono riferimenti forti, deboli o morbidi ad esso. L'azione di pulizia è un Runnable separato, non un metodo sull'oggetto stesso, garantendo che l'oggetto rimanga irraggiungibile durante l'intero processo di pulizia.
Perché Thread.interrupt() è inefficace per fermare un thread Cleaner durante lo spegnimento della JVM, e quali sono le implicazioni?
Il thread Cleaner è un thread daemon che blocca continuamente su ReferenceQueue.remove(), in attesa che i riferimenti fantasma diventino disponibili. Sebbene ReferenceQueue.remove() risponda agli interrupt lanciando InterruptedException, l'implementazione del Cleaner cattura questa eccezione e continua il suo ciclo infinito, ignorando effettivamente gli interrupt. Questo design garantisce che la pulizia critica delle risorse venga completata anche durante le sequenze di spegnimento. Tuttavia, se un'azione di pulizia registrata si blocca indefinitamente (ad esempio, in attesa di un timeout di rete o bloccata in un ciclo infinito), il thread Cleaner non terminerà mai. Questo può impedire alla JVM di spegnersi in modo ordinato se altri thread non daemon stanno aspettando risorse che il cleaner dovrebbe rilasciare.
Quale catastrofica perdita di memoria si verifica se l'azione di pulizia di un Cleaner cattura un riferimento forte all'oggetto da pulire?
Se il Runnable passato a Cleaner.register() cattura un riferimento forte all'oggetto (ad esempio, tramite this::cleanupMethod o una lambda che fa riferimento a this), crea un ciclo di riferimento fatale. Il Cleaner mantiene un insieme interno di oggetti Cleanable, ognuno dei quali detiene un riferimento al Runnable di pulizia. Se quel Runnable fa riferimento all'oggetto originale, l'oggetto rimane fortemente raggiungibile dal thread Cleaner stesso. Di conseguenza, l'oggetto non diventa mai raggiungibile in modo fantasma, il PhantomReference non entra mai nella coda e l'azione di pulizia non viene mai eseguita. Nel frattempo, l'oggetto non può essere raccolto, provocando una grave perdita di memoria che cresce illimitatamente con ogni oggetto registrato al Cleaner, causando eventualmente OutOfMemoryError.