Come anticpato, l'OutOfMemoryError è un sintomo di un memory leak, ma non necessariamente ne indica la presenza, potendo essere connesso con la semplice richiesta di maggiore memoria da parte dell'applicazione. Per stabilire l’effettiva necessità di memoria si ricorre spesso a misure continuative dell'occupazione di memoria da parte dell'applicazione, alternando carichi eccessivi con carichi leggeri e verificando come la richiesta vari nel tempo. Se l'applicazione non presenta memory leak, in concomitanza con carichi maggiori potremo notare delle "richieste di picco" della memoria, memoria che tornerà a svuotarsi (o comunque ad attestarsi su valori decisamente più bassi) successivamente all'occorrenza dei carichi minori.
Modellando su un grafico l'andamento della memoria occupata nel tempo, potremo assistere ad una situazione di questo tipo, dove i picchi sono concomitanti con gli orari d'ufficio.
La presenza di un picco non è quindi sintomo di memory leak. Può culminare con un OOM, ma in tal caso la soluzione sta nel dimensionare l'area massima di memoria allocabile in modo da poter servire le richieste di picco (compito tutt'altro che semplice dipendendo da fattori spesso non prevedibili o normalmente non monitorabili). Ci penserà il Garbage Collector a ripulire l'area occupata per renderla nuovamente disponibile per il prossimo picco.
Un potenziale (quasi certo) memory leak è invece ravvisabile nel seguente grafico nel quale vediamo che l'occupazione minima di memoria sale costantemente nel tempo. Magari lentamente, ma in continua crescita. Ciò significa che il Garbage Collector si trova ad avere a che fare con oggetti che non può rimuovere dalla memoria, con conseguente accumulo di spazio occupato.
Se l’applicazione rimane attiva per periodi di tempo prolungato, anche volendo aumentare l'area a disposizione dell'heap, l'area occupata tenderà a crescere e sarà solo questione di tempo prima che le risorse, in questo caso l'heap, siano sature. Il memory leak opera dunque in funzione del tempo, non del carico.
E' evidente come il problema sia particolarmente infido. Se l'area persa è minima, prima di incappare nel fault potranno passare giorni e giorni, se non settimane o persino mesi, per poi probabilmente non manifestarsi nemmeno alla successiva esecuzione a causa del riavvio di una macchina. Riavvio che potrebbe rappresentare una soluzione di backup, ma al prezzo di perdere in termini di affidabilità e prestazioni del sistema, e conseguentemente riflettendosi su una richiesta di manutenzione maggiore e proporzionalmente di un grado di fiducia minore.
Tra gli strumenti che possiamo trovare dalla Java 5 in poi nella JDK c'è un utile strumento di diagnostica, la JConsole, un'interfaccia grafica in grado di permetterci di monitorare diversi parametri tra i quali l'andamento del consumo di memoria, le richieste di CPU e così via. Volendo lanciare il programma, lo troveremo tra i binari della JDK. Pertanto, se avremo correttamente installato la JDK e aggiunto al path di sistema il percorso fino ai binari, potremo lanciare la JConsole semplicemente avviando il comando jconsole
da terminale.
Si aprirà una schermata che ci permetterà di connetterci con un processo remoto o locale. Nell’immagine seguente vediamo l'interfaccia tramite la quale avviare la connessione ad un programma Java lanciato in precedenza (un elementare programma che si arresta ogni secondo in attesa di sapere se deve continuare ad incrementare un contatore o se deve restare in attesa), identificato dal nome del package e della classe (oltre che dal PID associato al processo).
Avviata la connessione, la prima schermata che ci si presenterà contiene quattro grafici, un resoconto dei principali elementi monitorati. Nel menu a barra posto nella parte alta dell'interfaccia sarà possibile raggiungere le schede della memoria, dei threads, delle classi, un resoconto dei parametri della macchina virtuale e una scheda "MBeans" con un albero di oggetti MBean il cui scopo è rappresentare le risorse da gestire.
Osservando il grafico riguardante l'heap, possiamo notare subito l'andamento periodico del consumo di memoria allocata, crescente fino all'azione periodica del Garbage Collector. Volendo capirci di più, potremo indagare su quanto sta succedendo consultando l'apposita scheda e verificare il consumo di memoria delle singole aree della JVM. Queste aree, come osservabile nell'immagine seguente, sono rappresentate da rettangoli verdi che mostrano la parte allocata (più scura) rispetto a quella disponibile (più chiara).
E' possibile vedere che l'Eden Space si va man mano popolando per poi essere spopolato tramite l'azione del Minor GC, per mezzo del quale gli oggetti ancora attivi vengono mossi verso il Survivor Space. Successivamente viene eseguito un normale ciclo di GC e il Survivor Space viene alleggerito, con gli oggetti ancora referenziati che passano in carico alla Tenured Generation.
In questo programma elementare la richiesta per la Tenured Generation (nell'interfaccia grafica indicata come "Old Gen") è praticamente nulla, meno di un megabit. Ma è proprio in quest'ultima aerea che si hanno i principali riscontri della presenza di un memory leak.
Avviamo ora un altro programma, esso carica un elenco di persone ogni tot secondi e invoca un metodo della classe riportata di seguito. Questo metodo a sua volta non fa altro che aggiungere l'elenco ad una lista interna per poi invocare un metodo privato che dovrebbe compiere qualche elaborazione sulla lista, ad esempio per un controllo o per memorizzarla in un database. In questo esempio ci si limita ad eseguire una stampa a video. In apparenza niente di strano.
package memoryleak.test;
import java.util.ArrayList;
import memoryleak.data.ElencoPersone;
public class ElaborazioneElencoPersone {
private ArrayList elenchiPersone;
public ElaborazioneElencoPersone(){
elenchiPersone = new ArrayList();
}
public void elaboraElenco(ElencoPersone elencoPersone){
elenchiPersone.add(elencoPersone);
elaborazioneInternaElenco(elencoPersone);
}
private void elaborazioneInternaElenco(ElencoPersone elencoPersone){
System.out.println("Elaborazione avvenuta!");
}
}
Sottoponendo questa classe a un main
di test che invia dati in continuazione e attivando la JConsole, salta subito agli occhi l'occupazione di memoria che cresce non seguita dal rilascio della stessa, con conseguente accumulo. Nell'immagine seguente si mostra l'andamento di memoria occupata nell'heap e nella tenured generation. Nell'heap vi è una continua richiesta di nuova memoria, in quanto il GC non è nelle condizioni di rilasciare gli oggetti che arrivano nella tenured generation, soggetta a una crescita praticamente incontrollata.
Quello proposto è un caso piuttosto semplice nel quale risulta evidente l'accumulo. Ma già in questo esempio si può vedere perché è facile incappare in problemi di memoria (spesso non rilevati semplicemente perché i programmi non vengono eseguiti per periodi di tempo prolungati). Buona pratica è quella di prevedere il maggior numero di scenari possibili, ma per le più svariate ragioni (poco tempo a disposizione, scenari di utilizzo concordati su brevi periodi, etc.), non è sempre detto che la soluzione adottata sia in grado di far fronte a tutti gli scenari previsti (e in particolare a quelli non previsti).
E' possibile inoltre che non sia nemmeno chiaro quale sarà la responsabilità di ogni singola classe o metodo, in particolare negli ambiti in cui le applicazioni vengono sviluppate da più persone, o create per contesti distribuiti. In generale il disaccoppiamento dovrebbe ridurre simili problemi, ma non è detto che la divisione delle responsabilità o le specifiche siano chiare. Aggiungiamo a questo la semplice dimenticanza, ne risulta che simili lacune sono piuttosto frequenti.
Questo è un esempio semplice e la perdita di memoria è costante ed evidente fin dall'inizio. Spesso invece una volta incappati in un memory leak occorre monitorare l'applicazione per lungo tempo e non è nemmeno detto che la condizione diventi evidente sui grafici perché potrebbe esserci una perdita legata a qualche evento poco frequente. Altro problema è quindi quello di poter generare un tester in grado di sollecitare la giusta sequenza utile a mostrare l'accumulo di memoria. Di seguito si mostra un andamento nel quale l'accumulo inizialmente non è così evidente, pur delineandosi col tempo (e con un tester adeguato). I due tratti diagonali attorno alle 10:00 e alle 12:30 indicano cadute della connessione tra la JConsole e l’applicazione.
Dump dell'heap
Non sempre si ha la possibilità di creare tester idonei, ma anche avendoli e individuato un problema, non è detto che si sia in grado di capire dove sia l'accumulo e quali sono le sezioni di codice coinvolte, anche per poter stabilire se si tratta di un problema di dimensionamento dell'heap non in grado di soddisfare dei picchi, o vere e proprie perdite di memoria.
I grafi ci mostrano l'accumulo, ma non ci dicono dove e come avviene. Per ovviare a questo problema si può effettuare un dump della memoria da analizzare con appositi strumenti. Strumenti di analisi dell'heap infatti possono fornirci informazioni utili per individuare comportamenti anomali riducendo la necessità di monitorare per lungo tempo l'attività dell'applicazione e la necessità di tester appositi. Possiamo utilizzare la JConsole per ricavare un dump dell'heap da analizzare con un tool apposito. A tal fine, è possibile accedere alla scheda "MBeans" e navigare l’albero dei beans fino a:
cum.sun.management/HotSpotDiagnostic/Operations/dumpHeap
Avremo a disposizione un’interfaccia simile alla seguente con un pulsante ("dumpHeap") utilizzabile per ricavare il dump e due parametri ("p0" e "p1"). Il primo ci permette di indicare il nome completo del file (path e nome del file) dove vogliamo sia riportato il dump, mentre il secondo se fissato su true
richiede un ciclo di garbage collection prima di eseguire il dump. Nell'esempio mostrato, il file dove verrà caricato il dump lo chiamiamo dump.hprof
(successivamente sarà chiaro il motivo per cui abbiamo scelto questa estensione per il file). Premuto il pulsante, occorrerà attendere il completamento dell’operazione. Un'interfaccia con l'indicazione che il metodo relativo è stato invocato con successo (Method successfully invoked) ci informerà dell’avvenuta creazione del dump.
Da tener presente che il dump può richiedere spazi di memoria notevoli, anche nell'ordine del gigabyte.