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 argLine do 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:NewRatio ou -XX:SurvivorRatio para G1. Essas flags interferem com o mecanismo de auto-ajuste para atingir MaxGCPauseMillis. Você deve começar apenas com -Xmx e o objetivo de pausa.
  • Definir MaxGCPauseMillis muito 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.

javajvmgarbage-collectorg1zgc