Finalizzazione e pulizia degli oggetti

Tre mesi fa, ho iniziato una mini-serie di articoli sulla progettazione di oggetti con una discussione sui principi di progettazione incentrata sulla corretta inizializzazione all'inizio della vita di un oggetto. In questo articolo sulle tecniche di progettazione , mi concentrerò sui principi di progettazione che ti aiutano a garantire una pulizia adeguata alla fine della vita di un oggetto.

Perché pulire?

Ogni oggetto in un programma Java utilizza risorse di elaborazione finite. Ovviamente, tutti gli oggetti utilizzano un po 'di memoria per archiviare le proprie immagini nell'heap. (Questo è vero anche per oggetti che non dichiarano variabili di istanza. Ogni immagine di oggetto deve includere un qualche tipo di puntatore ai dati di classe e può includere anche altre informazioni dipendenti dall'implementazione.) Ma gli oggetti possono anche utilizzare altre risorse finite oltre alla memoria. Ad esempio, alcuni oggetti possono utilizzare risorse come handle di file, contesti grafici, socket e così via. Quando si progetta un oggetto, è necessario assicurarsi che alla fine rilasci tutte le risorse finite che utilizza in modo che il sistema non esaurisca quelle risorse.

Poiché Java è un linguaggio raccolto in modo indesiderato, è facile liberare la memoria associata a un oggetto. Tutto quello che devi fare è lasciare andare tutti i riferimenti all'oggetto. Poiché non devi preoccuparti di liberare esplicitamente un oggetto, come devi fare in linguaggi come C o C ++, non devi preoccuparti di danneggiare la memoria liberando accidentalmente lo stesso oggetto due volte. Tuttavia, devi assicurarti di rilasciare effettivamente tutti i riferimenti all'oggetto. Se non lo fai, puoi finire con una perdita di memoria, proprio come le perdite di memoria che ottieni in un programma C ++ quando ti dimentichi di liberare esplicitamente oggetti. Tuttavia, finché rilasci tutti i riferimenti a un oggetto, non devi preoccuparti di "liberare" esplicitamente quella memoria.

Allo stesso modo, non devi preoccuparti di liberare esplicitamente gli oggetti costituenti a cui fanno riferimento le variabili di istanza di un oggetto di cui non hai più bisogno. Il rilascio di tutti i riferimenti all'oggetto non necessario invaliderà in effetti tutti i riferimenti all'oggetto costituente contenuti nelle variabili di istanza di quell'oggetto. Se i riferimenti ora invalidati fossero gli unici riferimenti rimanenti a quegli oggetti costituenti, gli oggetti costituenti saranno disponibili anche per la garbage collection. Pezzo di torta, vero?

Le regole della garbage collection

Sebbene la garbage collection renda effettivamente la gestione della memoria in Java molto più semplice di quanto non sia in C o C ++, non è possibile dimenticare completamente la memoria quando si programma in Java. Per sapere quando potrebbe essere necessario pensare alla gestione della memoria in Java, è necessario conoscere un po 'il modo in cui viene trattata la garbage collection nelle specifiche Java.

La raccolta dei rifiuti non è obbligatoria

La prima cosa da sapere è che non importa quanto diligentemente cerchi nella specifica Java Virtual Machine (JVM Spec), non sarai in grado di trovare nessuna frase che comanda, ogni JVM deve avere un garbage collector. La specifica della Java Virtual Machine offre ai progettisti di VM un ampio margine di manovra nel decidere come le loro implementazioni gestiranno la memoria, incluso decidere se utilizzare o meno la raccolta dei rifiuti. Pertanto, è possibile che alcune JVM (come una JVM semplice per smart card) richiedano che i programmi eseguiti in ciascuna sessione "rientrino" nella memoria disponibile.

Naturalmente, puoi sempre esaurire la memoria, anche su un sistema di memoria virtuale. La specifica JVM non indica quanta memoria sarà disponibile per una JVM. Essa afferma solo che ogni volta che una JVM non a corto di memoria, dovrebbe lanciare un OutOfMemoryError.

Tuttavia, per dare alle applicazioni Java le migliori possibilità di essere eseguite senza esaurire la memoria, la maggior parte delle JVM utilizzerà un garbage collector. Il garbage collector recupera la memoria occupata da oggetti non referenziati nell'heap, in modo che la memoria possa essere utilizzata di nuovo da nuovi oggetti e di solito deframmenta l'heap durante l'esecuzione del programma.

L'algoritmo di Garbage Collection non è definito

Un altro comando che non troverai nella specifica JVM è Tutte le JVM che utilizzano la garbage collection devono utilizzare l'algoritmo XXX. I progettisti di ogni JVM decidono come funzionerà la garbage collection nelle loro implementazioni. L'algoritmo di raccolta dei rifiuti è un'area in cui i fornitori di JVM possono sforzarsi di rendere la loro implementazione migliore di quella della concorrenza. Questo è importante per te come programmatore Java per il seguente motivo:

Poiché generalmente non sai come verrà eseguita la garbage collection all'interno di una JVM, non sai quando un particolare oggetto verrà garbage collection.

E allora? potresti chiedere. Il motivo per cui potresti preoccuparti quando un oggetto viene raccolto in modo indesiderato ha a che fare con i finalizzatori. (Un finalizzatore è definito come un normale metodo di istanza Java denominato finalize()che restituisce void e non accetta argomenti.) Le specifiche Java fanno la seguente promessa sui finalizzatori:

Prima di recuperare la memoria occupata da un oggetto che ha un finalizzatore, il garbage collector richiamerà il finalizzatore di quell'oggetto.

Dato che non sai quando gli oggetti verranno raccolti nella spazzatura, ma sai che gli oggetti finalizzabili saranno finalizzati in quanto sono raccolti nella spazzatura, puoi fare la seguente grande deduzione:

Non sai quando verranno finalizzati gli oggetti.

Dovresti imprimere questo importante fatto nel tuo cervello e permettergli di informare per sempre i tuoi progetti di oggetti Java.

Finalizzatori da evitare

La regola pratica centrale riguardo ai finalizzatori è questa:

Non progettare i programmi Java in modo tale che la correttezza dipenda da una finalizzazione "tempestiva".

In altre parole, non scrivere programmi che si interrompono se determinati oggetti non vengono finalizzati in determinati punti della vita di esecuzione del programma. Se scrivi un programma del genere, potrebbe funzionare su alcune implementazioni della JVM ma fallire su altre.

Non fare affidamento sui finalizzatori per rilasciare risorse non di memoria

Un esempio di un oggetto che infrange questa regola è quello che apre un file nel suo costruttore e chiude il file nel suo finalize()metodo. Sebbene questo design sembri pulito, ordinato e simmetrico, crea potenzialmente un bug insidioso. Un programma Java generalmente avrà solo un numero finito di handle di file a sua disposizione. Quando tutte queste maniglie sono in uso, il programma non sarà in grado di aprire altri file.

Un programma Java che utilizza un tale oggetto (uno che apre un file nel suo costruttore e lo chiude nel suo finalizzatore) può funzionare bene su alcune implementazioni JVM. In tali implementazioni, la finalizzazione si verifica abbastanza spesso da mantenere sempre disponibile un numero sufficiente di handle di file. Ma lo stesso programma potrebbe non riuscire su una JVM diversa il cui garbage collector non viene finalizzato abbastanza spesso da impedire al programma di esaurire gli handle di file. Oppure, cosa ancora più insidiosa, il programma potrebbe funzionare su tutte le implementazioni JVM ora ma fallire in una situazione mission-critical alcuni anni (e cicli di rilascio) lungo la strada.

Altre regole pratiche per i finalizzatori

Altre due decisioni lasciate ai progettisti di JVM sono la selezione del thread (o dei thread) che eseguiranno i finalizzatori e l'ordine in cui verranno eseguiti i finalizzatori. I finalizzatori possono essere eseguiti in qualsiasi ordine, in sequenza da un singolo thread o contemporaneamente da più thread. Se il tuo programma dipende in qualche modo dalla correttezza dai finalizzatori eseguiti in un ordine particolare o da un particolare thread, potrebbe funzionare su alcune implementazioni JVM ma fallire su altre.

È inoltre necessario tenere presente che Java considera un oggetto da finalizzare se il finalize()metodo viene restituito normalmente o viene completato bruscamente generando un'eccezione. I garbage collector ignorano le eccezioni lanciate dai finalizzatori e non notificano in alcun modo al resto dell'applicazione che è stata lanciata un'eccezione. Se è necessario assicurarsi che un determinato finalizzatore compia completamente una determinata missione, è necessario scrivere tale finalizzatore in modo che gestisca eventuali eccezioni che potrebbero sorgere prima che il finalizzatore completi la sua missione.

Un'altra regola pratica sui finalizzatori riguarda gli oggetti rimasti nell'heap alla fine del ciclo di vita dell'applicazione. Per impostazione predefinita, il Garbage Collector non eseguirà i finalizzatori di alcun oggetto lasciato nell'heap quando l'applicazione viene chiusa. Per modificare questa impostazione predefinita, è necessario richiamare il runFinalizersOnExit()metodo di classe Runtimeo System, passando truecome singolo parametro. Se il tuo programma contiene oggetti i cui finalizzatori devono essere assolutamente richiamati prima che il programma esca, assicurati di richiamare runFinalizersOnExit()da qualche parte nel tuo programma.

Allora a cosa servono i finalizzatori?

A questo punto potresti avere la sensazione di non essere molto utile per i finalizzatori. Sebbene sia probabile che la maggior parte delle classi che progetti non includa un finalizzatore, ci sono alcuni motivi per utilizzare i finalizzatori.

Un'applicazione ragionevole, sebbene rara, per un finalizzatore è liberare la memoria allocata dai metodi nativi. Se un oggetto richiama un metodo nativo che alloca memoria (forse una funzione C che chiama malloc()), il finalizzatore di quell'oggetto potrebbe richiamare un metodo nativo che libera quella memoria (chiamate free()). In questa situazione, utilizzeresti il ​​finalizzatore per liberare la memoria allocata per conto di un oggetto: memoria che non verrà automaticamente recuperata dal garbage collector.

Un altro, più comune, utilizzo dei finalizzatori è fornire un meccanismo di fallback per il rilascio di risorse finite non di memoria come handle di file o socket. Come accennato in precedenza, non dovresti fare affidamento sui finalizzatori per il rilascio di risorse finite non di memoria. Invece, dovresti fornire un metodo che rilascerà la risorsa. Ma potresti anche voler includere un finalizzatore che controlli per assicurarsi che la risorsa sia già stata rilasciata e, in caso contrario, procede e la rilascia. Un tale finalizzatore protegge (e si spera non incoraggerà) un uso sciatto della tua classe. Se un programmatore client si dimentica di richiamare il metodo fornito per rilasciare la risorsa, il finalizzatore rilascerà la risorsa se l'oggetto viene mai raccolto in garbage collection. Il finalize()metodo diLogFileManager class, mostrato più avanti in questo articolo, è un esempio di questo tipo di finalizzatore.

Evita l'abuso del finalizzatore

L'esistenza della finalizzazione produce alcune complicazioni interessanti per le JVM e alcune interessanti possibilità per i programmatori Java. Ciò che la finalizzazione garantisce ai programmatori è il potere sulla vita e sulla morte degli oggetti. In breve, è possibile e completamente legale in Java resuscitare oggetti nei finalizzatori, riportarli in vita rendendoli di nuovo referenziati. (Un modo in cui un finalizzatore potrebbe ottenere ciò è aggiungendo un riferimento all'oggetto da finalizzare a un elenco collegato statico che è ancora "attivo".) Sebbene tale potere possa essere tentato di esercitarsi perché ti fa sentire importante, la regola pratica è resistere alla tentazione di usare questo potere. In generale, la resurrezione di oggetti nei finalizzatori costituisce un abuso del finalizzatore.

La principale giustificazione di questa regola è che qualsiasi programma che utilizza la resurrezione può essere riprogettato in un programma più facile da capire che non utilizza la resurrezione. Una dimostrazione formale di questo teorema è lasciata come esercizio al lettore (ho sempre voluto dirlo), ma in uno spirito informale, considera che la resurrezione dell'oggetto sarà casuale e imprevedibile quanto la finalizzazione dell'oggetto. In quanto tale, un progetto che utilizza la resurrezione sarà difficile da capire dal prossimo programmatore di manutenzione che capiterà, che potrebbe non comprendere appieno le peculiarità della raccolta dei rifiuti in Java.

Se ritieni di dover semplicemente riportare in vita un oggetto, valuta la possibilità di clonare una nuova copia dell'oggetto invece di resuscitare lo stesso vecchio oggetto. Il ragionamento alla base di questo consiglio è che i garbage collector nella JVM invocano il finalize()metodo di un oggetto solo una volta. Se quell'oggetto viene resuscitato e diventa disponibile per la garbage collection una seconda volta, il finalize()metodo dell'oggetto non verrà richiamato di nuovo.