Java Memory Management, con la sua garbage collection integrata, è uno dei migliori risultati del linguaggio. Permette agli sviluppatori di creare nuovi oggetti senza preoccuparsi esplicitamente dell’allocazione e deallocazione della memoria, perché il garbage collector recupera automaticamente la memoria per il riutilizzo. Questo permette uno sviluppo più veloce con meno codice boilerplate, eliminando le perdite di memoria e altri problemi legati alla memoria. Almeno in teoria.
Ironicamente, Java garbage collection sembra funzionare troppo bene, creando e rimuovendo troppi oggetti. La maggior parte dei problemi di gestione della memoria sono risolti, ma spesso al costo di creare seri problemi di prestazioni. Rendere la garbage collection adattabile a tutti i tipi di situazioni ha portato ad un sistema complesso e difficile da ottimizzare. Per capire la garbage collection, dovete prima capire come funziona la gestione della memoria in una Java Virtual Machine (JVM).
Come funziona veramente la Java Garbage Collection
Molte persone pensano che la garbage collection raccolga e scarti gli oggetti morti. In realtà, la Java garbage collection fa il contrario! Gli oggetti vivi vengono tracciati e tutto il resto viene designato come spazzatura. Come vedrete, questo fraintendimento fondamentale può portare a molti problemi di prestazioni.
Iniziamo con l’heap, che è l’area di memoria usata per l’allocazione dinamica. Nella maggior parte delle configurazioni il sistema operativo alloca l’heap in anticipo per essere gestito dalla JVM mentre il programma è in esecuzione. Questo ha un paio di importanti ramificazioni:
- La creazione degli oggetti è più veloce perché la sincronizzazione globale con il sistema operativo non è necessaria per ogni singolo oggetto. Un’allocazione reclama semplicemente una porzione di un array di memoria e sposta il puntatore di offset in avanti (vedi Figura 2.1). L’allocazione successiva inizia da questo offset e rivendica la porzione successiva dell’array.
- Quando un oggetto non è più utilizzato, il garbage collector recupera la memoria sottostante e la riutilizza per le future allocazioni di oggetti. Questo significa che non c’è nessuna cancellazione esplicita e nessuna memoria viene restituita al sistema operativo.
Figura 2.1: I nuovi oggetti sono semplicemente allocati alla fine dell’heap usato.
Tutti gli oggetti sono allocati sull’area heap gestita dalla JVM. Ogni oggetto che lo sviluppatore usa è trattato in questo modo, inclusi gli oggetti di classe, le variabili statiche e persino il codice stesso. Finché un oggetto è referenziato, la JVM lo considera vivo. Una volta che un oggetto non è più referenziato e quindi non è raggiungibile dal codice dell’applicazione, il garbage collector lo rimuove e recupera la memoria inutilizzata. Per quanto questo sembri semplice, solleva una domanda: qual è il primo riferimento nell’albero?
Garbage-Collection Roots-The Source of All Object Trees
Ogni albero di oggetti deve avere uno o più oggetti root. Finché l’applicazione può raggiungere quelle radici, l’intero albero è raggiungibile. Ma quando questi oggetti radice sono considerati raggiungibili? Oggetti speciali chiamati radici di garbage-collection (radici GC; vedi Figura 2.2) sono sempre raggiungibili e lo è anche ogni oggetto che ha una radice di garbage-collection alla propria radice.
Ci sono quattro tipi di radici GC in Java:
- Le variabili locali sono tenute in vita dallo stack di un thread. Questo non è un riferimento virtuale ad un oggetto reale e quindi non è visibile. A tutti gli effetti, le variabili locali sono radici GC.
- I thread Java attivi sono sempre considerati oggetti vivi e sono quindi radici GC. Questo è particolarmente importante per le variabili locali dei thread.
- Le variabili statiche sono referenziate dalle loro classi. Questo fatto le rende di fatto radici GC. Le classi stesse possono essere garbage-collected, il che rimuoverebbe tutte le variabili statiche referenziate. Questo è di particolare importanza quando usiamo application server, contenitori OSGi o caricatori di classi in generale. Discuteremo i problemi correlati nella sezione Problem Patterns.
- I riferimenti JNI sono oggetti Java che il codice nativo ha creato come parte di una chiamata JNI. Gli oggetti così creati sono trattati in modo speciale perché la JVM non sa se è referenziata dal codice nativo o meno. Tali oggetti rappresentano una forma molto speciale di GC root, che esamineremo più in dettaglio nella sezione Problem Patterns più avanti.
Figura 2.2: Le radici GC sono oggetti che sono essi stessi referenziati dalla JVM e quindi impediscono ad ogni altro oggetto di essere raccolto dalla spazzatura.
Pertanto, una semplice applicazione Java ha le seguenti radici GC:
- Variabili locali nel metodo principale
- Il thread principale
- Variabili statiche della classe principale
Marcare e spazzare via la spazzatura
Per determinare quali oggetti non sono più in uso, la JVM esegue ad intermittenza quello che è giustamente chiamato un algoritmo mark-and-sweep. Come si può intuire, è un processo semplice in due fasi:
- L’algoritmo attraversa tutti i riferimenti agli oggetti, a partire dalle radici del GC, e contrassegna ogni oggetto trovato come vivo.
- Tutta la memoria dell’heap che non è occupata da oggetti marcati viene recuperata. Viene semplicemente contrassegnata come libera, essenzialmente spazzata via dagli oggetti inutilizzati.
Garbage collection ha lo scopo di rimuovere la causa delle classiche perdite di memoria: oggetti irraggiungibili ma non cancellati in memoria. Tuttavia, questo funziona solo per le perdite di memoria nel senso originale. È possibile avere oggetti inutilizzati che sono ancora raggiungibili da un’applicazione perché lo sviluppatore ha semplicemente dimenticato di dereferenziarli. Tali oggetti non possono essere garbage-collected. Ancora peggio, una tale perdita di memoria logica non può essere rilevata da nessun software (vedi Figura 2.3). Anche il miglior software di analisi può solo evidenziare gli oggetti sospetti. Esamineremo l’analisi delle perdite di memoria nella sezione Analizzare l’impatto sulle prestazioni dell’utilizzo della memoria e della garbage collection, più avanti.