Gestione della Memoria in Java

Edoardo Midali
Edoardo Midali

La gestione della memoria in Java è un sistema sofisticato che combina allocazione automatica, garbage collection e diversi tipi di riferimenti per ottimizzare l’uso delle risorse. Comprendere questi meccanismi è essenziale per sviluppare applicazioni efficienti e diagnosticare problemi di performance legati alla memoria.

Modello di Memoria Java

Il modello di memoria Java definisce come thread diversi interagiscono attraverso la memoria condivisa e garantisce consistency in ambienti multi-thread. Questo modello è fondamentale per comprendere il comportamento delle applicazioni concorrenti.

Heap vs Stack Memory

Stack Memory: Ogni thread ha il proprio stack privato che contiene frame dei metodi, variabili locali e riferimenti. Lo stack è gestito automaticamente e ha dimensione limitata.

Heap Memory: Area condivisa tra tutti i thread dove risiedono gli oggetti. È gestita dal garbage collector e può crescere dinamicamente.

Method Area: Contiene metadati delle classi, bytecode, costanti e variabili statiche. È condivisa tra thread.

public class MemoryLocationDemo {

    // Variabile statica - Method Area
    private static int staticCounter = 0;

    // Variabile di istanza - memorizzata nell'heap con l'oggetto
    private String instanceField;

    public void demonstrateMemoryLocations() {
        // Variabili primitive locali - Stack
        int localInt = 42;
        boolean localBoolean = true;

        // Riferimenti locali - Stack, oggetti - Heap
        String localString = new String("Hello");
        List<Integer> localList = new ArrayList<>();

        // Array - riferimento nello Stack, array nell'Heap
        int[] localArray = new int[10];

        // Chiamata a metodo - nuovo frame nello Stack
        processData(localInt, localString);

        // Al termine del metodo, tutte le variabili locali
        // vengono rimosse dallo Stack automaticamente
    }

    private void processData(int value, String text) {
        // Nuovo frame nello Stack con parametri
        String processed = text.toUpperCase();

        // 'processed' è un nuovo oggetto nell'Heap
        // il riferimento è nello Stack di questo frame
    }
}

Memory Visibility e Synchronization

Il modello di memoria Java garantisce che le modifiche a variabili condivise siano visibili tra thread in modo predicibile.

public class MemoryVisibilityDemo {

    private volatile boolean flag = false; // volatile garantisce visibility
    private int counter = 0;

    // Senza volatile, questo potrebbe non terminare mai
    public void volatileExample() {
        Thread writer = new Thread(() -> {
            try {
                Thread.sleep(1000);
                flag = true; // Scrittura volatile
                System.out.println("Flag set to true");
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        Thread reader = new Thread(() -> {
            while (!flag) { // Lettura volatile
                // Busy wait
            }
            System.out.println("Flag detected as true");
        });

        writer.start();
        reader.start();
    }

    // Synchronization per memory consistency
    public synchronized void incrementCounter() {
        counter++; // Garantisce visibility attraverso synchronization
    }

    public synchronized int getCounter() {
        return counter;
    }
}

Allocazione di Oggetti

L’allocazione di oggetti in Java segue pattern specifici ottimizzati per performance e gestione automatica della memoria.

Process di Allocazione

  1. Thread Local Allocation Buffers (TLAB): Ogni thread ha un buffer privato per allocazioni veloci
  2. Eden Space: Allocazione principale nel young generation
  3. Large Object Allocation: Oggetti grandi vanno direttamente in old generation
public class ObjectAllocationDemo {

    public static void demonstrateAllocationPatterns() {
        // Allocazioni small object - tipicamente in TLAB
        for (int i = 0; i < 1000; i++) {
            String small = new String("Small object " + i);
            processSmallObject(small);
        }

        // Large object allocation - potrebbe bypassare young generation
        byte[] largeArray = new byte[1024 * 1024]; // 1MB array

        // Array multidimensionali - allocazioni multiple
        int[][] matrix = new int[100][100]; // 100 array separati + 1 array di riferimenti

        // String interning - comportamento speciale
        String literal = "Literal string"; // String pool
        String constructed = new String("Literal string"); // Heap separato
        String interned = constructed.intern(); // Riferimento al pool

        System.out.println("Literal == interned: " + (literal == interned)); // true
        System.out.println("Constructed == interned: " + (constructed == interned)); // false
    }

    private static void processSmallObject(String obj) {
        // Elaborazione che non mantiene riferimenti
        int hash = obj.hashCode();
        // obj diventa eligible per GC al termine del metodo
    }

    // Esempio di escape analysis
    public String createLocalObject() {
        StringBuilder sb = new StringBuilder();
        sb.append("Local");
        sb.append(" object");
        return sb.toString(); // sb "escapes" - allocato nell'heap
    }

    public void processLocalObject() {
        StringBuilder sb = new StringBuilder();
        sb.append("Processing");
        String result = sb.toString();
        System.out.println(result);
        // sb non "escapes" - potrebbe essere ottimizzato su stack
    }
}

Escape Analysis

La JVM analizza se oggetti “escono” dal loro scope locale per decidere dove allocarli.

public class EscapeAnalysisDemo {

    // Stack allocation candidate - oggetto non escapes
    public void localProcessing() {
        Point p = new Point(10, 20); // Potrebbe essere allocato su stack
        int distance = p.calculateDistance();
        System.out.println("Distance: " + distance);
        // p non è mai restituito o assegnato a campi
    }

    // Heap allocation - oggetto escapes
    public Point createPoint() {
        Point p = new Point(5, 15); // Deve essere allocato nell'heap
        return p; // Escapes attraverso return
    }

    private Point classField;

    // Heap allocation - oggetto escapes
    public void assignToField() {
        Point p = new Point(1, 1);
        this.classField = p; // Escapes attraverso assignment a campo
    }
}

class Point {
    private int x, y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int calculateDistance() {
        return (int) Math.sqrt(x * x + y * y);
    }
}

Reference Types

Java fornisce diversi tipi di riferimenti che influenzano il comportamento del garbage collector e permettono implementazioni sofisticate di cache e resource management.

Strong References

I riferimenti normali che prevengono la garbage collection finché il riferimento esiste.

public class StrongReferenceDemo {

    public void demonstrateStrongReferences() {
        // Strong reference normale
        List<String> strongList = new ArrayList<>();
        strongList.add("This object won't be GC'd");

        // Anche con System.gc(), l'oggetto rimane
        System.gc();

        // L'oggetto è eligible per GC solo quando:
        strongList = null; // Riferimento rimosso
        // oppure quando strongList esce dallo scope
    }

    // Memory leak attraverso strong references
    private static final Map<String, ExpensiveObject> CACHE = new HashMap<>();

    public ExpensiveObject getCachedObject(String key) {
        ExpensiveObject obj = CACHE.get(key);
        if (obj == null) {
            obj = new ExpensiveObject(key);
            CACHE.put(key, obj); // Strong reference - mai rimossa automaticamente
        }
        return obj;
    }
}

class ExpensiveObject {
    private final String key;
    private final byte[] data = new byte[1024 * 1024]; // 1MB per oggetto

    public ExpensiveObject(String key) {
        this.key = key;
    }
}

Weak References

Permettono al garbage collector di raccogliere oggetti anche quando il riferimento esiste ancora.

import java.lang.ref.WeakReference;
import java.lang.ref.ReferenceQueue;

public class WeakReferenceDemo {

    public void demonstrateWeakReferences() {
        // Oggetto con strong reference
        ExpensiveObject strongObj = new ExpensiveObject("strong");

        // Weak reference allo stesso oggetto
        WeakReference<ExpensiveObject> weakRef = new WeakReference<>(strongObj);

        System.out.println("Strong ref exists: " + (strongObj != null));
        System.out.println("Weak ref get(): " + (weakRef.get() != null));

        // Rimuovi strong reference
        strongObj = null;

        // Suggerisci GC
        System.gc();

        // Il weak reference potrebbe ora restituire null
        System.out.println("After GC - Weak ref get(): " + (weakRef.get() != null));
    }

    // Cache con weak references - auto-cleanup
    private final Map<String, WeakReference<ExpensiveObject>> weakCache = new HashMap<>();

    public ExpensiveObject getWeakCachedObject(String key) {
        WeakReference<ExpensiveObject> ref = weakCache.get(key);
        ExpensiveObject obj = (ref != null) ? ref.get() : null;

        if (obj == null) {
            obj = new ExpensiveObject(key);
            weakCache.put(key, new WeakReference<>(obj));
        }

        return obj;
    }

    // Cleanup periodico per rimuovere dead references
    public void cleanupWeakCache() {
        weakCache.entrySet().removeIf(entry -> entry.getValue().get() == null);
    }
}

Reference Queues

Permettono di ricevere notifiche quando riferimenti deboli vengono raccolti dal GC.

public class ReferenceQueueDemo {

    private final ReferenceQueue<ExpensiveObject> referenceQueue = new ReferenceQueue<>();
    private final Map<WeakReference<ExpensiveObject>, String> referenceToKey = new HashMap<>();

    public void demonstrateReferenceQueue() {
        // Crea oggetti con weak references monitorate
        for (int i = 0; i < 10; i++) {
            ExpensiveObject obj = new ExpensiveObject("Object " + i);
            WeakReference<ExpensiveObject> ref = new WeakReference<>(obj, referenceQueue);
            referenceToKey.put(ref, "Object " + i);
        }

        // Forza GC
        System.gc();

        // Processa reference queue
        Thread cleanupThread = new Thread(this::processReferenceQueue);
        cleanupThread.setDaemon(true);
        cleanupThread.start();

        try {
            Thread.sleep(2000); // Attende cleanup
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    private void processReferenceQueue() {
        try {
            while (true) {
                // Blocca fino a quando una reference è disponibile
                WeakReference<?> ref = (WeakReference<?>) referenceQueue.remove();

                // Cleanup associato alla reference
                String key = referenceToKey.remove(ref);
                if (key != null) {
                    System.out.println("Cleaned up reference for: " + key);
                    // Esegui cleanup aggiuntivo se necessario
                }
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

Soft e Phantom References

Soft References: Raccolte solo quando la memoria è scarsa - ideali per cache.

Phantom References: Permettono cleanup post-finalization senza resurrection dell’oggetto.

import java.lang.ref.SoftReference;
import java.lang.ref.PhantomReference;

public class AdvancedReferencesDemo {

    // Cache con soft references - liberate solo in caso di memory pressure
    private final Map<String, SoftReference<CachedData>> softCache = new HashMap<>();

    public CachedData getSoftCachedData(String key) {
        SoftReference<CachedData> ref = softCache.get(key);
        CachedData data = (ref != null) ? ref.get() : null;

        if (data == null) {
            data = loadExpensiveData(key);
            softCache.put(key, new SoftReference<>(data));
        }

        return data;
    }

    // Phantom references per cleanup post-GC
    private final ReferenceQueue<FileResource> phantomQueue = new ReferenceQueue<>();
    private final Set<PhantomReference<FileResource>> phantomRefs = new HashSet<>();

    public FileResource createFileResource(String filename) {
        FileResource resource = new FileResource(filename);

        // PhantomReference per cleanup dopo GC
        PhantomReference<FileResource> phantomRef = new PhantomReference<>(resource, phantomQueue);
        phantomRefs.add(phantomRef);

        return resource;
    }

    public void startPhantomCleanup() {
        Thread phantomCleanupThread = new Thread(() -> {
            try {
                while (true) {
                    PhantomReference<?> ref = (PhantomReference<?>) phantomQueue.remove();

                    // Esegui cleanup che non poteva essere fatto in finalize()
                    performPhantomCleanup(ref);
                    phantomRefs.remove(ref);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        phantomCleanupThread.setDaemon(true);
        phantomCleanupThread.start();
    }

    private CachedData loadExpensiveData(String key) {
        // Simula caricamento dati costoso
        return new CachedData(key);
    }

    private void performPhantomCleanup(PhantomReference<?> ref) {
        System.out.println("Performing phantom cleanup for: " + ref);
        // Cleanup di risorse native, file handles, etc.
    }
}

class CachedData {
    private final String key;
    private final byte[] data = new byte[10240]; // 10KB di dati

    public CachedData(String key) {
        this.key = key;
    }
}

class FileResource {
    private final String filename;

    public FileResource(String filename) {
        this.filename = filename;
        // Apri file handle
    }

    @Override
    protected void finalize() throws Throwable {
        // Cleanup in finalize - non sempre affidabile
        System.out.println("Finalizing FileResource: " + filename);
        super.finalize();
    }
}

Memory Pools e Monitoring

La JVM fornisce diversi memory pool specializzati che possono essere monitorati per ottimizzare performance.

Types of Memory Pools

Eden Space: Per nuove allocazioni Survivor Spaces: Per oggetti che sopravvivono a un GC cycle Old Generation: Per oggetti long-lived Metaspace: Per metadati delle classi Code Cache: Per codice compilato JIT

import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.management.MemoryPoolMXBean;
import java.lang.management.MemoryUsage;

public class MemoryMonitoringDemo {

    public static void monitorMemoryPools() {
        MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean();

        // Memoria heap generale
        MemoryUsage heapUsage = memoryBean.getHeapMemoryUsage();
        System.out.println("Heap Usage:");
        printMemoryUsage(heapUsage);

        // Memoria non-heap (Metaspace, Code Cache, etc.)
        MemoryUsage nonHeapUsage = memoryBean.getNonHeapMemoryUsage();
        System.out.println("\nNon-Heap Usage:");
        printMemoryUsage(nonHeapUsage);

        // Memory pools individuali
        System.out.println("\nIndividual Memory Pools:");
        for (MemoryPoolMXBean poolBean : ManagementFactory.getMemoryPoolMXBeans()) {
            MemoryUsage usage = poolBean.getUsage();
            System.out.println(poolBean.getName() + ":");
            printMemoryUsage(usage);
            System.out.println();
        }
    }

    private static void printMemoryUsage(MemoryUsage usage) {
        long used = usage.getUsed();
        long committed = usage.getCommitted();
        long max = usage.getMax();

        System.out.printf("  Used: %d MB%n", used / 1024 / 1024);
        System.out.printf("  Committed: %d MB%n", committed / 1024 / 1024);
        System.out.printf("  Max: %s MB%n", max == -1 ? "unlimited" : max / 1024 / 1024);
        System.out.printf("  Usage: %.2f%%%n", (double) used / committed * 100);
    }

    // Memory threshold notifications
    public static void setupMemoryAlerts() {
        for (MemoryPoolMXBean poolBean : ManagementFactory.getMemoryPoolMXBeans()) {
            if (poolBean.isUsageThresholdSupported()) {
                // Imposta threshold al 80% della memoria committed
                long threshold = (long) (poolBean.getUsage().getCommitted() * 0.8);
                poolBean.setUsageThreshold(threshold);

                System.out.println("Set threshold for " + poolBean.getName() +
                                 " at " + threshold / 1024 / 1024 + " MB");
            }
        }
    }
}

Ottimizzazioni di Memoria

Object Pooling

Per oggetti costosi da creare, il pooling può ridurre pressure sul GC.

public class ObjectPoolDemo {

    public static class ExpensiveObjectPool {
        private final Queue<ExpensiveObject> pool = new ConcurrentLinkedQueue<>();
        private final AtomicInteger currentSize = new AtomicInteger(0);
        private final int maxSize;

        public ExpensiveObjectPool(int maxSize) {
            this.maxSize = maxSize;
        }

        public ExpensiveObject acquire() {
            ExpensiveObject obj = pool.poll();
            if (obj == null) {
                obj = new ExpensiveObject("pooled");
            } else {
                currentSize.decrementAndGet();
            }
            return obj;
        }

        public void release(ExpensiveObject obj) {
            if (currentSize.get() < maxSize) {
                obj.reset(); // Resetta stato per riutilizzo
                pool.offer(obj);
                currentSize.incrementAndGet();
            }
            // Se pool è pieno, lascia che GC raccolga l'oggetto
        }

        public int getPoolSize() {
            return currentSize.get();
        }
    }

    public static void demonstrateObjectPooling() {
        ExpensiveObjectPool pool = new ExpensiveObjectPool(10);

        // Usa oggetti dal pool
        for (int i = 0; i < 100; i++) {
            ExpensiveObject obj = pool.acquire();

            // Usa l'oggetto
            obj.doWork();

            // Rilascia nel pool per riutilizzo
            pool.release(obj);

            if (i % 10 == 0) {
                System.out.println("Pool size: " + pool.getPoolSize());
            }
        }
    }
}

Flyweight Pattern

Per oggetti con stato intrinseco condiviso e stato estrinseco variabile.

public class FlyweightDemo {

    // Flyweight per caratteri con formattazione condivisa
    public static class CharacterFlyweight {
        private final char character;
        private final String font;
        private final int size;

        public CharacterFlyweight(char character, String font, int size) {
            this.character = character;
            this.font = font;
            this.size = size;
        }

        // Stato estrinseco passato come parametro
        public void render(int x, int y, String color) {
            System.out.printf("Rendering '%c' at (%d,%d) in %s, font=%s, size=%d%n",
                character, x, y, color, font, size);
        }
    }

    // Factory per gestire flyweight instances
    public static class CharacterFlyweightFactory {
        private static final Map<String, CharacterFlyweight> flyweights = new HashMap<>();

        public static CharacterFlyweight getFlyweight(char character, String font, int size) {
            String key = character + "_" + font + "_" + size;

            return flyweights.computeIfAbsent(key,
                k -> new CharacterFlyweight(character, font, size));
        }

        public static int getFlyweightCount() {
            return flyweights.size();
        }
    }

    public static void demonstrateFlyweight() {
        String text = "Hello World Hello World";
        String font = "Arial";
        int size = 12;

        // Senza flyweight: un oggetto per ogni carattere
        // Con flyweight: un oggetto per ogni carattere unico + font + size

        for (int i = 0; i < text.length(); i++) {
            if (text.charAt(i) != ' ') {
                CharacterFlyweight flyweight = CharacterFlyweightFactory
                    .getFlyweight(text.charAt(i), font, size);

                flyweight.render(i * 10, 0, "black");
            }
        }

        System.out.println("Total flyweight instances: " +
                          CharacterFlyweightFactory.getFlyweightCount());
    }
}

Best Practices

Minimize Object Creation: Riutilizza oggetti quando possibile, specialmente in hot paths.

Use Appropriate Reference Types: Soft references per cache, weak references per mappings reversibili.

Monitor Memory Usage: Usa profiling tools per identificare memory hotspots.

Avoid Memory Leaks: Rimuovi listeners, clear collections, cleanup ThreadLocal.

Optimize Data Structures: Scegli strutture dati appropriate per il caso d’uso.

Consider Object Pooling: Per oggetti costosi da creare in scenari high-throughput.

Conclusione

La gestione della memoria in Java è un sistema complesso che bilancia automazione e controllo. Comprendere i diversi tipi di riferimenti, i pattern di allocazione e le strategie di ottimizzazione è essenziale per sviluppare applicazioni robuste e performanti.

L’evoluzione continua della JVM introduce nuove ottimizzazioni e meccanismi di gestione memoria, ma i principi fondamentali rimangono stabili. Una gestione attenta della memoria, combinata con monitoring appropriato, permette di sfruttare appieno le capacità della piattaforma Java.