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
- Thread Local Allocation Buffers (TLAB): Ogni thread ha un buffer privato per allocazioni veloci
- Eden Space: Allocazione principale nel young generation
- 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.