JVM e Garbage Collection in Java

La Java Virtual Machine (JVM) e il Garbage Collection (GC) rappresentano il cuore dell’ecosistema Java, fornendo gestione automatica della memoria che libera i programmatori dalla complessità dell’allocazione e deallocazione manuale. Comprendere questi meccanismi è essenziale per sviluppare applicazioni Java performanti e per diagnosticare problemi di memoria.
Architettura della JVM
La JVM è una macchina virtuale che esegue bytecode Java, fornendo un layer di astrazione tra il codice Java e il sistema operativo sottostante. La sua architettura è progettata per garantire portabilità, sicurezza e performance ottimali.
Aree di Memoria della JVM
Heap Memory: Area dove vengono allocati tutti gli oggetti Java. È divisa in generazioni per ottimizzare il garbage collection.
Method Area (Metaspace): Contiene informazioni sulle classi, costanti, metodi statici e bytecode. In Java 8+ è stata sostituita dal Metaspace che utilizza memoria nativa.
Stack Memory: Ogni thread ha il proprio stack che contiene frame di metodi, variabili locali e riferimenti parziali.
PC Register: Contiene l’indirizzo dell’istruzione attualmente in esecuzione per ogni thread.
Native Method Stack: Stack per metodi nativi (JNI).
public class MemoryAreasDemo {
// Memorizzato nel Method Area/Metaspace
private static final String CONSTANT = "Static constant";
private static int staticCounter = 0;
// Memorizzato nell'heap quando l'oggetto viene creato
private String instanceField;
private List<String> instanceList;
public void demonstrateMemoryAreas() {
// Variabili locali nel stack del thread corrente
int localVariable = 42;
String localString = "Local string";
// Oggetti creati nell'heap
List<String> localList = new ArrayList<>();
MyObject obj = new MyObject();
// Il riferimento 'localList' è nello stack
// L'oggetto ArrayList è nell'heap
localList.add("Item"); // La stringa "Item" è nell'heap (o string pool)
processData(localVariable); // Nuovo frame nello stack
}
private void processData(int value) {
// Nuovo frame nello stack con parametri e variabili locali
int processedValue = value * 2;
// Frame viene rimosso quando il metodo termina
}
}
Heap Memory Generations
L’heap è organizzato in generazioni basate sull’osservazione che la maggior parte degli oggetti ha vita breve (weak generational hypothesis).
Young Generation:
- Eden Space: Dove vengono allocati nuovi oggetti
- Survivor Spaces (S0, S1): Per oggetti che sopravvivono a un ciclo GC
Old Generation (Tenured): Per oggetti che sopravvivono a multipli cicli GC nel young generation.
Permanent Generation (Java 7 e precedenti) o Metaspace (Java 8+): Per metadati delle classi.
Garbage Collection Fundamentals
Il Garbage Collection è il processo automatico di recupero della memoria occupata da oggetti non più referenziati. Questo meccanismo elimina memory leak e semplifica la programmazione, ma introduce pause durante l’esecuzione.
Algoritmi di Rilevamento
Reference Counting: Mantiene un contatore di riferimenti per ogni oggetto (non usato dalla JVM per limitazioni con riferimenti circolari).
Tracing GC: Traccia tutti gli oggetti raggiungibili dalle root references (variabili locali, campi statici, JNI references).
Mark and Sweep: Marca oggetti raggiungibili, poi elimina quelli non marcati.
Copying GC: Copia oggetti vivi in una nuova area, lasciando quella vecchia completamente libera.
public class GCBehaviorDemo {
public static void demonstrateGCTriggers() {
List<byte[]> memoryConsumer = new ArrayList<>();
try {
// Alloca memoria fino a triggerare GC
for (int i = 0; i < 1000; i++) {
// Ogni array è 1MB
byte[] largeArray = new byte[1024 * 1024];
memoryConsumer.add(largeArray);
if (i % 100 == 0) {
// Informazioni memoria correnti
Runtime runtime = Runtime.getRuntime();
long totalMemory = runtime.totalMemory();
long freeMemory = runtime.freeMemory();
long usedMemory = totalMemory - freeMemory;
System.out.printf("Iteration %d: Used=%dMB, Free=%dMB, Total=%dMB%n",
i, usedMemory / 1024 / 1024, freeMemory / 1024 / 1024,
totalMemory / 1024 / 1024);
}
}
} catch (OutOfMemoryError e) {
System.out.println("OutOfMemoryError reached");
}
// Rimuovi riferimenti per permettere GC
memoryConsumer.clear();
// Suggerimento per GC (non garantito)
System.gc();
// Attesa per permettere al GC di agire
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
public static void demonstrateGenerationalBehavior() {
// Oggetti short-lived - finiscono nel young generation
for (int i = 0; i < 100000; i++) {
String temp = "Temporary string " + i;
processTemporaryData(temp);
}
// Oggetti long-lived - promocessi al old generation
List<String> longLivedData = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
longLivedData.add("Long lived data " + i);
}
// Conserva riferimento per evitare GC
System.out.println("Long lived data size: " + longLivedData.size());
}
private static void processTemporaryData(String data) {
// Elaborazione che non conserva riferimenti
int hash = data.hashCode();
// data diventa eligible per GC alla fine del metodo
}
}
Algoritmi di Garbage Collection
La JVM offre diversi algoritmi di garbage collection, ognuno ottimizzato per scenari specifici di applicazione.
Serial GC
Algoritmo single-threaded adatto per applicazioni client con heap piccoli. Utilizza mark-and-sweep per old generation e copying per young generation.
Caratteristiche:
- Basso overhead di memoria
- Pause lunghe (stop-the-world)
- Adatto per applicazioni desktop
Attivazione: -XX:+UseSerialGC
Parallel GC (Throughput Collector)
Versione multi-threaded del Serial GC, progettato per massimizzare il throughput dell’applicazione.
Caratteristiche:
- Utilizza tutti i core disponibili per GC
- Pause moderate ma prevedibili
- Default per server-class machines
- Ottimo per applicazioni batch
Configurazione:
-XX:+UseParallelGC
-XX:ParallelGCThreads=4
-XX:MaxGCPauseMillis=200
CMS (Concurrent Mark Sweep)
Collector concorrente che riduce le pause GC eseguendo la maggior parte del lavoro in parallelo con l’applicazione.
Caratteristiche:
- Basse pause GC
- Overhead di CPU durante esecuzione normale
- Problemi di frammentazione
- Deprecated in Java 9+
Fasi di Esecuzione:
- Initial Mark (stop-the-world)
- Concurrent Mark
- Concurrent Preclean
- Remark (stop-the-world)
- Concurrent Sweep
G1 (Garbage First)
Collector low-latency progettato per heap grandi (>4GB) con pause GC predicibili.
Caratteristiche:
- Divide heap in regioni di dimensione fissa
- Raccoglie prima le regioni con più garbage
- Pause target configurabili
- Compattazione incrementale
public class G1GCDemo {
// Configurazione G1GC tipica
/*
* -XX:+UseG1GC
* -XX:MaxGCPauseMillis=100
* -XX:G1HeapRegionSize=32m
* -XX:+G1PrintRegionRememberedSetInfo
*/
public static void simulateG1Workload() {
// Simula allocazioni miste per testare G1
List<Object> longTerm = new ArrayList<>();
Random random = new Random();
for (int i = 0; i < 10000; i++) {
// Allocazioni short-term
for (int j = 0; j < 100; j++) {
byte[] shortTerm = new byte[random.nextInt(1024)];
processShortTermData(shortTerm);
}
// Alcune allocazioni long-term
if (i % 100 == 0) {
byte[] longTermData = new byte[random.nextInt(10240)];
longTerm.add(longTermData);
// Occasionalmente rimuovi vecchi dati
if (longTerm.size() > 500) {
longTerm.remove(0);
}
}
if (i % 1000 == 0) {
System.out.println("Processed " + i + " iterations");
}
}
}
private static void processShortTermData(byte[] data) {
// Simula elaborazione che non conserva riferimenti
int checksum = 0;
for (byte b : data) {
checksum += b;
}
}
}
ZGC e Shenandoah
Collector ultra-low-latency per applicazioni che richiedono pause estremamente brevi.
ZGC Caratteristiche:
- Pause <10ms indipendentemente dalla dimensione heap
- Supporta heap da 8MB a 16TB
- Colored pointers per tracking oggetti
Shenandoah Caratteristiche:
- Concurrent compaction
- Pause brevi e predicibili
- Overhead di memoria moderato
# ZGC Configuration
-XX:+UseZGC
-XX:+UnlockExperimentalVMOptions
# Shenandoah Configuration
-XX:+UseShenandoahGC
-XX:+UnlockExperimentalVMOptions
Memory Leaks e Debugging
Nonostante il GC automatico, le applicazioni Java possono ancora soffrire di memory leak attraverso riferimenti non rilasciati.
Cause Comuni di Memory Leak
Static Collections: Collezioni statiche che crescono indefinitamente.
Listener non rimossi: Event listener che mantengono riferimenti ad oggetti.
ThreadLocal non puliti: Variabili ThreadLocal che non vengono rimosse.
Inner Class References: Inner class che mantengono riferimenti alla outer class.
public class MemoryLeakExamples {
// MEMORY LEAK: Static collection che cresce
private static final List<Object> STATIC_LIST = new ArrayList<>();
public void causeStaticLeak() {
// Questo oggetto non verrà mai raccolto dal GC
STATIC_LIST.add(new LargeObject());
}
// MEMORY LEAK: Inner class con riferimento implicito
public class InnerClassLeak {
private byte[] largeData = new byte[1024 * 1024];
public void doSomething() {
// Questa inner class mantiene riferimento a MemoryLeakExamples.this
}
}
// SOLUZIONE: Static inner class
public static class StaticInnerClass {
private byte[] largeData = new byte[1024 * 1024];
public void doSomething() {
// Nessun riferimento implicito alla outer class
}
}
// MEMORY LEAK: ThreadLocal non pulito
private static final ThreadLocal<ExpensiveObject> THREAD_LOCAL = new ThreadLocal<>();
public void useThreadLocal() {
THREAD_LOCAL.set(new ExpensiveObject());
// PROBLEMA: non chiama THREAD_LOCAL.remove()
}
public void useThreadLocalCorrectly() {
try {
THREAD_LOCAL.set(new ExpensiveObject());
// Usa l'oggetto
} finally {
THREAD_LOCAL.remove(); // IMPORTANTE: cleanup
}
}
// MEMORY LEAK: Event listener non rimosso
public void setupListener() {
EventBus eventBus = EventBus.getInstance();
MyListener listener = new MyListener();
eventBus.addListener(listener);
// PROBLEMA: listener non viene mai rimosso
// SOLUZIONE: rimuovi quando non serve più
// eventBus.removeListener(listener);
}
}
Tools di Monitoring e Debugging
JVisualVM: Tool grafico per monitoraggio heap, thread e performance.
jstat: Command-line tool per statistiche GC.
jmap: Per dump dell’heap e analisi memoria.
Eclipse MAT: Memory Analyzer Tool per analisi dump heap.
# Monitoraggio GC con jstat
jstat -gc -t <pid> 1s
# Dump dell'heap
jmap -dump:format=b,file=heap.hprof <pid>
# Analisi histogram classi
jmap -histo <pid>
# Informazioni generali JVM
jinfo <pid>
GC Tuning e Ottimizzazione
Il tuning del GC è un processo iterativo che richiede comprensione dell’applicazione e monitoraggio delle metriche.
Metriche Chiave
Throughput: Percentuale di tempo speso nell’applicazione vs GC.
Latency: Durata delle pause GC.
Footprint: Quantità di memoria utilizzata.
Allocation Rate: Velocità di allocazione oggetti.
public class GCTuningMetrics {
public static void measureAllocationRate() {
Runtime runtime = Runtime.getRuntime();
long startTime = System.currentTimeMillis();
long startMemory = runtime.totalMemory() - runtime.freeMemory();
// Simula allocazioni intensive
List<byte[]> allocations = new ArrayList<>();
for (int i = 0; i < 10000; i++) {
allocations.add(new byte[1024]); // 1KB per allocazione
}
long endTime = System.currentTimeMillis();
long endMemory = runtime.totalMemory() - runtime.freeMemory();
long duration = endTime - startTime;
long memoryUsed = endMemory - startMemory;
double allocationRate = (double) memoryUsed / duration; // bytes/ms
System.out.printf("Allocation rate: %.2f MB/s%n",
allocationRate * 1000 / (1024 * 1024));
}
// JVM flags per logging GC dettagliato
/*
* -Xloggc:gc.log
* -XX:+PrintGCDetails
* -XX:+PrintGCTimeStamps
* -XX:+PrintGCApplicationStoppedTime
* -XX:+UseG1GC
* -XX:MaxGCPauseMillis=100
*/
}
Strategie di Tuning
Dimensionamento Heap: Bilanciare memoria disponibile e frequenza GC.
Rapporto Generazioni: Ottimizzare dimensioni young/old generation.
Scelta Collector: Basata su requisiti di latency vs throughput.
Parallel Threads: Configurare thread GC in base ai core disponibili.
# Configurazione heap sizing
-Xms4g -Xmx4g # Heap iniziale e massimo
-XX:NewRatio=3 # Old generation 3 volte young generation
-XX:SurvivorRatio=8 # Eden 8 volte survivor space
# G1GC tuning
-XX:+UseG1GC
-XX:MaxGCPauseMillis=100
-XX:G1HeapRegionSize=16m
-XX:G1MixedGCCountTarget=8
# Parallel GC tuning
-XX:+UseParallelGC
-XX:ParallelGCThreads=8
-XX:MaxGCPauseMillis=200
-XX:GCTimeRatio=99
Best Practices
Monitor Continuously: Usa tools di monitoraggio per identificare problemi prima che diventino critici.
Avoid Premature Optimization: Optimizza solo dopo aver identificato bottleneck reali.
Test Different Collectors: Testa diversi collector con il tuo workload specifico.
Minimize Object Creation: Riduci pressure sul GC attraverso object pooling e riutilizzo.
Proper Sizing: Dimensiona heap appropriatamente - né troppo grande né troppo piccolo.
Clean References: Rimuovi riferimenti non necessari per permettere GC tempestivo.
Conclusione
La comprensione della JVM e del Garbage Collection è fondamentale per sviluppare applicazioni Java performanti. Mentre il GC automatico semplifica la gestione della memoria, una conoscenza approfondita dei suoi meccanismi permette di ottimizzare performance, ridurre pause e prevenire memory leak.
La scelta del collector e la sua configurazione dipendono fortemente dai requisiti dell’applicazione: throughput vs latency, dimensione heap, pattern di allocazione. Il monitoring continuo e il tuning iterativo sono essenziali per mantenere performance ottimali in produzione.
Con l’evoluzione della JVM, nuovi collector come ZGC e Shenandoah promettono di ridurre ulteriormente le pause GC, rendendo Java sempre più adatto per applicazioni real-time e ad alta responsività.