Java ha fatto passi da gigante nella gestione della memoria, ma scegliere il giusto Garbage Collector (GC) rimane una delle decisioni architetturali più critiche per un’applicazione ad alto traffico. Spesso ci affidiamo al G1 (il default dalle versioni recenti di Java), dando per scontato che sia la scelta ottimale. Ma cosa succede se, in un contesto a microservizi spring boot, spingiamo l’allocazione di memoria al limite assoluto?
Per rispondere a questa domanda, ho creato un endpoint Spring Boot progettato per “inondare” la heap memory, testato poi con k6 che simula 100 utenti concorrenti.
Setup dell'Esperimento
Per stressare i vari Garbage Collector, abbiamo bisogno di un’applicazione che allochi enormi quantità di oggetti a vita breve (short-lived objects). Ecco il controller REST implementato in Spring Boot:
package com.example.demo;
import java.util.ArrayList;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ProvaRest {
@GetMapping("/test")
public String test() {
List<byte[]> garbage = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
garbage.add(new byte[1024 * 50]); // ~50KB
}
return "ok";
}
} Cosa fa questo codice? Ogni volta che l’endpoint /test viene chiamato, crea una lista contenente 1000 array di byte da 50KB ciascuno. Stiamo parlando di circa 50MB di memoria allocata per ogni singola richiesta HTTP. Appena il metodo ritorna "ok", questi 50MB diventano irraggiungibili, trasformandosi immediatamente in garbage (spazzatura) che la JVM deve ripulire.
Per simulare il traffico, abbiamo utilizzato questo script k6:
import http from ‘k6/http’;
import { sleep } from ‘k6’;
export const options = {
vus: 100, // 100 utenti virtuali simultanei
duration: ’60s’, // durata del test
};
export default function () {
http.get(‘http://localhost:8080/test’);
sleep(0.1); // piccola pausa per simulare utenti reali
}
Con 100 utenti virtuali che fanno richieste continue (con soli 100ms di pausa), stiamo chiedendo alla JVM di allocare svariati Gigabyte di memoria al secondo, per un totale di 60 secondi.
Risultati
Abbiamo eseguito il test isolando quattro diversi Garbage Collector: G1 (default), Parallel, Serial e l’innovativo ZGC.
Di seguito i grafici ottenuti da k6 alla fine dei 60 secondi:
Tabella riassuntiva
Analisi Dettagliata dei Risultati
Il G1 è il GC predefinito ed è progettato per bilanciare throughput e latenza. Tuttavia, di fronte a un’allocazione massiva e repentina (50MB * 100 utenti), il G1 è letteralmente collassato. Con un tasso di errore del 43,48% e latenze che hanno sfiorato i 29 secondi, il G1 non è riuscito a svuotare la memoria abbastanza velocemente. Questo ha causato enormi pause Stop-The-World (STW), portando i client in timeout e abbattendo il throughput a misere 24 richieste al secondo.
Il Parallel GC cerca di massimizzare il throughput usando più thread per la garbage collection. È riuscito a gestire 141 req/s, ma ha sofferto di pause significative (P95 di oltre 3 secondi), tipiche dei GC generazionali che fermano l’applicazione per pulire enormi blocchi di memoria. Sorprendentemente, il Serial GC (che usa un solo thread) si è comportato meglio in termini di stabilità (0.10% di errori e 309 req/s). In scenari con heap limitati o configurazioni non ottimizzate, il basso overhead del Serial GC lo rende paradossalmente più resiliente del Parallel quando la memoria viene saturata così ferocemente, anche se a costo di una latenza media di 221 ms.
Lo ZGC (Z Garbage Collector) ha semplicemente distrutto la concorrenza. Progettato espressamente per applicazioni a bassa latenza e heap enormi (da pochi MB a svariati Terabyte), esegue il lavoro pesante in modo concorrente, senza fermare i thread dell’applicazione. I risultati parlano da soli: oltre 900 richieste al secondo elaborando Gigabyte di garbage in tempo reale, con una latenza media di 10 millisecondi. Il P95 a 12ms dimostra che lo ZGC non ha mai messo in pausa l’applicazione in modo percettibile. Il leggero tasso di errore (0.38%) è un effetto collaterale trascurabile dovuto all’enorme volume di traffico HTTP gestito nello stesso lasso di tempo (oltre 54.000 richieste contro le misere 1.500 del G1).
Conclusioni
Il Default non è sempre la scelta giusta: Se la tua applicazione ha pattern di allocazione anomali (es. elaborazione di grandi file, manipolazione massiva di immagini o array di byte), il G1 GC con impostazioni di default potrebbe diventare il tuo peggior collo di bottiglia.
Conosci i tuoi oggetti: L’esperimento dimostra quanto sia costoso creare “spazzatura” a vita breve. Ottimizzare il codice per riutilizzare gli oggetti o usare stream al posto di caricare tutto in memoria è sempre la prima difesa.
Se la latenza è tutto, passa a ZGC: Se stai usando Java 17 o versioni successive e la tua applicazione richiede tempi di risposta garantiti e stabili anche sotto stress da allocazione, attivare ZGC (
-XX:+UseZGC) può letteralmente raddoppiare le performance della tua API senza dover toccare una riga di codice.
