Salta al contenuto
Java

Gestione della Memoria in Java

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.

Contenuto redatto con l'assistenza di strumenti di intelligenza artificiale e rivisto dalla redazione di Codegrind, che ne è responsabile. Hai trovato un errore? Scrivici.