Garbage collector no Java: G1, ZGC e como escolher
1. Visão geral
O garbage collector (GC) determina quanto tempo sua aplicação Java para e quanto CPU consome para limpar memória. Se você escolher errado, pode enfrentar latência disparando mesmo que o código não tenha mudado.
Este artigo explica como G1 e ZGC funcionam no Java 21, como ativá-los e ler GC log, como verificar qual GC está rodando por meio de código e testes. Ao final, há critérios para escolher o GC para sua aplicação real.
2. Os GCs disponíveis no Java 21
O HotSpot JDK 21 tem os seguintes GCs principais:
| GC | Flag | Objetivo |
|---|---|---|
| Serial | -XX:+UseSerialGC |
Heap pequeno, 1 CPU, footprint baixo |
| Parallel | -XX:+UseParallelGC |
Throughput máximo, aceita pausas longas |
| G1 | -XX:+UseG1GC |
Equilíbrio entre throughput e pausas |
| ZGC | -XX:+UseZGC |
Pausas ultracurtas, heap grande |
| Shenandoah | -XX:+UseShenandoahGC |
Pausas curtas (apenas em alguns builds) |
Se você não passar nenhuma flag, a JVM escolhe automaticamente. Em máquinas “server-class” (pelo menos 2 CPUs e cerca de 1792 MB de RAM), a JVM usa G1. Caso contrário, usa Serial.
Aviso: Em containers, a JVM lê os limites de CPU e memória do cgroup. Um Pod limitado a 1 CPU será considerado pela JVM como não server-class e rodará silenciosamente Serial GC. Esta é uma causa comum de pausas anormalmente longas em serviços no Kubernetes.
3. Como o G1 funciona
G1 (Garbage-First) divide o heap em múltiplas regiões iguais, geralmente de 1 MB a 32 MB. Cada região tem um papel: Eden, Survivor, Old ou Humongous (para objetos grandes).
O ciclo básico inclui:
- Young GC: interrompe todos os threads da aplicação (stop-the-world), copia objetos vivos de Eden para Survivor ou Old.
- Concurrent marking: quando Old fica cheio até certo limite, G1 marca objetos vivos em paralelo com a aplicação.
- Mixed GC: limpa tanto young quanto as regiões Old com mais lixo primeiro. Este é o significado de “Garbage-First”.
O ponto forte do G1 é que você pode definir um objetivo de pausa:
java -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -Xms2g -Xmx2g -jar order-service.jar
MaxGCPauseMillis=100 solicita que G1 procure manter cada pausa abaixo de 100 ms (padrão é 200 ms). G1 ajusta o tamanho da young generation para atingir esse objetivo. Este é um objetivo flexível, não um compromisso. As pausas do G1 ainda aumentam com o número de objetos vivos que precisam ser copiados.
4. Como o ZGC funciona
O ZGC faz quase tudo pesado (marking, relocation, atualização de referências) em paralelo com a aplicação. Usa colored pointers e load barriers: cada vez que o código lê uma referência, o barrier verifica se o objeto foi movido e corrige a referência se necessário.
O resultado são pausas geralmente abaixo de 1 ms e não aumentam com o tamanho do heap. Um heap de 2 GB ou 2 TB produzem pausas equivalentes.
4.1. Generational ZGC no Java 21
Java 21 introduz Generational ZGC (JEP 439). Esta versão separa young e old generation, permitindo limpeza mais eficiente de objetos de curta duração. No Java 21, você deve ativar uma flag adicional:
java -XX:+UseZGC -XX:+ZGenerational -Xmx4g -jar order-service.jar
Se faltar -XX:+ZGenerational, Java 21 roda ZGC no estilo antigo (non-generational). A partir do JDK 23, generational é o padrão. A partir do JDK 24, o modo non-generational foi removido. Portanto, se usar ZGC no Java 21, ative generational imediatamente.
4.2. O preço a pagar
Pausas curtas têm custo:
- O load barrier desacelera cada leitura de referência, então o throughput é geralmente alguns pontos percentuais mais baixo que G1.
- ZGC precisa de headroom, ou seja, espaço livre no heap. A aplicação ainda aloca durante a execução de GC em paralelo. Se o heap ficar cheio antes do GC terminar, threads ficam bloqueadas (“Allocation Stall”). Nesse ponto, a latência dispara, contrário ao objetivo inicial.
- Threads GC rodam em paralelo, então competem por CPU com a aplicação.
5. Experimentando com um pequeno programa
O programa abaixo cria muitas Order de curta duração e retém algumas para simular um cache:
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); // retém 1% -> vai para old generation
}
if (cache.size() > 20_000) {
cache.subList(0, 10_000).clear(); // libera alguns, cria lixo em old
}
}
System.out.printf("Done in %d ms, cache=%d%n",
System.currentTimeMillis() - start, cache.size());
}
}
Cada iteração cria uma Order com payload de 512 bytes. A maioria dos objetos morre logo, consistente com a hipótese “maioria dos objetos morre jovem”. Cerca de 1% são mantidos em cache tempo suficiente para serem promovidos para old generation. O comando subList(...).clear() ocasionalmente libera alguns deles.
Execute com G1 e ative o log de GC usando unified logging (-Xlog):
java -XX:+UseG1GC -Xmx512m -Xlog:gc OrderLoadSimulator.java
A saída terá algo como:
[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 linha Pause Young mostra o heap antes e depois do GC (25M->3M), o heap total commitado (512M) e o tempo de pausa (2.814ms).
Agora execute com 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
O tempo no final de cada linha ZGC (0.021s) é o tempo total do ciclo de GC, com a maioria rodando em paralelo com a aplicação. Não é o tempo que a aplicação fica parada. Para ver as pausas reais, use -Xlog:gc,gc+phases=debug e procure por linhas Pause Mark Start, Pause Mark End, Pause Relocate Start. Essas pausas normalmente são apenas algumas dezenas de microsegundos.
Os números específicos variarão em sua máquina. A tendência comum é o ZGC completar um pouco mais devagar no tempo total, mas sem pausas longas.
6. Verificando qual GC está rodando por meio de código
Em ambiente de produção, você deve confirmar o GC real em vez de confiar na configuração. GarbageCollectorMXBean lhe diz:
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 um ou mais MXBeans com nomes característicos. Por exemplo, com 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
Com Generational ZGC, os nomes serão ZGC Minor Cycles, ZGC Minor Pauses, ZGC Major Cycles e ZGC Major Pauses.
6.1. Escrever um teste para confirmar a configuração do GC
Você pode bloquear a configuração do GC com um teste, para que ninguém mude inadvertidamente as JVM options. Primeiro, configure o Surefire para executar testes com 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>
Em seguida, escreva um teste JUnit 5 combinado com 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");
}
}
O primeiro teste verifica que todos os collectors pertencem a ZGC. O segundo garante que o modo generational está ativado. Se alguém remover -XX:+ZGenerational, o nome do bean mudará para ZGC Cycles e o teste falhará.
Aviso: Este teste depende das JVM options do processo de teste. Se sua IDE não ler
argLinedo Surefire, o teste falhará quando executado diretamente na IDE.
7. Configurando o GC para uma aplicação Spring Boot
Spring Boot não escolhe o GC automaticamente. Você passa a flag para a JVM de acordo com o método de implantação. Ao rodar localmente com o plugin Maven:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<jvmArguments>-XX:+UseZGC -XX:+ZGenerational -Xmx2g</jvmArguments>
</configuration>
</plugin>
Em um container, a forma mais simples é usar a variável de ambiente JAVA_TOOL_OPTIONS. A JVM lê automaticamente essa variável ao 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 define o heap máximo como 75% da memória do container. O restante é reservado para metaspace, thread stack e direct buffers. Sempre especifique explicitamente -XX:+UseG1GC ou -XX:+UseZGC em vez de deixar a JVM escolher, pela razão mencionada na seção 2.
Se você já tem Spring Boot Actuator e Micrometer, a métrica jvm.gc.pause mostra a distribuição do tempo de pausa por tipo de GC. Monitore o p99 e o máximo dessa métrica antes e depois de trocar de GC.
8. Como escolher o GC
Comece pelos requisitos de sua aplicação, não pelo GC “mais novo”:
Escolha G1 quando:
- Você não tem razão específica para mudar. G1 é a escolha padrão razoável para a maioria dos REST APIs e serviços.
- O heap é de alguns centenas de MB a alguns gigabytes.
- Pausas de dezenas a 200 ms são aceitáveis.
- O CPU é limitado rigidamente e você precisa de bom throughput.
Escolha ZGC (generational) quando:
- Os requisitos de latência p99 ou p999 são muito rigorosos, por exemplo, trading, game servers, APIs com SLA abaixo de 50 ms.
- O heap é grande (dezenas de GB ou mais), quando as pausas de G1 começam a ficar longas devido a muitos objetos vivos.
- Você tem CPU e memória de sobra para criar headroom.
Escolha Parallel quando: você roda batch jobs, ETL, processamento de dados offline. O tempo total é mais importante que as pausas.
Escolha Serial quando: você roda CLIs pequenas, serverless functions com heap abaixo de cerca de 100 MB e 1 CPU.
Aviso: Não use ZGC em containers pequenos (1 CPU, heap 512 MB). Threads GC competirão por CPU com a aplicação, e o pouco headroom pode causar Allocation Stall. O resultado pode ser pior que G1.
Alguns erros comuns ao afinar:
- Copiar muitas flags antigas como
-XX:NewRatioou-XX:SurvivorRatiopara G1. Essas flags interferem com o mecanismo de auto-ajuste para atingirMaxGCPauseMillis. Você deve começar apenas com-Xmxe o objetivo de pausa. - Definir
MaxGCPauseMillismuito baixo, por exemplo 10 ms, para G1. G1 encolherá a young generation, o GC rodará frequentemente e o throughput cairá drasticamente. Se você precisa de pausas dessa magnitude, mude para ZGC. - Trocar de GC para consertar memory leaks. Nenhum GC consegue liberar objetos ainda referenciados. Analise um heap dump primeiro.
- Avaliar com benchmarks de alguns segundos. Meça com carga similar à produção por pelo menos alguns minutos, e compare latência p99, CPU, memória.
9. Conclusão
G1 é a escolha padrão equilibrada, adequada para a maioria das aplicações. ZGC, especialmente a versão generational no Java 21, oferece pausas sub-milissegundos com heaps muito grandes, mas consome mais CPU e requer mais headroom.
Sempre declare explicitamente o GC, ative -Xlog:gc e confirme qual GC está rodando usando GarbageCollectorMXBean ou testes. Mude de GC apenas quando dados de latência p99 mostrarem que a pausa é realmente um problema.