MokaByte 97 - Giugno 2005
  MokaByte 97 - Giugno 2005  

 

 

 

Individuare i Memory Leaks nelle applicazioni Java

Perchè le nostre applicazioni Java sono poco scalabili e allocano tanta memoria? Forse perchè non la gestiscono in modo efficiente...

Introduzione
Qualunque sviluppatore Java, indipendentemente dalla propria capacità o esperienza, si è con ogni probabilità trovato faccia a faccia con comportamenti inattesi da parte delle applicazioni su cui lavorava. Ad esempio, applicazioni web che funzionano regolarmente con 1800 utenti contemporaneamente connessi e che con "solo" 100 utenti in più rallentano in modo inaccettabile. Ogni volta che una applicazione si comporta in modo inaspettatamente negativo, ciò può dipendere da diversi fattori, non soltanto dovuti ad algoritmi inefficienti ma anche a gestione scorretta della memoria. Qualcuno potrebbe storcere la bocca, giacchè è noto che in Java esiste un meccanismo di Garbage Collection che "ripulisce" automaticamente tutto ciò che non viene più utilizzato…..ma è proprio vero che le cose stanno così? Se il "Garbage Collector" rimuove tutto ciò che vede, cosa accade se esistono oggetti che non riesce a vedere? Che non li rimuove!!! Tali oggetti pertanto creano delle "falle" nella memoria, ossia quel tipo di problemi noti come "Memory Leaks" (rif [1])….."ma no", avrebbe detto una nota pubblicità di un detersivo diversi anni fa, "non esiste sporco impossibile!" Impossibile no, ma difficile da individuare certamente sì. E' lecito chiedersi se esistono delle tecniche per prevenire queste situazioni, quali sono, quando e come utilizzarle. In questo articolo cercheremo di dare alcune risposte.

 

Problemi nelle applicazioni Java
Quando una applicazione Java presenta problemi o malfunzionamenti, è generalmente possibile individuare una serie di "sintomi" che possono essere raggruppati in una o più delle seguenti categorie:

  • Scalabilità, ad esempio codice che funziona correttamente almeno fino a quando l'insieme degli utenti simultanei non raggiunge un valore critico, dopo il quale il comportamento degrada
  • Performance percepita, ad esempio ritardi durante lo start-up della applicazione, o durante l'esecuzione di una semplice operazione (ad es. disegnare una finestra)
  • Eccessivo utilizzo della CPU, ad esempio rallentamenti nell'eseguire operazioni grafiche o transazioni su DBMS
  • Condivisione di risorse critiche, ad esempio quando in una applicazione multi-thread diversi thread concorrono nell'utilizzo della memoria

Il sorgere di questi inconvenienti non è necessariamente legato alla fase di sviluppo o a quella del testing o all'esercizio, nel senso che essi possono presentarsi in qualsiasi momento....e purtroppo il costo del loro presentarsi non è lo sempre lo stesso: Giga Group ha stabilito un costo superiore del 50% nel caso in cui i problemi insorgano durante la fase di produzione: in sostanza, più tardi si rilevano i problemi, maggiore è il costo da sostenere per eliminarli.

 

Il concetto di Profiling
Quanto detto evidenzia la necessità di intervenire prima possibile, magari già durante le fasi dello sviluppo di una applicazione, al fine di permettere agli sviluppatori di "correggere il tiro" e di prevenire il maggior numero di problemi al più presto possibile. In definitiva, occorre "profilare" in modo opportuno la nostra applicazione. Ma cosa significa esattamente profilare (traduzione italiana del verbo inglese "profiling")? Come descritto in modo molto efficace in rif.[2], si intende per "profiling":

  • la capacità di monitorare e tracciare gli eventi che accadono a run time
  • la capacità di tracciare il costo di tali eventi
  • la capacità di attribuire tale costo a specifiche parti della applicazione

Ad esempio, un profiler può fornire informazioni su quale parte della nostra applicazione consuma la maggiore quantità di tempo di CPU, o su quale parte della nostra applicazione alloca la maggiore quantità di memoria.
A questo punto nascono due domande: quando effettuare il profiling e come procedere ad effettuarlo.
Un testo fondamentale sulle problematiche di performance in ambiente Java (rif.[3]) risponde alla prima domanda, in quanto mostra proprio come i processi di testing possano essere efficacemente integrati e completati da quelli di profiling, immergendo poi il tutto nel ciclo di vita della applicazione. Secondo quanto affermato dagli autori, una volta che la nostra applicazione risulta funzionalmente corretta, è il momento di verificare che la performance sia accettabile, ed eventualmente impostare una adeguata infrastruttura di profiling. La figura 1 riassume proprio questo tipo di approccio, in cui si nota in particolare anche che i risultati ricavati durante il profiling possono innescare successive fasi di analisi, progettazione, codifica.


Figura 1
- Il Profiling nel Ciclo di Vita dell'Applicazione

Adesso occorre rispondere alla seconda domanda, e cioè come effettuare il profiling, e cioè con quali metodologie, strategie, strumenti. Le aree tipiche in cui si attuano le procedure di profiling riguardano il tempo di esecuzione (la Performance Analysis), la copertura del codice eseguito (Code Coverage), ma soprattutto - area decisamente più critica - l'Analisi della Memoria (Memory Analysis), proprio per individuare i già citati "Memory Leaks". E' su quest'ultima area che soffermeremo la nostra attenzione.
Per tutti gli argomenti trattati saranno presentati diversi esempi pratici, che verranno analizzati e trattati utilizzando la soluzione Compuware DevPartner Java (rif.[4]).

 

L' occupazione della RAM
Quale "impronta" lascia nella memoria runtime (quella comunemente indicata come "heap") la nostra applicazione? Tale informazione va sotto il nome di RAM Footprint. Scegliamo quindi nella nostra applicazione una parte che consideriamo "sospetta" perchè pensiamo possa consumare tanta RAM: essa costituirà la cosiddetta parte "profilata" della applicazione. Perchè è importante conoscere il "RAM Footprint" della parte profilata della nostra applicazione? Perchè così siamo in grado, in modo abbastanza rapido, di verificare se la nostra applicazione è o no il principale "RAM allocator" durante l'esecuzione, quello che in definitiva si aggiudica una fetta un pò troppo grossa di RAM.
Nella figura 2 si può notare un "pie chart" (diagramma a torta) che illustra le varie "fette" di RAM allocate da una applicazione durante la sua esecuzione. I diversi colori evidenziano le parti profilate e non profilate della applicazione. Nel caso in esame si nota come la maggior parte della RAM sia stata occupata dalla parte profilata della nostra applicazione, che quindi necessita di una attenta analisi al fine di comprendere i motivi che hanno portato a questo fatto.


Figura 2
- RAM Footprint di una Applicazione


I Memory Leaks
Obiettivo di una buona analisi della memoria è quello di individuarne le "falle", ovvero quei difetti della nostra applicazione - detti "Memory Leaks" - che portano ad un uso improprio o inefficiente della memoria stessa e che possono quindi causare problemi quali rallentamenti generali della performance e anche scarsa scalabilità.
Come individuare i Memory Leaks? Quali sono le cause dei Memory Leaks? L'esperienza dimostra che possono essere molteplici. Facciamo un semplice esempio. Consideriamo il codice che gestisce una struttura dati di tipo stack (la classica "pila"). Come noto, le operazioni fondamentali che possono essere effettuate sono quella di "inserisci elemento nello stack" (push) e "estrai elemento dallo stack" (pop). Immaginiamo di analizzare una semplice applicazione Java che gestisce uno stack e cerchiamo i potenziali Memory Leaks. Eseguendo una sessione di analisi otteniamo il diagramma riportato in figura 3


Figura 3
- Ricerca Memory Leaks: diagramma real time

si può intuitivamente già vedere che qualcosa nella nostra applicazione non va. In effetti, il diagramma che rappresenta la occupazione della memoria dovrebbe tornare dopo un certo tempo a zero in virtù delle Garbage Collection responsabili della eliminazione degli oggetti allocati. Il fatto che il diagramma si mantenga alto e non torni ai livelli iniziali desta dei sospetti. Se al termine della nostra sessione di analisi, sono rimasti in memoria degli oggetti, essi vengono riguardati come potenziali Memory leaks e ciò fornisce utili indicazioni su dove spingere ulteriori indagini. Ogni volta che il Garbage Collector non individua ed elimina un oggetto, significa che esiste almeno una reference ad esso, e probabilmente non dovrebbe esserci..... Il nostro strumento di profiling ci fornisce poi anche una analisi dettagliata delle varie parti della applicazione responsabili di aver creato potenziali "Leaks" (figura 4); vediamo di sfruttarle nel modo opportuno.


Figura 4 - Ricerca Memory Leaks: sintesi risultati ottenuti

Se concentriamo la nostra attenzione sulla prima delle classi caratterizzate dal maggior numero medio di "leaked bytes", la classe Object[], possiamo visualizzarne anche il relativo Object Reference Path, che ci fornisce informazioni sul perchè quel determinato oggetto è in memoria, in quanto referenziato da altri. In figura 5 vediamo appunto tale grafo


Figura 5 - Ricerca Memory Leaks: Object Reference Graph
(clicca sull'immagine per ingrandire)

 

facendo ora click sul nodo in giallo (Object[], oggetto della nostra analisi), ci viene presentato il frammento di codice in cui la struttura di dati Object[] viene allocata nel metodo pop()(figura 6)


Figura 6 - Ricerca Memory Leaks: Visualizzazione codice

e non è difficile accorgersi che basterebbe inserire una semplice istruzione di "ripulitura" tipo elements[size] = null per eliminare il Memory Leak. In sostanza, in questo caso semplice, abbiamo a che fare con un metodo pop()che quando estrae gli elementi non li "ripulisce", quindi essi continuano a occupare memoria e quindi non vengono eliminati dal Garbage Collector ....la nuova versione "pulita" del nostro frammento di codice potrebbe presentarsi così:

package stackLeakExample;

public class Stack
{
private Object[] elements; // Buffer per contenere lo stack
private int size = 0;

public Stack(int initialCapacity)
{
this.elements = new Object[initialCapacity];
}

public void push(Object e)
{
ensureCapacity();
elements[size++] = e;
}

public Object pop()
{
Object e = elements[--size];

// STATEMENT INSERITO PER ELIMINARE IL LEAK!!!
elements[size] = null;

return e;
}

private void ensureCapacity()
{
if (elements.length == size)
{
Object[] oldElements = elements;
elements = new Object[2*elements.length+1];
System.arraycopy(oldElements, 0, elements, 0, size);
}
}
}

Abbiamo esaminato in questo semplice esempio una delle possibili cause dei Memory Leaks, dovuta a una errata inizializzazione e re-inizializzazione degli elementi estratti dallo stack. Altre possibili cause possono aversi ad esempio quando viene aperta una connessione verso un database che non viene poi successivamente chiusa. Esistono poi alcune categorie di oggetti che, per la loro stessa natura, possono portare a intasare gravemente la memoria favorendo la comparsa di problemi di scalabilità: essi sono i cosiddetti "oggetti temporanei", quegli oggetti cioè che vengono mantenuti in memoria anche quando non servono più alla applicazione che li ha utilizzati. Vediamoli un pò più da vicino.

 

Gli oggetti temporanei
Come già detto più sopra, uno dei punti di forza universalmente riconosciuti di Java è la gestione automatica della memoria, grazie alla presenza del Java Garbage Collector. Come specificato in rif.[5], è però necessario conoscerne bene i meccanismi di di funzionamento, altrimenti si rischia di provocare l'insorgere di oggetti che in memoria vengono mantenuti per tempi inaccettabilmente lunghi, riducendo la performance della nostra applicazione e consumando anche elevate porzioni di RAM (RAM footprint, v. più sopra). Consideriamo il frammento di codice seguente:

java.lang.String:
String r = new String ();
For (int I-0< limit; I+=1) {
r = r + compute(i);
}
return r;

Per ciascuna iterazione, una nuova occorrenza di String r viene allocata come oggetto temporaneo e la r corrente viene copiata nella nuova istanza dopo che compute(i)è stato aggiunto alla nuova stringa r. Tutto questo accade per ogni iterazione. Una tecnica comunemente utilizzata e nota per poter risolvere questo problema consiste nell'utilizzare StringBuffer al posto di String, proprio allo scopo di ridurre il numero di oggetti temporanei allocati e produrre codice più veloce e scalabile. Il frammento appena visto diventa quindi

java.lang.StringBuffer:
StringBuffer r = new StringBuffer();
For (int i=0; i< limit; i+=1) {
r.append(compute(i));
}
return r.toString();

Nella vita reale le cose non sono sempre così semplici e dirette. Effettuare una analisi approfondita del codice (che può essere tanto e neppure scritto da noi.....) e comprendere quali parti sono responsabili della creazione di pericolosi oggetti temporanei può essere un compito piuttosto complesso da svolgere, spesso complesso in modo eccessivo. Ecco dunque l'utilità di poter disporre di appositi tool, che permettano di effettuare analisi della memoria con ricerca degli oggetti temporanei e mettano a disposizione anche il relativo Object Graph, fondamentale per "tracciare" con precisione chi ha invocato chi, la memoria utilizzata dai vari oggetti e anche la storia degli oggetti temporanei, quanti ne sono stati creati e che tipo di "persistenza" possiedono, distinguendo gli "short lived", eliminati alla prima garbage collection, i "medium lived", eliminati dopo la seconda o terza, i "long lived", eliminabili dopo varie passate se non addirittura indistruttibili. Se si ha a che fare con applicazioni che provocano l'insorgere di numerosi oggetti temporanei e si esegue su di essi una Memory Analysis di tipo Object Lifetime, si ottiene un andamento a "picchi" come quello riportato in figura 7.


Figura 7
- Eccessivo numero di oggetti temporanei creati

In seguito a ciò, e in modo analogo a quanto visto a proposito dei Memory Leaks, è possibile ottenere ulteriori informazioni sulle diverse parti della applicazione che hanno creato oggetti temporanei e spingere oltre le proprie indagini, fino a scoprire i "colpevoli"...

 

Conclusioni
Riuscire a scrivere codice Java per applicazioni business-critical che sia, oltre che funzionalmente corretto, anche performante e scalabile, è compito estremamente complesso e gravoso. Proprio per questi motivi una accorta ed efficienze conoscenza dei meccanismi di gestione della memoria in Java è di fondamentale importanza, così come l'utilizzo combinato dei diversi meccanismi di analisi applicabili. Non si tratta, come abbiamo visto, di effettuare soltanto una analisi generalizzata volta ad individuare la porzione di RAM occupata, ma di spingere l'analisi della memoria a 360 gradi, con l'obiettivo di individuare i tanto temuti Memory Leaks, causati da una molteplicità di fattori tra cui - molto importanti - i cosiddetti oggetti temporanei; chi lavora con Java sa bene che non è possibile evitarne del tutto l'impiego, ma è certo che una accorta e consapevole gestione degli stessi porta senz'altro ad una maggiore qualità ed efficienza del codice.

 

Bibliografia
[1] G. Begic "Monitoring Object Creation in Java Application" - http://www-106.ibm.com/developerworks/rational/library/content/RationalEdge/jun01/MonitoringObjectCreationJune01.pdf
[2] D. Viswanathan, S. Liang "Java Virtual Machine Profiler Interface" - http://www.research.ibm.com/journal/sj/391/viswanathan.html
[3] S. Wilson, J. Kesselman, "Java Platform Performance - Strategies and Tactics" http://java.sun.com/docs/books/performance/1st_edition/html/JPTitle.fm.html
[4] "DevPartner Java Home Page" - http://www.compuware.com/products/devpartner/java.htm
[5] Compuware White Paper, "Temporary Objects - Managing the Java Garbage Collector for application quality and performance" http://www.compuware.com/dl/garbage.pdf

Doriano Gallozzi è nato a Roma nel 1964 e si è laureato in Ingegneria Elettronica (indirizzo Informatica) nel 1989. Da sempre interessato a problematiche di analisi, progettazione e sviluppo di Sistemi Informativi, ha lavorato per diversi anni in aziende del settore informatico italiano (gruppo ENI, gruppo Telecom Italia), dove ha acquisito diverse esperienze tanto nel campo della progettazione e sviluppo del software (in ambiente M/F come in ambiente distribuito) quanto nel campo dei RDBMS (DBA su diversi progetti per clienti finali quali Telecom Italia). Da gennaio 2000 lavora nella Divisione Prevendita di Compuware Italia. La sua attività verte principalmente sulla piattaforma J2EE e tecnologie correlate, ma anche su ambiti tecnologici quali l'Enterprise Application Integration, i Portali Web, gli strumenti di Business Process Modeling.