Quem Recolhe o Lixo — os Coletores G1 e ZGC

Quem Recolhe o Lixo — os Coletores G1 e ZGC

Como a JVM decide o que é lixo pela alcançabilidade a partir das raízes, a hipótese geracional, as pausas stop-the-world, os coletores Serial, Parallel, G1 e ZGC medidos lado a lado, os objetos gigantes do G1, e como achar vazamentos de memória com o heap dump e o Eclipse MAT.
Java

• • 18 min de leitura

Como a JVM descobre quais objetos ninguém mais usa, por que quase todos os coletores dividem o heap por idade, como os principais coletores funcionam, e como escolher e observar um deles.

"A Máquina por Trás da Máquina" deixou duas perguntas. Como a memória dos objetos que ninguém mais usa é recuperada sem que o programa faça nada? E por que um programa que retinha arrays de 1 MB esgotou um heap de 64 MB com apenas 31 MB de dados? A resposta está no coletor de lixo (garbage collector), uma das peças mais sofisticadas da JVM, e uma das razões de o Java ter substituído o C++ em tantos sistemas: ele elimina uma classe inteira de erros de memória. Nesta aula você vai ver como um coletor decide o que é lixo, por que os coletores modernos separam os objetos por idade, como funcionam os coletores da HotSpot, e como observar o que eles fazem.

O que é lixo

Em C, quem aloca memória com malloc precisa liberá-la com free. Esquecer causa vazamentos. Liberar duas vezes, ou usar depois de liberar, corrompe o programa. Em Java, não existe free: a JVM descobre sozinha quais objetos podem ser descartados.

O critério não é "ninguém vai usar mais", porque isso é impossível de saber. O critério é a alcançabilidade: um objeto está vivo se pode ser alcançado, seguindo referências, a partir de algum ponto de partida garantidamente vivo. Esses pontos de partida são as raízes (GC roots):

  • as variáveis locais e os parâmetros de todos os métodos em execução, nas pilhas de todas as threads;
  • os campos estáticos das classes carregadas;
  • algumas referências internas da JVM, como as mantidas por código nativo.

O coletor parte das raízes, segue todas as referências, e marca cada objeto que encontra. Tudo o que não foi marcado é inalcançável, e portanto lixo, mesmo que os objetos lixo apontem uns para os outros. Dois objetos que se referenciam mutuamente, sem que nenhuma raiz chegue até eles, são recolhidos juntos. Esse detalhe distingue a coleta por alcançabilidade da contagem de referências, a técnica usada, por exemplo, pelo Python, que precisa de um mecanismo extra para ciclos.

Depois de marcar, o coletor recupera o espaço. Pode simplesmente registrar os buracos deixados pelos objetos mortos, e aí o heap fica fragmentado, com espaço livre espalhado em pedaços pequenos. Ou pode compactar, movendo os objetos vivos para ficarem juntos, o que exige atualizar todas as referências a eles, mas deixa o espaço livre num bloco contínuo.

A hipótese geracional

Observando programas reais, os projetistas de coletores notaram um padrão: a maioria dos objetos morre jovem. O String montado para uma mensagem, o iterador de um laço, o record intermediário de uma stream. Quase todos morrem milissegundos depois de nascer. Os que sobrevivem a esse início costumam viver muito: as configurações, os caches, os dados carregados no começo do programa.

Essa hipótese geracional levou a uma organização do heap que quase todos os coletores usam:

  • A geração jovem recebe todos os objetos novos. Ela é coletada com frequência, e cada coleta é rápida, porque a maior parte do que está lá já morreu. O coletor só precisa copiar os poucos sobreviventes para outro lugar e considerar o resto da área inteira livre. O custo é proporcional aos vivos, e não ao tamanho da área.
  • A geração velha recebe os objetos que sobreviveram a algumas coletas jovens. Ela é coletada raramente.

A alocação na geração jovem também é baratíssima. Cada thread recebe um pedaço exclusivo da área jovem, uma TLAB (thread-local allocation buffer), e criar um objeto é só avançar um ponteiro dentro dela, algumas instruções de máquina, sem nenhuma sincronização. Somado à análise de escape da aula anterior, isso explica por que criar objetos pequenos e temporários custa tão pouco em Java.

Pausas, vazão e memória

Todo coletor precisa, em algum momento, garantir que o programa não mude as referências enquanto elas são examinadas. A forma mais simples é parar todas as threads da aplicação durante a coleta, uma pausa chamada stop-the-world. Coletores mais avançados fazem a maior parte do trabalho concorrentemente, enquanto a aplicação roda, e param as threads só por instantes.

Nenhum coletor é o melhor em tudo. Há três objetivos que disputam entre si:

  • Latência: pausas curtas, para que a aplicação sempre responda rápido.
  • Vazão (throughput): a maior fração possível do tempo de CPU dedicada à aplicação, e não ao coletor.
  • Memória: o menor heap que funcione bem.

Um coletor que trabalha concorrentemente reduz as pausas, mas consome CPU em paralelo com a aplicação e costuma precisar de mais memória livre, porque a aplicação continua alocando durante a coleta. Um coletor que para tudo e coleta com força máxima tem a melhor vazão, mas pausas longas. Escolher um coletor é escolher qual desses objetivos importa mais.

Os coletores da HotSpot

O JDK 25 traz quatro coletores principais. Para compará-los, um programa que cria pedidos sem parar durante 5 segundos e mantém sempre os 200 mil mais recentes, uma carga típica de servidor, com muitos objetos de vida curta e um conjunto considerável de vivos:

record Pedido(long id, String cliente, double valor, byte[] detalhes) { }

Deque<Pedido> recentes = new ArrayDeque<>();
long inicio = System.nanoTime();
long criados = 0;
while (System.nanoTime() - inicio < 5_000_000_000L) {
    Pedido p = new Pedido(criados, "cliente-" + (criados % 1000), criados * 1.5, new byte[512]);
    recentes.addLast(p);
    if (recentes.size() > 200_000) {
        recentes.removeFirst();
    }
    criados++;
}

Com -Xmx512m e o log do coletor ligado com -Xlog:gc, numa máquina de 2 núcleos (os números vão variar na sua):

Coletor Opção Pedidos criados Pausas Média Máxima Total em pausas
Serial -XX:+UseSerialGC 8,5 milhões 50 82 ms 176 ms 4,1 s
Parallel -XX:+UseParallelGC 11,1 milhões 114 34 ms 93 ms 3,9 s
G1 -XX:+UseG1GC 12,2 milhões 252 11 ms 66 ms 2,7 s
ZGC -XX:+UseZGC 26,8 milhões 288 0,008 ms 0,057 ms 0,002 s

Cada linha do log do coletor conta uma história:

[0.098s][info][gc] GC(0) Pause Young (Allocation Failure) 26M->25M(90M) 23.812ms

Aos 98 ms, houve a coleta número 0, uma pausa na geração jovem, provocada por falta de espaço para alocar. O heap usado foi de 26 MB para 25 MB, com 90 MB reservados, e a pausa durou quase 24 ms. Aqui a coleta recuperou pouco, porque neste programa muitos objetos sobrevivem à primeira coleta.

Serial

O mais simples: uma única thread faz toda a coleta, com a aplicação parada. É eficiente para heaps pequenos e máquinas com um só processador, e por isso a JVM o escolhia automaticamente em máquinas pequenas. Com um heap de centenas de megabytes, as pausas passam de 100 ms.

Parallel

O mesmo modelo do Serial, com várias threads coletando em paralelo. Ainda para tudo, mas termina mais rápido. Foi o coletor padrão até o Java 8, e continua sendo uma boa escolha para processamento em lote, em que só importa terminar o trabalho no menor tempo total, e uma pausa de meio segundo não incomoda ninguém.

G1: o padrão

O G1 (Garbage-First) é o coletor padrão desde o Java 9. Ele divide o heap em regiões de tamanho igual, de 1 a 32 MB conforme o tamanho do heap, e cada região é, a cada momento, jovem, velha ou livre. Em vez de coletar a geração velha inteira de uma vez, o G1 marca os vivos concorrentemente e depois coleta primeiro as regiões com mais lixo, daí o nome. Ele tenta respeitar uma meta de pausa, de 200 ms por padrão (ajustável com -XX:MaxGCPauseMillis), escolhendo quantas regiões coletar em cada pausa.

O G1 é um meio-termo: pausas bem menores que as do Parallel, e vazão próxima. Para a maioria das aplicações, é a escolha certa sem nenhum ajuste. O Java 27 levou isso adiante: a partir dele, o G1 é o padrão em qualquer máquina. Até o Java 26, em máquinas com um processador ou pouca memória, a JVM ainda escolhia o Serial.

Os objetos gigantes

As regiões explicam a pendência da aula anterior. No G1, um objeto com mais da metade do tamanho de uma região é humongous (gigante): em vez de nascer na geração jovem, ele recebe regiões inteiras e contíguas só para ele. Com um heap de 64 MB, as regiões têm 1 MB. Um array de 1 MB, com 1.048.576 bytes de dados mais o cabeçalho, não cabe numa região, e ocupa duas. Metade do espaço fica desperdiçado. Um teste com -Xmx64m:

arrays de 1 MB:            31
arrays de 1.000.000 bytes: 62

Arrays de 1.000.000 bytes, um pouco menores, cabem numa região cada, e o dobro deles coube no mesmo heap. Com regiões de 4 MB (-XX:G1HeapRegionSize=4m), os arrays de 1 MB deixam de ser gigantes, e 48 couberam. Com o coletor Serial, que não usa regiões, couberam 58. Programas que alocam muitos arrays grandes, como buffers de imagem ou de arquivos, devem conhecer esse comportamento, e o log do G1 marca essas alocações.

ZGC: pausas de microssegundos

O ZGC faz quase todo o trabalho concorrentemente, inclusive mover os objetos, a parte mais difícil. Para isso, usa barreiras de leitura: pequenos trechos de código que o JIT insere em cada leitura de referência e que corrigem, na hora, uma referência que aponte para um objeto já movido. As pausas não dependem do tamanho do heap nem da quantidade de objetos vivos, e ficam abaixo de um milissegundo, mesmo com heaps de terabytes.

No teste, as 288 pausas do ZGC somaram 2,3 milissegundos. O log dele mostra as fases concorrentes e as pausas curtíssimas, visíveis com -Xlog:gc*:

[0.099s][info][gc,phases] GC(0) Y: Pause Mark Start (Major) 0.010ms
[0.121s][info][gc,phases] GC(0) Y: Pause Mark End 0.010ms
[0.121s][info][gc,phases] GC(0) Y: Pause Relocate Start 0.004ms

O ZGC ganhou gerações no Java 21 e, desde o Java 24, só existe a versão geracional. Neste teste ele também processou mais pedidos, mas não tome isso como regra. Numa máquina de dois núcleos, o trabalho concorrente disputa a CPU com a aplicação, e em outras cargas a vazão do ZGC fica abaixo da do G1. O ganho garantido dele é a latência, e o preço é mais CPU e geralmente mais memória.

O OpenJDK também inclui o Shenandoah, um coletor concorrente de baixa latência desenvolvido pela Red Hat, com objetivos parecidos com os do ZGC.

Como escolher

  • Não sabe? Fique com o G1. É o padrão por bons motivos.
  • Latência é crítica, como em APIs que precisam responder sempre em poucos milissegundos, sistemas financeiros e jogos com servidor: experimente o ZGC.
  • Só importa o tempo total, como em processamento em lote e relatórios noturnos: considere o Parallel.
  • Heap pequeno e um processador, como em pequenas ferramentas e alguns contêineres: o Serial pode ser suficiente.

E, antes de trocar de coletor, meça: com o log do coletor e com as ferramentas da próxima aula.

Vazamentos de memória numa linguagem com coletor

O coletor recolhe os objetos inalcançáveis. Um objeto que continua alcançável, mas que o programa nunca mais vai usar, não é lixo para o coletor, e fica na memória para sempre. É o vazamento de memória em Java, e ele é sempre o mesmo padrão: uma referência esquecida.

static final List<byte[]> HISTORICO = new ArrayList<>();

static void processarRequisicao(int n) {
    byte[] dados = new byte[10_000];
    HISTORICO.add(dados);          // ninguém nunca remove
}

Cada requisição acrescenta 10 KB a uma lista estática, uma raiz que vive enquanto o programa viver. Com -Xmx128m, o programa quebrou com OutOfMemoryError depois de 13 mil requisições. Num servidor, isso aparece como um consumo de memória que cresce devagar, dia após dia, até a queda.

Os suspeitos habituais são:

  • coleções estáticas ou de longa duração que só crescem, como caches sem limite e históricos;
  • ouvintes e callbacks registrados e nunca removidos, em que um objeto de vida longa guarda referências a objetos que deveriam ter morrido;
  • referências esquecidas dentro de estruturas, como a posição não anulada do exercício 5 de "Escreva uma Vez para Todo Tipo";
  • objetos guardados em ThreadLocal em threads que nunca terminam, um assunto da Fase 7.

Para investigar, a JVM pode gravar um heap dump, uma fotografia de todos os objetos do heap, no momento do erro:

java -Xmx128m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=dump.hprof Vazamento
java.lang.OutOfMemoryError: Java heap space
Dumping heap to dump.hprof ...
Heap dump file created [135141691 bytes in 0.124 secs]

O arquivo .hprof pode ser aberto em ferramentas como o Eclipse Memory Analyzer (MAT), que mostram quais objetos ocupam mais memória e, principalmente, o caminho de referências da raiz até eles. Neste caso, o caminho levaria direto ao campo HISTORICO. Deixar -XX:+HeapDumpOnOutOfMemoryError ligado em produção é uma prática recomendada: quando o erro acontece, a evidência já está gravada. Cuidado apenas com o espaço em disco e com os dados sensíveis, porque o dump contém todo o conteúdo do heap.

Quando um cache realmente precisa guardar objetos "enquanto houver memória", a biblioteca tem referências especiais, WeakReference e SoftReference, que não impedem a coleta, e a classe WeakHashMap, cujas entradas desaparecem quando a chave deixa de ser alcançável por outros caminhos. Na prática, caches com tamanho máximo e tempo de expiração, oferecidos por bibliotecas como o Caffeine, costumam ser mais previsíveis. E para executar uma ação quando um objeto é recolhido, como liberar um recurso nativo, existe a classe Cleaner. O antigo método finalize está obsoleto e marcado para remoção. Para liberar recursos, o caminho certo continua sendo o try-with-resources de "Recursos que se Fecham Sozinhos", que não depende do coletor.

O log do coletor e o heap dump respondem perguntas específicas. Mas quando um programa está lento e ninguém sabe por quê, é preciso uma visão geral: que métodos consomem mais tempo, o que está alocando tanta memória, onde as threads esperam. E quando alguém propõe uma otimização, é preciso uma forma confiável de medir se ela melhora alguma coisa. A JVM tem ferramentas próprias para as duas coisas: o JDK Flight Recorder, que grava o que acontece dentro dela em produção, e o JMH, que mede corretamente o desempenho de um trecho de código.

Fontes e leituras recomendadas

  • Oracle, "HotSpot Virtual Machine Garbage Collection Tuning Guide" (Java SE 25): https://docs.oracle.com/en/java/javase/25/gctuning/index.html. O guia oficial dos coletores, com a descrição de cada um, as opções e a ergonomia que escolhe o padrão.
  • JEP 439, "Generational ZGC" e JEP 523, "Make G1 the Default Garbage Collector in All Environments": https://openjdk.org/jeps/439 e https://openjdk.org/jeps/523. A chegada das gerações ao ZGC e a mudança do Java 27 que tornou o G1 padrão em qualquer máquina.
  • Richard Jones, Antony Hosking e Eliot Moss, The Garbage Collection Handbook, 2ª edição (CRC Press, 2023): o livro de referência sobre coleta de lixo, com todos os algoritmos e as técnicas de concorrência.
  • Eclipse Memory Analyzer (MAT): https://eclipse.dev/mat/. A ferramenta mais usada para analisar heap dumps e encontrar vazamentos, com o cálculo de retenção e os caminhos até as raízes.
  • Per Lidén e Stefan Karlsson, "ZGC: The Next Generation Low-Latency Garbage Collector": palestras e artigos dos criadores do ZGC, reunidos na página do projeto: https://wiki.openjdk.org/display/zgc.

Exercícios

Exercício 1

Os objetos A e B se referenciam mutuamente: A tem um campo que aponta para B, e B um campo que aponta para A. Nenhuma variável do programa aponta para nenhum dos dois. Eles serão recolhidos? E se um campo estático apontasse para B?

Ver resposta

✓ Resposta: No primeiro caso, os dois são recolhidos. O coletor não conta referências: ele parte das raízes e marca o que alcança. Como nenhuma raiz leva a A ou a B, nenhum dos dois é marcado, e o fato de apontarem um para o outro é irrelevante. No segundo caso, o campo estático é uma raiz. Ela alcança B, e B alcança A pelo seu campo, então os dois continuam vivos. Esse segundo cenário é a estrutura de quase todo vazamento: um objeto de vida longa, alcançável por uma raiz, mantém vivo, por uma cadeia de referências, um grafo inteiro de objetos que deveria ter morrido.

Exercício 2

Explique, com a hipótese geracional, por que uma coleta da geração jovem costuma ser muito mais rápida do que uma coleta completa do heap, mesmo quando a geração jovem é grande.

Ver resposta

✓ Resposta: Uma coleta jovem por cópia só trabalha com os objetos vivos: ela os encontra a partir das raízes (e das referências vindas da geração velha, que o coletor rastreia à parte), copia-os para outro lugar e considera a área jovem inteira livre, sem examinar um por um os objetos mortos. Pela hipótese geracional, quase todos os objetos jovens já morreram quando a coleta acontece, e restam poucos para copiar. Uma geração jovem de 1 GB com 20 MB de sobreviventes custa o trabalho de 20 MB. Uma coleta completa, por outro lado, precisa marcar todos os vivos do heap inteiro, incluindo a geração velha, onde a maioria dos objetos está viva, e às vezes compactá-los. O custo é proporcional a todo o conjunto de dados vivos do programa. O teste da aula mostra o caso difícil: com muitos objetos que sobrevivem à primeira coleta e morrem logo depois, as coletas jovens copiam muito e recuperam pouco, e as pausas crescem.

Exercício 3

Um serviço de API tem uma meta de responder 99% das requisições em até 50 ms, e os logs mostram pausas do G1 de até 120 ms. Proponha dois caminhos para resolver, e diga como verificaria o resultado.

Ver resposta

✓ Resposta: O primeiro caminho é trocar para o ZGC, com -XX:+UseZGC, cujas pausas ficam abaixo de um milissegundo independentemente do heap. Ele consome mais CPU e costuma precisar de um heap um pouco maior, então é preciso verificar se o serviço tem folga de processador e memória. O segundo é reduzir a pressão sobre o coletor: investigar, com as ferramentas da próxima aula, o que está alocando tanta memória, e atacar as fontes principais. Também dá para ajustar o G1, com uma meta de pausa menor (-XX:MaxGCPauseMillis=30) e um heap maior, mas a meta é só um objetivo, e com muitos objetos vivos o G1 pode não conseguir cumpri-la. A verificação tem duas partes: o log do coletor, para confirmar que as pausas diminuíram, e, principalmente, a métrica que importa, que é o percentil 99 do tempo de resposta medido na própria API, sob uma carga realista. Uma melhora nas pausas que não aparece no tempo de resposta significa que o problema estava em outro lugar.

Exercício 4

Com um heap de 64 MB e o G1, couberam 31 arrays de 1 MB e 62 arrays de 1.000.000 bytes. Explique a diferença, e diga por que o coletor Serial não tem o mesmo problema.

Ver resposta

✓ Resposta: Com 64 MB de heap, o G1 usa regiões de 1 MB. Um array de 1 MB tem 1.048.576 bytes de dados mais o cabeçalho do objeto, e não cabe numa região de exatamente 1.048.576 bytes. Como é um objeto gigante, ele precisa de regiões contíguas e exclusivas, e ocupa duas. O restante da segunda região fica desperdiçado, então cada array consome 2 MB do heap. Um array de 1.000.000 bytes cabe numa região, com sobra, e consome 1 MB. Daí a diferença de quase o dobro. O coletor Serial não divide o heap em regiões de tamanho fixo: a geração velha é uma área contínua, e cada array ocupa só o seu tamanho real, com pouco desperdício. Por isso couberam 58, perto do máximo. A lição prática: com o G1, alocações grandes e frequentes merecem atenção, seja ajustando o tamanho das regiões, seja reaproveitando buffers em vez de criar novos.

Exercício 5

Um desenvolvedor sugere chamar System.gc() depois de cada requisição, "para manter a memória limpa". Explique por que isso é uma má ideia.

Ver resposta

✓ Resposta: System.gc() pede à JVM uma coleta completa do heap. Com a maioria dos coletores, isso provoca uma pausa longa, que examina todos os objetos vivos, inclusive os de longa duração que nunca iriam morrer. Fazer isso a cada requisição transformaria as coletas jovens, rápidas e baratas, em coletas completas constantes: o programa ficaria muito mais lento, e a latência de todas as requisições pioraria. O coletor já decide quando coletar com muito mais informação do que o programador: taxa de alocação, ocupação de cada região, metas de pausa. Além disso, a chamada é só um pedido, que pode ser ignorado (com -XX:+DisableExplicitGC, por exemplo). Se a memória parece "suja", isso é o comportamento normal de um heap com coletor, que só trabalha quando precisa. Se ela cresce sem parar, o problema é um vazamento, e a solução é encontrar a referência esquecida, não forçar coletas.

Comentários

Mais em Java

Extensão — A API das Trilhas: REST, Validação, Erros e OpenAPI
Extensão — A API das Trilhas: REST, Validação, Erros e OpenAPI

Nesta aula as trilhas e os relatos ganham uma API HTTP de verdade. Os DTOs são…

Extensão — JavaFX: Cenas, Controles e FXML
Extensão — JavaFX: Cenas, Controles e FXML

Nesta aula a janela de catálogo é refeita em JavaFX. A estrutura fica num…

Do Arquivo Solto ao Projeto — Pacotes, Classpath e JARs
Do Arquivo Solto ao Projeto — Pacotes, Classpath e JARs

Como organizar código Java em pacotes, usar import, compilar com javac -d…