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.
|