JVM e Garbage Collection in Java

Edoardo Midali
Edoardo Midali

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:

  1. Initial Mark (stop-the-world)
  2. Concurrent Mark
  3. Concurrent Preclean
  4. Remark (stop-the-world)
  5. 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à.