Garbage collector en Java: G1, ZGC y cómo elegir
1. Descripción general
El garbage collector (GC) determina cuánto tiempo se detiene tu aplicación Java y cuánta CPU consume para limpiar la memoria. Si eliges mal, puedes encontrarte con una latencia que se dispara incluso sin cambios en el código.
Este artículo explica cómo funcionan G1 y ZGC en Java 21, cómo habilitarlos y leer registros de GC, cómo verificar qué GC se está ejecutando mediante código y pruebas. Al final hay criterios para elegir el GC para una aplicación real.
2. Los GC disponibles en Java 21
HotSpot JDK 21 tiene los siguientes GC principales:
| GC | Flag | Objetivo |
|---|---|---|
| Serial | -XX:+UseSerialGC |
Heap pequeño, 1 CPU, huella baja |
| Parallel | -XX:+UseParallelGC |
Máximo rendimiento, acepta pausas largas |
| G1 | -XX:+UseG1GC |
Equilibrio entre rendimiento y pausa |
| ZGC | -XX:+UseZGC |
Pausa extremadamente corta, heap grande |
| Shenandoah | -XX:+UseShenandoahGC |
Pausa corta (solo en ciertos builds) |
Si no pasas ningún flag, la JVM elige automáticamente. En una máquina “server-class” (al menos 2 CPU y aproximadamente 1792 MB de RAM), la JVM usa G1. De lo contrario, usa Serial.
Nota: En contenedores, la JVM lee los límites de CPU y memory del cgroup. Un Pod limitado a 1 CPU será tratado por la JVM como si no fuera server-class y ejecutará silenciosamente Serial GC. Esta es una causa común de pausas largas inesperadas en servicios en Kubernetes.
3. Cómo funciona G1
G1 (Garbage-First) divide el heap en múltiples regions del mismo tamaño, generalmente de 1 MB a 32 MB. Cada region tiene un rol específico: Eden, Survivor, Old o Humongous (para objetos grandes).
El ciclo básico consiste en:
- Young GC: detiene todos los threads de la aplicación (stop-the-world), copia objetos vivos desde Eden a Survivor u Old.
- Concurrent marking: cuando Old se llena hasta un umbral, G1 marca objetos vivos en paralelo con la aplicación.
- Mixed GC: limpia tanto young como las regions Old con más basura primero. Este es el significado de “Garbage-First”.
La ventaja de G1 es que puedes establecer un objetivo de pausa:
java -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -Xms2g -Xmx2g -jar order-service.jar
MaxGCPauseMillis=100 solicita a G1 que intente mantener cada pausa por debajo de 100 ms (el valor predeterminado es 200 ms). G1 ajusta el tamaño de young generation para lograr este objetivo. Este es un objetivo flexible, no una garantía. La pausa de G1 sigue aumentando con el número de objetos vivos que necesitan ser copiados.
4. Cómo funciona ZGC
ZGC realiza casi todo el trabajo pesado (marking, relocation, actualización de referencias) en paralelo con la aplicación. Utiliza colored pointers y load barrier: cada vez que el código lee una referencia, el barrier verifica si el objeto se ha movido y corrige la referencia si es necesario.
El resultado es que las pausas son típicamente inferiores a 1 ms y no aumentan según el tamaño del heap. Un heap de 2 GB o 2 TB ofrecen pausas equivalentes.
4.1. Generational ZGC en Java 21
Java 21 introduce Generational ZGC (JEP 439). Esta versión separa young y old generation, lo que permite limpiar objetos de corta vida de manera mucho más eficiente. En Java 21, debes habilitar un flag adicional:
java -XX:+UseZGC -XX:+ZGenerational -Xmx4g -jar order-service.jar
Sin -XX:+ZGenerational, Java 21 ejecuta ZGC de forma antigua (non-generational). A partir de JDK 23, generational es el valor predeterminado. A partir de JDK 24, el modo non-generational se ha eliminado. Por lo tanto, si usas ZGC en Java 21, deberías habilitar generational de inmediato.
4.2. El precio a pagar
Las pausas cortas tienen un costo:
- El load barrier ralentiza cada lectura de referencia, así que el rendimiento suele ser unos pocos puntos porcentuales inferior a G1.
- ZGC necesita headroom, es decir, espacio libre en el heap. La aplicación sigue asignando memoria mientras el GC se ejecuta en paralelo. Si el heap se llena antes de que termine el GC, el thread se bloquea (“Allocation Stall”). Entonces la latencia se dispara, lo opuesto al objetivo inicial.
- Los threads de GC se ejecutan en paralelo, por lo que compiten por CPU con la aplicación.
5. Prueba con un programa pequeño
El siguiente programa crea muchos Order de corta vida y retiene una parte para simular un caché:
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class OrderLoadSimulator {
record Order(String id, String customer, byte[] payload) {}
public static void main(String[] args) {
List<Order> cache = new ArrayList<>();
long start = System.currentTimeMillis();
for (int i = 0; i < 5_000_000; i++) {
Order order = new Order(UUID.randomUUID().toString(), "customer-" + (i % 1000), new byte[512]);
if (i % 100 == 0) {
cache.add(order); // retener 1% -> sube a old generation
}
if (cache.size() > 20_000) {
cache.subList(0, 10_000).clear(); // liberar algunos, crear basura en old
}
}
System.out.printf("Done in %d ms, cache=%d%n",
System.currentTimeMillis() - start, cache.size());
}
}
Cada iteración crea un Order con un payload de 512 bytes. La mayoría de objetos mueren inmediatamente, acorde con la hipótesis “la mayoría de objetos mueren jóvenes”. Aproximadamente el 1% se mantiene en cache lo suficiente como para ser promovido a old generation. El comando subList(...).clear() ocasionalmente los libera.
Ejecutar con G1 y habilitar el log de GC usando unified logging (-Xlog):
java -XX:+UseG1GC -Xmx512m -Xlog:gc OrderLoadSimulator.java
La salida tendrá este aspecto:
[0.008s][info][gc] Using G1
[0.312s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 25M->3M(512M) 2.814ms
[0.401s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 28M->4M(512M) 1.957ms
...
Done in 3120 ms, cache=15000
Cada línea Pause Young indica el heap antes y después del GC (25M->3M), el heap total committed (512M) y el tiempo de pausa (2.814ms).
Ahora ejecutar con Generational ZGC:
java -XX:+UseZGC -XX:+ZGenerational -Xmx512m -Xlog:gc OrderLoadSimulator.java
[0.010s][info][gc] Using The Z Garbage Collector
[0.420s][info][gc] GC(0) Minor Collection (Allocation Rate) 60M(12%)->14M(3%) 0.021s
[0.610s][info][gc] GC(1) Minor Collection (Allocation Rate) 72M(14%)->16M(3%) 0.018s
...
Done in 3350 ms, cache=15000
El tiempo al final de cada línea de ZGC (0.021s) es la duración total del ciclo de GC, la mayoría ejecutándose en paralelo con la aplicación. No es el tiempo que la aplicación estuvo detenida. Para ver la pausa real, usas -Xlog:gc,gc+phases=debug y buscas líneas como Pause Mark Start, Pause Mark End, Pause Relocate Start. Estas pausas típicamente son solo unos pocos microsegundos.
Los números exactos variarán en tu máquina. La tendencia típica es que ZGC complete un poco más lentamente en tiempo total pero sin pausas largas.
6. Verificar qué GC se está ejecutando mediante código
En un entorno de producción, deberías confirmar el GC real en lugar de confiar en la configuración. GarbageCollectorMXBean te lo dice:
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.util.List;
public class GcInspector {
public static List<String> activeCollectors() {
return ManagementFactory.getGarbageCollectorMXBeans().stream()
.map(GarbageCollectorMXBean::getName)
.toList();
}
public static void main(String[] args) {
for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
System.out.printf("%s: count=%d, time=%d ms%n",
gc.getName(), gc.getCollectionCount(), gc.getCollectionTime());
}
}
}
Cada GC registra uno o más MXBeans con un nombre característico. Por ejemplo con G1:
G1 Young Generation: count=0, time=0 ms
G1 Concurrent GC: count=0, time=0 ms
G1 Old Generation: count=0, time=0 ms
Con Generational ZGC, los nombres serán ZGC Minor Cycles, ZGC Minor Pauses, ZGC Major Cycles y ZGC Major Pauses.
6.1. Escribir una prueba para confirmar la configuración de GC
Puedes bloquear la configuración de GC con una prueba, para que nadie cambie accidentalmente las opciones de JVM. Primero, configura Surefire para ejecutar pruebas con ZGC:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<argLine>-XX:+UseZGC -XX:+ZGenerational</argLine>
</configuration>
</plugin>
Luego escribe una prueba JUnit 5 combinada con AssertJ:
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class GcInspectorTest {
@Test
void whenRunningWithZgcFlag_thenZgcCollectorsAreActive() {
var collectors = GcInspector.activeCollectors();
assertThat(collectors)
.isNotEmpty()
.allMatch(name -> name.startsWith("ZGC"));
}
@Test
void whenGenerationalZgc_thenMinorCyclesBeanExists() {
assertThat(GcInspector.activeCollectors())
.contains("ZGC Minor Cycles");
}
}
La primera prueba verifica que todos los collectors sean de ZGC. La segunda prueba asegura que el modo generational esté habilitado. Si alguien borra -XX:+ZGenerational, el nombre del bean cambiará a ZGC Cycles y la prueba fallará.
Nota: Esta prueba depende de las opciones de JVM del proceso de prueba. Si tu IDE no lee
argLinede Surefire, la prueba fallará cuando la ejecutes directamente desde el IDE.
7. Configurar GC para una aplicación Spring Boot
Spring Boot no elige el GC automáticamente. Pasas el flag a la JVM según cómo despliegues. Cuando ejecutas localmente con el plugin Maven:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<jvmArguments>-XX:+UseZGC -XX:+ZGenerational -Xmx2g</jvmArguments>
</configuration>
</plugin>
En contenedores, la forma más sencilla es usar la variable de entorno JAVA_TOOL_OPTIONS. La JVM la lee automáticamente al iniciar:
docker run -e JAVA_TOOL_OPTIONS="-XX:+UseG1GC -XX:MaxRAMPercentage=75 -Xlog:gc*:stdout:time" \
--cpus=2 --memory=2g payment-service:1.0
MaxRAMPercentage=75 establece el heap máximo al 75% de la memoria del contenedor. El resto se reserva para metaspace, thread stack y direct buffer. Siempre especifica claramente -XX:+UseG1GC o -XX:+UseZGC en lugar de dejar que la JVM elija, por las razones mencionadas en la sección 2.
Si ya tienes Spring Boot Actuator y Micrometer, la métrica jvm.gc.pause te muestra la distribución del tiempo de pausa para cada tipo de GC. Monitorea el p99 y max de esta métrica antes y después de cambiar el GC.
8. Cómo elegir un GC
Comienza con los requisitos de tu aplicación, no con el GC “más nuevo”:
Elige G1 cuando:
- Aún no tienes una razón específica para cambiar. G1 es la opción predeterminada razonable para la mayoría de REST API y servicios.
- El heap va desde unos pocos cientos de MB hasta alrededor de decenas de GB.
- Pausas de decenas a 200 ms son aceptables.
- La CPU está muy limitada y necesitas buen rendimiento.
Elige ZGC (generational) cuando:
- Tienes requisitos muy estrictos de latencia p99 o p999, por ejemplo, trading, servidores de juegos, APIs con SLA inferior a 50 ms.
- El heap es grande (decenas de GB o más), cuando las pausas de G1 comienzan a ser largas debido a muchos objetos vivos.
- Tienes CPU y memoria extra para hacer headroom.
Elige Parallel cuando: ejecutas batch jobs, ETL, procesamiento de datos offline. El tiempo total importa más que las pausas.
Elige Serial cuando: ejecutas CLI pequeños, serverless functions con heap por debajo de aproximadamente 100 MB y 1 CPU.
Nota: No uses ZGC en contenedores pequeños (1 CPU, heap 512 MB). Los threads de GC competirán por CPU con la aplicación, y el heap con poco headroom puede causar Allocation Stall. El resultado puede ser peor que G1.
Algunos errores comunes al ajustar:
- Copiar en masa flags antiguos como
-XX:NewRatioo-XX:SurvivorRatioen G1. Estos flags interfieren con el mecanismo de autojuste para lograrMaxGCPauseMillis. Deberías comenzar solo con-Xmxy el objetivo de pausa. - Establecer
MaxGCPauseMillisdemasiado bajo para G1, por ejemplo 10 ms. G1 reducirá drásticamente young generation, el GC se ejecutará frecuentemente y el rendimiento caerá. Si necesitas pausas de ese tamaño, cambia a ZGC. - Cambiar GC para arreglar memory leaks. Ningún GC puede liberar objetos que aún son referenciados. Analiza un heap dump primero.
- Evaluar con benchmarks de unos pocos segundos. Mide con una carga similar a producción durante al menos varios minutos, y compara latencia p99, CPU y memory.
9. Conclusión
G1 es la opción predeterminada equilibrada, adecuada para la mayoría de aplicaciones. ZGC, especialmente la versión generational en Java 21, ofrece pausas inferiores a milisegundos con heaps muy grandes, a cambio de consumir más CPU y requerir más headroom.
Siempre declara el GC explícitamente, habilita -Xlog:gc y confirma qué GC se está ejecutando usando GarbageCollectorMXBean o pruebas. Solo cambia el GC cuando los datos de latencia p99 muestren que las pausas son realmente un problema.