Trilha de Kotlin · parte 32 de 59
Heap, GC e pausas
Aula da fase 5 da trilha de Kotlin: Como G1, ZGC e Shenandoah diferem em pausa e em custo de CPU, e como ler os logs de GC?
avançadoPublicada em · 18 min de leitura
Pense numa cozinha de restaurante com uma única bancada. Cada prato usa uma tigela, e a tigela só volta à prateleira quando ninguém mais precisa dela. Alguém precisa recolher as tigelas abandonadas, e esse alguém é o coletor de lixo (garbage collector, ou GC). Ele pode trabalhar sem parar a cozinha, ou pode pedir silêncio por um instante e recolher tudo de uma vez. A segunda opção é o que chamamos de pausa, e é ela que aparece como um soluço no p99 de um endpoint Spring.
Nem todo coletor escolhe o mesmo equilíbrio. Alguns pausam pouco e gastam mais processador em paralelo. Outros gastam menos processador e pausam mais. O problema é que “baixa latência” é uma promessa de marketing até alguém medir o que a aplicação sente. Esta aula mostra como ler o log do GC, compara cinco coletores disponíveis no JDK 21 com uma carga controlada e separa duas coisas que costumam ser confundidas: a pausa que o coletor impõe e a espera que a aplicação sofre.
o mapa da aula
Começamos pelo heap e pela ideia de gerações, que explica por que o coletor divide a memória em partes. Depois lemos uma linha real do log do GC, para que o formato não fique abstrato. A terceira seção apresenta os coletores e o que a documentação oficial diz sobre cada um. A seção central traz a medição: uma carga sintética, cinco coletores, dois tamanhos de heap e três execuções por configuração. Ela revela uma diferença que muda como se lê a palavra pausa. A seção seguinte explica o caso mais estranho da medição, o Shenandoah que levou 20 segundos. Fechamos com erros comuns e exercícios. A versão do compilador foi o kotlinc 2.1.21 e a JVM foi o Temurin 21.0.8, num Mac com processador arm64.
1. o heap e a ideia das gerações
O heap é a região da memória onde a JVM guarda os objetos que o programa cria. Os objetos são alocados ali, e quando deixam de ser alcançáveis a partir das raízes (variáveis de pilha, campos estáticos e referências ativas), o coletor pode recuperar o espaço. Em um serviço, o heap cresce e encolhe conforme a carga, e o trabalho do GC é manter o espaço livre sem parar o programa por tempo demais.
A maioria dos coletores modernos divide o heap em gerações. A intuição vem de uma observação empírica: a maior parte dos objetos morre pouco depois de nascer, como uma variável de um pedido HTTP que termina em milissegundos, e uma minoria sobrevive por muito tempo, como um cache. Se a memória é separada entre uma região jovem, onde a coleta é frequente e barata, e uma região velha, onde a coleta é rara e mais cara, o coletor gasta seu esforço onde há mais memória para recuperar. A página de tuning do GC da Oracle descreve essa estratégia ao falar de “generational scavenging”, com a finalidade de concentrar o esforço nas áreas do heap que provavelmente têm muita memória recuperável. A formulação “a maioria morre jovem” é a hipótese geracional, que a página consultada não nomeia.
Essa divisão tem consequências para o diagnóstico. Um serviço que cria muitos objetos de vida curta costuma ter coletas jovens frequentes, mas baratas. Um serviço que guarda muitos objetos por muito tempo, como um cache grande, enche a região velha, e as coletas dessa região custam mais. Por isso o mesmo volume de alocação pode produzir perfis de pausa completamente diferentes.
2. o que o log mostra
Antes de comparar coletores, precisamos saber ler o que eles imprimem. Com -Xlog:gc, o JDK escreve uma linha por evento. Uma linha real do G1, de uma das execuções desta aula (os espaços de alinhamento dos tags foram removidos), é esta:
[0.052s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 25M->2M(128M) 0.876ms
Cada campo tem um papel. O primeiro é o instante desde o início da JVM, em segundos. O segundo é o número da coleta, GC(0). Depois vem o tipo da pausa, aqui uma coleta da geração jovem, e a causa, que é a evacuação típica do G1. A expressão 25M->2M(128M) diz quanto havia de heap ocupado antes, quanto ficou depois e qual era a capacidade total naquele momento. O último campo é a duração da pausa, 0.876ms. Esse último número é o que costuma entrar em painéis de latência, mas ele não conta tudo, como veremos na seção 5.
O formato muda entre coletores. No ZGC, por exemplo, as pausas aparecem com outros nomes, como Pause Mark Start, e só no tag gc,phases. Quem filtra o log só pelo tag gc pode concluir que o ZGC não pausa, e está errado, como mostraremos a seguir. Para ver tudo, use -Xlog:gc*=info, que liga todos os tags de GC, com a ressalva de que a mesma coleta pode aparecer em mais de uma linha.
3. os coletores disponíveis
No JDK 21, a documentação de tuning da Oracle afirma que, em máquinas de classe servidor, o G1 é selecionado por padrão. Ele tem como meta pausas pequenas e uniformes com alto throughput. A mesma página diz que, na configuração padrão, o G1 busca um equilíbrio entre throughput e latência, sem priorizar um dos dois ao máximo. O G1 divide o heap em regiões e coleta as mais vantajosas em cada ciclo.
O ZGC é o coletor pensado para pausas curtas. A JEP 439, que o trouxe em forma geracional no JDK 21, fixa como meta que as pausas não passem de 1 milissegundo. Nessa JEP, o modo geracional é opt-in, habilitado com -XX:+ZGenerational, e o modo não geracional continua sendo o padrão do JDK 21 (a JEP diz que a intenção é tornar o geracional padrão em uma versão futura). A JEP 474 tornou o geracional padrão no JDK 23, e a JEP 490 removeu o modo não geracional no JDK 24. Na máquina desta aula, o PrintFlagsFinal do JDK 21 confirmou ZGenerational = false por padrão.
O Shenandoah também é um coletor concorrente, e aparece no JDK 21 com flags próprias, como ShenandoahGCMode e ShenandoahGCHeuristics. Não encontramos, nesta consulta, documentação oficial que descreva seu comportamento de pacing, e a seção 6 mostra o efeito medido, mas não sua explicação oficial.
O Parallel e o Serial são coletores mais simples. O Serial usa uma única thread, e a documentação da Oracle diz que ele costuma ser adequado para a maioria dos aplicativos pequenos, especialmente os que pedem heaps de até cerca de 100 megabytes em processadores modernos. O Parallel usa várias threads para as coletas, mas, como os demais coletores de pausa, para o programa durante a coleta.
4. a medição
Para comparar os coletores, precisamos de uma carga que se comporte como um serviço de alocação intensa, mas que seja simples de reproduzir. O programa abaixo mantém cerca de 20 MB de objetos vivos e cria 20 milhões de blocos de 256 bytes que morrem logo depois. O detalhe que importa é o anel: cada bloco é guardado numa posição de um vetor de 1024 entradas e logo substituído. Sem isso, o compilador pode perceber que o bloco não sai do laço e eliminar a alocação, uma otimização de análise de escape que a aula sobre JIT trata em detalhe. Sem o anel, a mesma carga teve 5 ciclos de coleta com o G1, contra 106 com o anel, porque o bloco de 256 bytes deixou de ser alocado na maior parte das iterações (10,4 bytes por iteração contra 282, medidos com ThreadMXBean). A aula de JIT explica o que o compilador fez com esse bloco.
fun main(args: Array<String>) {
val rodadas = args.getOrNull(0)?.toInt() ?: 3_000_000
val vivos = arrayOfNulls<ByteArray>(20_000)
val anel = arrayOfNulls<ByteArray>(1024)
var soma = 0L
for (rodada in 0 until rodadas) {
val lixo = ByteArray(256)
lixo[0] = rodada.toByte()
anel[rodada and 1023] = lixo
soma += lixo[0]
if (rodada % 100 == 0) vivos[(rodada / 100) % vivos.size] = ByteArray(1024)
}
println("fim soma=$soma vivos=${vivos.count { it != null }} anel=${anel.count { it != null }}")
}
Cada execução foi feita com um heap fixo, -Xmx128m ou -Xmx512m, um dos coletores, e o log -Xlog:gc*=info gravado em arquivo. O tempo real veio do /usr/bin/time -p, com três execuções por configuração. As pausas foram somadas a partir das linhas com tempo no fim, separando o tag gc do tag gc,phases onde era preciso. A tabela resume o resultado. Os números são de uma máquina só, com uma carga sintética, e devem ser lidos como comparação entre coletores nesta carga, não como previsão para um serviço real.
| Coletor | Heap | Tempo real (3 execuções) | Ciclos | Soma das pausas (execução 1) | Maior pausa |
|---|---|---|---|---|---|
| G1 | 128 MB | 0,38 a 0,46 s | 106 | 90 ms em 112 pausas | 4,0 ms |
| ZGC | 128 MB | 0,70 a 0,73 s | 53 | 1,4 ms em 159 pausas | 0,10 ms |
| Shenandoah | 128 MB | 18,4 a 19,4 s | 63 | 13 ms em 252 pausas | 0,34 ms |
| Parallel | 128 MB | 0,51 a 0,53 s | 149 | 98 ms em 149 pausas | 4,9 ms |
| Serial | 128 MB | 0,53 a 0,54 s | 160 | 136 ms em 160 pausas | 21,6 ms |
| G1 | 512 MB | 0,43 a 0,45 s | 20 | 43 ms em 20 pausas | 7,6 ms |
| ZGC | 512 MB | 0,61 a 0,68 s | 13 a 14 | 0,43 ms em 41 pausas | 0,07 ms |
| Shenandoah | 512 MB | 0,63 a 1,52 s | 16 | 5,1 ms em 64 pausas | 0,27 ms |
| Parallel | 512 MB | 0,55 a 0,59 s | 42 | 92 ms em 42 pausas | 7,9 ms |
| Serial | 512 MB | 0,61 a 0,66 s | 39 | 132 ms em 39 pausas | 8,3 ms |
A tabela já mostra algo que a leitura das pausas sozinha esconderia: o ZGC tem as menores pausas e, ainda assim, não é o coletor mais rápido nesta carga em heap pequeno. A figura separa os dois lados dessa história.
A figura compara as mesmas quatro configurações em duas linhas. Na linha de cima está o que o coletor impõe, as pausas stop-the-world. Na de baixo está o que a aplicação sente, as esperas de alocação. Olhe a coluna do ZGC com 128 MB: a pausa soma 1,4 ms, mas a linha de baixo está em rosa, com 53 a 55 esperas que somam de 222 a 226 ms. A leitura correta é que o coletor pausou pouco e, mesmo assim, a aplicação esperou muito, porque a alocação tinha de aguardar memória livre que ainda não existia.
5. pausa não é espera
As linhas que mostram essa espera vêm do próprio ZGC, com o texto Allocation Stall (main), seguido do tempo em milissegundos. Uma espera de alocação acontece quando uma thread da aplicação pede memória e não há espaço livre suficiente, então ela fica parada até que o coletor libere alguma. Essa espera não aparece como pausa stop-the-world no log, e é por isso que ela escapa da soma das pausas. Mesmo assim, do ponto de vista de um endpoint, ela conta como latência.
Nos números desta aula, a diferença de heap muda o quadro. Com 512 MB, o ZGC teve de 9 a 11 esperas por execução, somando de 67 a 74 ms. Com 128 MB, foram de 53 a 55 esperas, somando de 222 a 226 ms. O heap menor deixou menos folga para o coletor trabalhar em paralelo com a aplicação, e a espera apareceu. Esse é o tipo de achado que um gráfico de pausa não mostra, e que só aparece quando se soma o tempo que a aplicação fica parada por memória.
Vale notar a forma de contar. Uma primeira versão da contagem somou todas as linhas que citavam Allocation Stall, e chegou a números inflados, porque o ciclo de GC também registra a causa do ciclo com essa expressão. Na contagem final, usamos apenas as linhas de Allocation Stall (main), que são de fato as threads esperando. Esse tipo de erro de contagem aparece com frequência quando se lê log de GC, e a regra é checar a contagem contra o número de ciclos antes de confiar nela.
6. o caso do Shenandoah
O Shenandoah levou entre 18,4 e 19,4 segundos com 128 MB, contra 0,38 a 0,46 segundo do G1 na mesma carga. Com 512 MB, ele levou de 0,63 a 1,52 segundo, com uma execução bem mais lenta que as outras duas. O log não mostrava ciclos degenerados nem coletas completas (contamos zero ocorrências): os ciclos eram concorrentes, com quatro pausas curtas cada, e o total de pausas somava de 13 a 16 ms. O tempo estava em outro lugar.
O log com o tag gc+ergo mostrava linhas com Pacer for ..., e a flag ShenandoahPacing aparecia no PrintFlagsFinal do JDK 21 como experimental e ligada por padrão, com um atraso máximo configurado por ShenandoahPacingMaxDelay. Esses indícios sugerem que o pacer atrasa as threads que alocam quando o coletor está ficando para trás. Para testar a hipótese, rodamos a mesma carga com o pacing desligado. A flag exige desbloquear as opções experimentais, e o comando foi java -Xmx128m -XX:+UseShenandoahGC -XX:+UnlockExperimentalVMOptions -XX:-ShenandoahPacing. O tempo caiu para 0,62 a 0,65 segundo, na mesma faixa dos outros coletores.
A figura mostra o mecanismo como o log e a medição indicam. A aplicação aloca, o heap enche, o ciclo concorrente é disparado, e o pacer barra a alocação quando o ciclo não acompanha. Esse é o sentido das setas e do bloqueio na aresta. O resultado é apresentado embaixo, com a mesma configuração e uma única diferença, a flag. A descrição do mecanismo é a leitura que fazemos do log e da medição, e não uma explicação oficial.
O cuidado de leitura é grande aqui. Não sabemos, com o material consultado, o quanto desse comportamento é esperado no Shenandoah em produção, e não recomendamos desligar uma flag experimental com base em uma carga sintética. A lição que fica é outra: um coletor pode ser rápido em pausas e ainda assim atrasar a aplicação por um mecanismo que não aparece no painel de pausas. Por isso a medição de tempo real de parede precisa acompanhar a leitura de pausas.
7. como escolher e o que medir
A escolha de coletor começa pela pergunta que o serviço precisa responder. Se o objetivo é processar lotes com alto throughput e pausas toleráveis, o Parallel costuma ser um ponto de partida razoável, e a tabela mostra que ele fica atrás do G1 nesta carga, mas na mesma faixa de meio segundo. Se o objetivo é latência de cauda, e há folga de memória, o ZGC merece teste: com 512 MB suas pausas ficaram abaixo de 0,1 ms, mas em tempo de parede ele ainda foi mais lento que o G1 nesta carga. Se o serviço roda com memória muito apertada, o heap pequeno pode ser o problema que nenhum coletor resolve sozinho.
Em qualquer caso, a medição precisa ser feita com a carga real. Antes de trocar de coletor, registre o p99 de latência, o tempo de CPU e o log de GC em uma janela de carga normal. Depois troque apenas uma coisa de cada vez. Compare também o tempo de espera de alocação quando o log expuser esse dado, porque ele é o que a aplicação sente quando o coletor não consegue acompanhar.
8. erros comuns e boas práticas
O primeiro erro é escolher o coletor pelo nome. “Baixa latência” descreve uma intenção, e a medição mostra o resultado de um coletor numa carga. Quem troca para o ZGC sem medir pode ganhar pausas menores e perder tempo de parede, se o heap estiver apertado.
O segundo erro é olhar só a coluna de pausas. Como a seção 5 mostrou, uma aplicação pode esperar por memória sem que nenhuma pausa stop-the-world apareça. A boa prática é olhar as duas coisas, pausa e espera, e somar o tempo de parede de uma carga fixa.
O terceiro erro é filtrar o log pelo tag errado. O ZGC imprime as pausas no tag gc,phases, e um filtro que olha só o gc faz o coletor parecer sem pausas. A boa prática é usar -Xlog:gc*=info durante a investigação e restringir o filtro depois.
O quarto erro é fixar o heap pequeno por hábito. A tabela mostra que o mesmo coletor se comporta de forma diferente com 128 MB e com 512 MB. Em containers, o limite de memória também entra na conta, e o heap precisa caber nele com folga.
O quinto erro é tratar flags experimentais como ajustes inofensivos. O ShenandoahPacing existe e tem efeito grande nesta carga. Antes de mexer em uma flag experimental, teste com a carga real e documente a versão exata do JDK, porque o comportamento pode mudar entre versões.
Pergunta de entrevistaComo você escolheria um coletor de lixo para um serviço?
Começo pelo requisito: a prioridade é latência ou throughput? Para processamento em lote, o coletor padrão e o foco em throughput costumam bastar. Para serviços que respondem rápido e precisam de pausas curtas, avalio G1 ou ZGC, e meço as pausas com logs de GC e JFR. O coletor é só uma parte: reduzir alocação costuma ter mais efeito. A decisão final vem de dados do próprio serviço, em carga parecida com a de produção.
9. exercícios
- Rode a carga com
-Xmx64me compare o número de ciclos e a soma das pausas no G1 e no Parallel. Explique por que o número de ciclos muda com o tamanho do heap. - Refaça a medição do ZGC com
-Xlog:gc*=infoe conte as esperas deAllocation Stall (main)separadas das linhasGarbage Collection (Allocation Stall). Explique a diferença entre as duas. - Adicione à carga um
HashMapcom 1 milhão de entradas que cresce durante a execução. Meça de novo com G1 e ZGC e observe se a região velha passa a dominar o log.
10. glossário
Heap: região de memória onde a JVM guarda os objetos criados pelo programa.
Coletor de lixo (GC): componente da JVM que recupera a memória de objetos que não são mais alcançáveis.
Geração jovem e geração velha: divisão do heap usada por coletores geracionais, em que objetos recém-criados ficam em uma região que é coletada com frequência.
Pausa stop-the-world: intervalo em que o coletor para as threads da aplicação para executar uma fase da coleta.
Espera de alocação: tempo em que uma thread da aplicação fica parada porque não há memória livre para uma alocação.
Pacing: mecanismo do Shenandoah que atrasa a alocação quando o ciclo concorrente está atrasado. A flag ShenandoahPacing é experimental no JDK 21.
Throughput: quantidade de trabalho útil feita por unidade de tempo, em oposição ao tempo gasto com o próprio coletor.
fontes
Consultadas em 20/10/2026.
Oracle, Java SE 21, Garbage Collection Tuning Guide, seção “Introduction to Garbage Collection Tuning” (generational scavenging e Serial, incluindo a faixa de cerca de 100 MB): https://docs.oracle.com/en/java/javase/21/gctuning/introduction-garbage-collection-tuning.html
Oracle, Java SE 21, Garbage Collection Tuning Guide, seção “General Recommendations for G1” (metas do G1 e seleção de G1 em máquinas de classe servidor): https://docs.oracle.com/en/java/javase/21/gctuning/garbage-first-garbage-collector-tuning.html
A afirmação de que o G1 é selecionado por padrão em máquinas de classe servidor está na seção “Why Does the Choice of Garbage Collector Matter?” da página de introdução. Os limites de memória e de CPU que definem essa classe de máquinas estão na seção de ergonomia, que não conseguimos ler nesta consulta, por isso a aula não os informa.
OpenJDK, JEP 439, Generational ZGC (meta de pausa de até 1 ms, opt-in com -XX:+ZGenerational no JDK 21): https://openjdk.org/jeps/439
OpenJDK, JEP 474, ZGC: Generational Mode by Default (JDK 23): https://openjdk.org/jeps/474
OpenJDK, JEP 490, ZGC: Remove the Non-Generational Mode (JDK 24): https://openjdk.org/jeps/490
Os números desta aula foram medidos em 20/10/2026, com o Temurin 21.0.8 e o kotlinc 2.1.21, em um Mac arm64 (Apple M1 Max, 32 GB). O comando base foi java -Xmx<heap> -XX:+Use<coletor>GC -Xlog:gc*=info:file=<arquivo> -cp gc.jar GcKt 20000000, em que gc.jar foi gerado com kotlinc Gc.kt -include-runtime -d gc.jar e a classe GcKt vem do código da seção 4. O tempo de parede foi medido por /usr/bin/time -p, com três execuções por configuração. A soma das pausas foi feita a partir das linhas com tempo no fim, com o tag gc para os coletores que o imprimem e com o tag gc,phases para o ZGC. A contagem de esperas usa só as linhas Allocation Stall (main).
A primeira versão desta medição usou um arquivo chamado GcKt.kt, e o compilador gerou a classe GcKtKt, que não era a classe que o comando chamava. O nome do arquivo precisa ser Gc.kt para que a classe seja GcKt. Essa é uma armadilha pequena, mas que quebra a execução sem explicação óbvia.
O comportamento de pacing do Shenandoah foi observado com -XX:+UnlockExperimentalVMOptions -XX:-ShenandoahPacing e pela existência da flag no PrintFlagsFinal do JDK 21. Não encontramos documentação oficial que explique o mecanismo, e a aula registra isso como lacuna.
relacionados
- Trilha de Kotlin
- Curriculo de um Desenvolvedor Kotlin
- Bytecode e decompilação
- JIT e análise de escape
- Boxing e alocações escondidas
- Profiling com JFR e startup