G1GC vira o coletor padrao no JDK 27 (JEP 523) - pod de 1 CPU no Kubernetes sai do SerialGC - Meu Universo Nerd

Tem uma melhoria de garbage collector chegando que você ganha só de subir a versão. No JDK 27 o G1GC passa a ser o coletor padrão em todo ambiente, inclusive no seu container de 1 CPU que hoje roda SerialGC sem ninguém ter pedido. Sem flag nova, sem refatorar uma linha de código de negócio.

E o melhor: dá pra provar a diferença em uma linha de comando. Nesse artigo você vai entender por que tantos pods Java estão silenciosamente no SerialGC hoje, o que o JEP 523 muda na prática e como antecipar esse ganho antes mesmo do GA do JDK 27, marcado pra 14 de setembro de 2026.

A sexta-feira em que o pod escolheu o GC errado sozinho

Deixa eu te contar uma cena que talvez seja familiar. Sexta à noite, deploy tranquilo, e de repente o p99 de latência do serviço dobrou sob carga. O time olhou pro banco. Olhou pro Kafka. Olhou pra rede. Tudo verde. O culpado estava escondido num lugar que quase ninguém audita: o garbage collector que a JVM escolheu sozinha lá no boot do container.

O pod tinha resources.limits.cpu: "1" no manifesto do Kubernetes. Parecia inofensivo. Só que com 1 CPU a JVM decidiu que aquela máquina não era "boa o bastante" pra um coletor sofisticado e caiu no SerialGC, um coletor single-thread que para o mundo inteiro pra limpar o heap. Sob carga, isso vira pausa de GC de centenas de milissegundos. O gráfico de latência conta o resto da história.

Como Tech Leader, já perdi mais de uma madrugada caçando esse tipo de fantasma. E o detalhe que mais incomoda é que ninguém configurou aquilo. Foi a ergonomia automática da JVM, uma herança de 20 anos atrás, decidindo por você. Bora entender de onde vem essa decisão e por que o JDK 27 finalmente aposenta ela.

Por que seu container cai no SerialGC (a herança do "server-class machine")

Desde o J2SE 5.0, lá em 2004, a HotSpot tem uma heurística chamada ergonomia de máquina server-class. A ideia era boa pra época: detectar automaticamente se a máquina é "grande" e, se for, ligar configurações mais agressivas de heap e de GC. O critério é simples e cruel: a máquina é considerada server-class se tiver 2 ou mais processadores E 2 GB ou mais de memória física. Se passar nesse corte, a JVM escolhe o coletor paralelo/G1. Se não passar, cai no SerialGC.

Em 2004 isso fazia todo sentido. O problema é que ninguém rodava Java dentro de um container com cpu.limits de 1 naquela época. Hoje, com UseContainerSupport ligado por padrão, a JVM lê a cota de CPU do cgroup pra calcular quantos processadores ela "enxerga". Se você limita o pod a 1 CPU, a JVM enxerga 1 processador, não passa no corte de server-class, e seleciona o SerialGC. Mesmo que o pod tenha 4 GB de RAM. O gatilho que pega quase todo mundo no Kubernetes é a CPU, não a memória.

Quer ver acontecendo na sua frente? Roda isso dentro de um container com limite de 1 CPU usando o JDK 26 ou anterior:

# Dentro de um pod com resources.limits.cpu: "1" (JDK 26)
$ java -XX:+PrintCommandLineFlags -version

-XX:InitialHeapSize=33554432 -XX:MaxHeapSize=536870912 -XX:+UseSerialGC
openjdk version "26" 2026-03-17

Olha lá o -XX:+UseSerialGC no fim da linha. A JVM não te avisou, não logou um warning amigável, não mandou e-mail. Ela só decidiu. E o pior tipo de bug de produção é exatamente esse: o silencioso, que só aparece quando a carga chega. Se você quiser confirmar o coletor já em runtime, dentro da aplicação, dá pra ler isso de forma programática:

import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;

public class GcCheck {
    public static void main(String[] args) {
        for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
            System.out.println("GC ativo: " + gc.getName());
        }
    }
}
// Com SerialGC imprime: "Copy" e "MarkSweepCompact"
// Com G1 imprime: "G1 Young Generation" e "G1 Old Generation"

Se o seu serviço de produção imprime MarkSweepCompact, você está rodando um full GC stop-the-world single-thread cada vez que o old gen enche. Pra um batch curto, tudo bem. Pra uma API sob carga, é dor de cabeça garantida. A fonte oficial dessa heurística está na documentação de ergonomia de GC da Oracle, que vale a leitura pra entender o histórico.

JEP 523: o G1 vira default em todo lugar no JDK 27

O JEP 523 (Make G1 the Default Garbage Collector in All Environments) resolve isso de forma direta: a partir do JDK 27, o G1 passa a ser o coletor padrão independentemente da detecção de server-class. Acabou a história de pod pequeno cair no SerialGC por acidente. O JEP entrou junto com o JDK 27 no Rampdown Phase 1, em 4 de junho de 2026, e o GA está marcado pra 14 de setembro de 2026.

A motivação oficial é exatamente o cenário da nossa war story: containers com 1 CPU viraram regra, não exceção, e a ergonomia dos anos 2000 ficou desalinhada com a realidade do Kubernetes. O G1 entrega pausas previsíveis e trabalho concorrente, sendo um default muito mais seguro pra um serviço do que o SerialGC. Repara que a mudança é só do padrão: se você realmente quer SerialGC (um sidecar minúsculo, um job CLI), ele continua a um -XX:+UseSerialGC de distância.

Na prática, o antes e depois fica assim no mesmo pod de 1 CPU:

# JDK 26 e anteriores, container 1 CPU
$ java -XX:+PrintCommandLineFlags -version | grep -o 'Use.*GC'
-XX:+UseSerialGC

# JDK 27, mesmo container 1 CPU, ZERO flag adicional
$ java -XX:+PrintCommandLineFlags -version | grep -o 'Use.*GC'
-XX:+UseG1GC

Nenhuma linha do seu código de negócio mudou. Nenhum application.yaml novo. Você só trocou a imagem base do JDK 26 pro 27 e ganhou um coletor com controle de pausa de graça. Esse é o tipo de melhoria que eu gosto: a que vem no upgrade, sem custo de migração.

Como a JVM conta CPU no container (e o que o GC log revela)

Aqui entra um detalhe que todo dev sênior que opera Kubernetes precisa ter na ponta da língua, porque ele explica casos aparentemente contraditórios. Com UseContainerSupport ligado, a JVM calcula o número de processadores disponíveis a partir da cota de CPU do cgroup, basicamente ceil(cpu.quota / cpu.period). O período padrão é 100000 microssegundos.

Faz a conta com os valores que você usa no manifesto todo dia:

  • limits.cpu: "1" vira quota 100000 / período 100000 = 1 processador. Abaixo do corte. SerialGC.
  • limits.cpu: "1500m" vira 150000 / 100000 = 1,5, arredondado pra cima dá 2 processadores. Passa no corte. G1.

Sacou a armadilha? A diferença entre o seu pod rodar SerialGC ou G1 no JDK 26 pode ser apenas a distância entre limits.cpu: "1" e limits.cpu: "1500m". Eu já vi time "economizar" baixando o limite de CPU de 1500m pra 1000m num ajuste de custo de cloud e, sem perceber, jogar a aplicação inteira no SerialGC. A fatura caiu, a latência piorou, e ninguém ligou os dois fatos por semanas.

E como isso aparece no GC log? Faz toda a diferença ver com os próprios olhos. No SerialGC, sob pressão de memória, você vê pausas full stop-the-world que congelam a aplicação:

# SerialGC: full GC para o mundo inteiro (note o tempo)
[2.118s][info][gc] GC(7) Pause Full (Allocation Failure) 498M->214M(512M) 381.742ms

# G1: a coleta young roda concorrente, pausa curtinha
[2.090s][info][gc] GC(7) Pause Young (Normal) (G1 Evacuation Pause) 320M->96M(512M) 11.604ms

381 milissegundos contra 11. Multiplica isso pela frequência de coleta sob carga e você entende por que o p99 da war story explodiu. Não é que o SerialGC seja "bugado", ele faz exatamente o que promete: um coletor simples, single-thread, que para tudo. O erro foi ele ter sido escolhido sozinho pra um workload que precisava de pausa curta.

Como antecipar o ganho hoje, antes do JDK 27

Você não precisa esperar setembro pra arrumar isso. O fix vale desde sempre: é só deixar a escolha do GC explícita, em vez de delegar pra ergonomia. Se você roda Java em container com menos de 2 CPUs, coloca o G1 na mão agora:

# Forca o G1 explicitamente, independente da versao do JDK
JAVA_TOOL_OPTIONS="-XX:+UseG1GC -XX:MaxRAMPercentage=75.0 -XX:MaxGCPauseMillis=200"

No Spring Boot, dá pra deixar isso no próprio Dockerfile ou no manifesto do Kubernetes. O ponto importante é nunca confiar no default implícito num ambiente conteinerizado. Um @Component simples que loga o GC ativo no boot te dá observabilidade barata e evita susto:

import java.lang.management.ManagementFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Component;

@Component
public class GcStartupLogger {

    private static final Logger log = LoggerFactory.getLogger(GcStartupLogger.class);

    @PostConstruct
    public void logGarbageCollector() {
        ManagementFactory.getGarbageCollectorMXBeans()
            .forEach(gc -> log.info("GC em uso neste pod: {}", gc.getName()));
    }
}

Subiu o serviço, primeira coisa que aparece no log: qual GC está rodando. Se vier MarkSweepCompact num pod que deveria atender tráfego, você acabou de achar um problema antes dele te achar. Pra fechar o diagnóstico sob carga, liga o log de GC e observa as pausas:

# Observa pausas reais de GC com timestamp
$ java -Xlog:gc*:stdout:time,level,tags -jar app.jar

Impacto prático: quando comemorar e quando ainda tunar

Vamos ser honestos sobre o tamanho do ganho, porque conteúdo técnico de verdade não vende milagre. O JEP 523 não deixa sua aplicação magicamente 10x mais rápida. O que ele faz é te tirar de um default ruim (SerialGC stop-the-world) e te colocar num default bom (G1 com pausa controlada) para a classe de serviço que mais sofria: o pod pequeno, de 1 CPU, sob carga de I/O. Para essa galera, a queda de p99 de latência nos momentos de GC é real e perceptível.

Mas tem um trade-off que o dev sênior precisa conhecer. O G1 tem um custo de memória nativa e de estruturas internas (remembered sets, region metadata) que o SerialGC não tem. Em heaps muito pequenos, abaixo de uns 256 MB, esse overhead pode pesar mais do que ajudar. Então:

  • Comemore o default novo em qualquer serviço que atenda tráfego: API REST, consumer de fila, worker de microsserviço.
  • Continue no SerialGC de propósito em jobs CLI curtos, sidecars minúsculos e funções serverless de heap mínimo, onde o startup e o footprint mandam.
  • Sempre tune o básico mesmo com G1: -XX:MaxRAMPercentage pra usar bem a RAM do container e -XX:MaxGCPauseMillis pra alinhar a meta de pausa com seu SLA.

Se você quer ir mais fundo na economia de heap do JDK 27 como um todo, eu cobri o Compact Object Headers (JEP 534), que corta heap sem mudar código, e também o panorama do JDK 27 com foco em heap e Kubernetes. São a dupla perfeita pra planejar seu upgrade de runtime no segundo semestre.

Perguntas frequentes (FAQ)

1. Como saber qual GC meu pod usa hoje sem mudar o código?
Roda java -XX:+PrintCommandLineFlags -version dentro do container, ou java -XX:+PrintFlagsFinal -version | grep -E "UseG1GC|UseSerialGC". Em runtime, os GarbageCollectorMXBean te dizem o nome do coletor ativo.

2. O JEP 523 muda alguma coisa no meu código de aplicação?
Nada. Ele altera só o coletor padrão escolhido pela JVM no boot. Seu código de negócio, suas dependências e seu application.yaml continuam iguais. É uma mudança de runtime, não de API.

3. Por que meu pod com 4 GB de RAM ainda cai no SerialGC?
Porque o gatilho de "server-class" exige 2 ou mais CPUs E 2 GB ou mais de RAM. No Kubernetes, o limite de CPU costuma ser o corte: com limits.cpu: "1", a JVM enxerga 1 processador e descarta o G1, independente da memória.

4. Preciso esperar o JDK 27 pra arrumar isso?
Não. Adicione -XX:+UseG1GC explicitamente hoje no container com menos de 2 CPUs. O JDK 27 só torna isso o comportamento padrão, mas tornar a escolha explícita é boa prática em qualquer versão.

5. G1 é sempre melhor que SerialGC?
Para serviços sob carga, quase sempre. Para heaps minúsculos (abaixo de ~256 MB), jobs curtos e sidecars, o SerialGC pode ter footprint menor e startup mais rápido. Avalie pelo perfil do workload, não por regra fixa.

Conclusão: o default que finalmente fez as pazes com o container

O JEP 523 não é a feature mais barulhenta do JDK 27, mas é uma das mais úteis pra quem opera Java em Kubernetes no dia a dia. Ele corrige uma heurística de 2004 que vinha te entregando o SerialGC de surpresa em pods pequenos, justamente os que mais sofrem com pausa stop-the-world sob carga.

  • O G1 vira default em todo ambiente no JDK 27, inclusive container de 1 CPU, sem flag e sem mudar código.
  • Hoje, muitos pods rodam SerialGC sem saber por causa do corte de server-class (2 CPUs E 2 GB).
  • Você pode antecipar o ganho já deixando -XX:+UseG1GC explícito, e deve sempre logar o GC ativo no boot.

E aí, você já foi pegar qual GC seus pods estão usando agora? Aposto que tem mais SerialGC escondido na sua frota do que você imagina. Conta nos comentários o que você encontrou, quero ver os casos reais. E se você está planejando o upgrade pro JDK 27, comenta também que eu monto um checklist de runtime completo pra série.

Antes de subir pro Boot 4 e pro JDK 27, vale revisar também o fim de suporte do Spring Boot 3.5 em 30 de junho, porque GC novo em runtime velho é meio caminho andado. Bora deixar a frota redonda antes de setembro.