Série: Java em Produção de Verdade — Este é o segundo artigo da série. Se você ainda não leu o primeiro, ele cobre os fundamentos das Virtual Threads, Thread Pinning e o Efeito Manada — conceitos que usaremos aqui como base. Leia a Parte 1 aqui — Virtual Threads no Java 21: O Fim da Era da Escassez (e as Armadilhas que Podem Lhe Derrubar).
Você leu sobre Virtual Threads. Entendeu o modelo mental. Resolveu o Thread Pinning, colocou o Semaphore na frente do banco. A aplicação está funcionando em desenvolvimento.
Aí você faz o deploy.
E começa a estranheza: latência oscilando sem motivo aparente, container sendo morto pelo kernel em hora de pico, dashboards mostrando CPU baixa enquanto as requisições acumulam na fila. Tudo parece bem — até não estar.
Esse artigo é sobre o que acontece depois do deploy. O ambiente de produção — Docker, Kubernetes e observabilidade — tem suas próprias armadilhas para aplicações com Virtual Threads, e a maioria delas é invisível até ser tarde demais.
O Custo do Stack e o Risco de OOM Kill no Docker
Vamos começar com memória, porque aqui mora um risco que pode matar seu container literalmente — sem stack trace, sem aviso, sem graceful shutdown.
A diferença fundamental entre os dois modelos:
- Platform Thread: ~1MB de stack alocado no espaço nativo da JVM, fora da Heap
- Virtual Thread: stack armazenado como objetos Java na Heap, sujeito ao GC
Essa migração de "stack nativo" para "objetos na Heap" tem uma consequência direta: o -Xmx que era suficiente antes pode não ser mais.
A Equação Mudou
Com Platform Threads, a memória era previsível:
Memória Total ≈ Heap (-Xmx) + MetaSpace + (N_threads × ~1MB nativo)
Com Virtual Threads, o stack das threads entrou na Heap:
Memória Total ≈ Heap (inclui stacks das VTs) + MetaSpace + Carrier Thread stacks
Quando você define --memory=512m no Docker (ou resources.limits.memory no Kubernetes), o Linux cgroup aplica esse limite em toda a memória do processo. Se a JVM ultrapassar esse limite, o kernel envia um SIGKILL. Isso é o OOM Kill — e ele não avisa.
🐳 Regra de ouro para Docker: Monitore o uso de Heap com Virtual Threads ativas. O
-Xmxque era suficiente antes pode precisar de um incremento de 20–30% para acomodar os stacks das Virtual Threads na Heap. Ajuste o limite do container com uma margem de segurança de pelo menos 15% acima do-Xmx.
# docker-compose.yml — configuração segura para Virtual Threads
services:
app:
image: minha-app:latest
environment:
JAVA_OPTS: >-
-Xms128m
-Xmx384m
-XX:+UseZGC
-Djdk.virtualThreadScheduler.parallelism=4
deploy:
resources:
limits:
memory: 512m # ~33% de margem acima do Xmx — nunca coloque Xmx = limite
Note o -Djdk.virtualThreadScheduler.parallelism=4. Esse parâmetro controla quantas Carrier Threads existem. Num container com 4 CPUs, faz sentido manter o padrão — mas configurá-lo explicitamente garante que o comportamento não mude se o número de CPUs do container mudar.
Por Que ZGC?
Com alto volume de Virtual Threads, a Heap vira um ambiente de alta rotatividade: objetos de stack sendo criados e destruídos constantemente. Coletores de lixo com pausas longas — como o G1 em cargas pesadas — vão introduzir latência perceptível justamente nos momentos de maior pressão. O ZGC (e o Shenandoah) foram projetados para pausas sub-milissegundo, independente do tamanho da Heap. Para aplicações com Virtual Threads em produção, são a escolha mais segura.
CPU Throttling no Kubernetes — O Inimigo Silencioso das Carrier Threads
O Kubernetes adiciona mais uma camada de complexidade. E essa é especialmente traiçoeira porque age de forma completamente silenciosa.
O Mecanismo
Quando você define resources.limits.cpu: "2" no seu Pod, o Kubernetes usa CPU quotas do cgroup para garantir que seu container não use mais que 2 cores. Se o processo tentar usar mais, o kernel throttle — literalmente estrangula o processo, impedindo-o de executar por um período proporcional ao excesso.
Lembra das Carrier Threads do artigo anterior? Elas são threads de SO que executam as Virtual Threads. Se o Kubernetes está throttling seu container, as Carrier Threads não conseguem ser agendadas. O resultado: mesmo com 1.000.000 de Virtual Threads prontas para executar, elas ficam paradas esperando que as Carrier Threads ganhem CPU de volta.
O Sintoma Enganoso
Latência alta com CPU aparentemente baixa nos dashboards.
O processo não está usando CPU porque está sendo throttled — mas os gráficos mostram 40% de uso (já que os períodos de throttle são ciclos onde o processo simplesmente não roda, reduzindo a média medida). A métrica que importa não é cpu_usage, é cpu_throttled_seconds_total — disponível no cAdvisor de qualquer cluster Kubernetes.
# kubernetes deployment — configuração consciente para Virtual Threads
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
containers:
- name: app
resources:
requests:
cpu: "1"
memory: "256Mi"
limits:
cpu: "2" # Define o teto efetivo de Carrier Threads ativas
memory: "512Mi"
env:
- name: JAVA_OPTS
value: >-
-Xmx384m
-XX:+UseZGC
-Djdk.virtualThreadScheduler.parallelism=2
-XX:StartFlightRecording=filename=/tmp/jfr/recording.jfr,
duration=60s,settings=profile
⚠️ Alinhamento crítico: O valor de
virtualThreadScheduler.parallelismdeve ser coerente com olimits.cpu. Se você define 2 CPUs de limite mas 8 Carrier Threads, as Carrier Threads extras vão competir por CPU, aumentar o throttling e piorar a situação. Mantenha os dois valores alinhados.
Observabilidade com JDK Flight Recorder (JFR)
O JFR é a ferramenta de observabilidade mais poderosa para diagnosticar problemas com Virtual Threads em produção. Ele tem suporte nativo a eventos específicos de Virtual Threads desde o Java 21 — e tem overhead tão baixo que pode rodar continuamente em produção sem impacto perceptível.
Os Eventos que Importam
| Evento JFR | O que revela |
|---|---|
jdk.VirtualThreadPinned |
Thread Pinning ativo — synchronized + I/O no caminho crítico |
jdk.VirtualThreadSubmitFailed |
Falhas ao submeter Virtual Threads — sinal de saturação do scheduler |
jdk.VirtualThreadStart / End
|
Volume total de VTs criadas — detecta explosão de criação |
jdk.ThreadSleep |
Threads em sleep desnecessariamente longo |
Diagnóstico em Runtime (Sem Restart)
# Inicia uma gravação de 2 minutos sem reiniciar a aplicação
jcmd <PID> JFR.start name=vt-diagnosis \
settings=profile \
duration=120s \
filename=/tmp/vt-diagnosis.jfr
# Analisa eventos de pinning diretamente no terminal
jfr print --events jdk.VirtualThreadPinned /tmp/vt-diagnosis.jfr
Para uma análise visual completa, o JDK Mission Control (JMC) é a GUI oficial — você abre o arquivo .jfr e tem uma linha do tempo completa de todos os eventos, com drill-down por thread, por método e por tempo.
Integração com Prometheus via Micrometer
Se você usa Spring Boot 3.2+, as métricas de Virtual Threads já estão disponíveis via Micrometer. Configure alertas para:
# Alerta: Thread Pinning detectado em produção
- alert: VirtualThreadPinningDetected
expr: jvm_threads_virtual_pinned_count > 0
for: 1m
annotations:
summary: "Thread Pinning ativo — investigar synchronized + I/O"
# Alerta: CPU Throttling acima do aceitável
- alert: ContainerCPUThrottling
expr: rate(container_cpu_cfs_throttled_seconds_total[5m]) > 0.25
for: 5m
annotations:
summary: "Container sendo throttled — Carrier Threads impactadas"
🔍 Dica de ouro: Se
VirtualThreadPinneddisparar, você tem Thread Pinning em produção. SeCPUThrottlingdisparar junto com latência alta, você tem Carrier Threads sendo estranguladas pelo cgroup. São problemas diferentes com causas diferentes — os alertas separados evitam a investigação no lugar errado.
Checklist do Desenvolvedor Moderno
Consolidando tudo da série em um checklist operacional:
Antes de Ligar Virtual Threads
- [ ] Java 21+ no ambiente — não negocie isso
- [ ] Verifique versões de drivers JDBC — PostgreSQL ≥ 42.6, MySQL Connector/J ≥ 9.0
- [ ] Audite
synchronizedem caminhos críticos de I/O — migre paraReentrantLock - [ ] Defina limites de concorrência para recursos escassos via
Semaphoreou Resilience4jBulkhead
Configuração Docker
- [ ] Adicione 20–30% de margem no limite de memória do container acima do
-Xmx - [ ] Configure
-Djdk.virtualThreadScheduler.parallelismexplicitamente com base nas CPUs alocadas - [ ] Use ZGC ou Shenandoah como GC — menores pausas, melhor para alta rotatividade de objetos na Heap
Configuração Kubernetes
- [ ] Monitore
cpu_throttled_seconds_totalno cAdvisor — throttling é o inimigo silencioso das Carrier Threads - [ ] Alinhe
virtualThreadScheduler.parallelismcomresources.limits.cpu - [ ] Ative JFR com perfil de Virtual Threads em staging antes de ir para produção
Observabilidade em Produção
- [ ] Alerta para
jdk.VirtualThreadPinned— qualquer valor acima de zero merece investigação - [ ] Alerta para
container_cpu_cfs_throttled_seconds_totalacima de 25% - [ ] Dashboard com
jvm_threads_states_threads_total{state="runnable"}para volume de VTs ativas - [ ] Health checks que considerem saturação do
Bulkheadcomo estado de degradação
Conclusão
A era da escassez de threads terminou. O restaurante pode ter 1 milhão de garçons.
Mas o banco de dados ainda tem 100 mesas. O Kubernetes ainda tem CPU limitada. O container ainda tem memória definida pelo cgroup. E o kernel ainda manda SIGKILL sem pedir licença.
Virtual Threads resolvem o problema de escassez de threads — e apenas esse. Os outros problemas continuam existindo, e alguns ficam até mais visíveis porque o freio acidental que as Platform Threads proporcionavam sumiu.
O modelo mental correto não é "Virtual Threads = performance livre". É: Virtual Threads = eu paro de me preocupar com threads e começo a me preocupar com os recursos reais que minha aplicação consome.
Com esse modelo na cabeça, a ferramenta é genuinamente transformadora.
Ficou com alguma dúvida ou quer aprofundar algum dos pontos? Comenta aqui embaixo — respondo todos. 🙌
Referências
JEP 444 — Virtual Threads (Java 21)
Base conceitual para o comportamento das Carrier Threads e o impacto de CPU throttling discutido neste artigo.
https://openjdk.org/jeps/444OpenJDK — JDK Flight Recorder (JFR) Event Reference
Documentação dos eventosjdk.VirtualThreadPinned,jdk.VirtualThreadStarte demais eventos de Virtual Threads disponíveis via JFR.
https://docs.oracle.com/en/java/javase/21/docs/api/jdk.jfr/jdk/jfr/package-summary.htmlSpring Boot 3.2 Release Notes — Virtual Threads
Referência para configuração de Virtual Threads com Spring Boot, incluindo integração com Micrometer para as métricas citadas nas configurações de alerta.
https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.2-Release-NotesResilience4j — Documentação oficial do CircuitBreaker
Referência para a configuração defailureRateThreshold,slidingWindowSizeewaitDurationInOpenStateusados nos exemplos de resiliência.
https://resilience4j.readme.io/docs/circuitbreaker
Código-fonte
Se você ainda não viu o repositório da série, ele contém demos executáveis dos conceitos da Parte 1 — Efeito Manada, Thread Pinning e benchmark Platform vs Virtual Threads — cada um com logs que tornam o comportamento visível em tempo real.
Top comments (2)
Muito bom!!
Qual foi o maior problema que você só descobriu depois de colocar Virtual Threads em produção?
Eu ainda não implantei em produção, estou em fase de testes, justamente pra não ter surpresa em ambiente produtivo meu amigo @mensonones hehe. Mas pude experimentar o potencial dessas “surpresas” em ambiente local, rodando em um serviço meu. O que mais me pegou foi a falta de um controle embutido como nas platform threads que possuem pool size e fila de capacidade interna. As virtual threads, até onde pude ver, precisam de um controle de capacidade externo mas fino, como no exemplo do semáforo.
Caso o dev não se atente a isso e ache que é só trocar as threads convencionais pelas virtuais, o principal susto pode vir da memória da sua app indo embora e morrendo por OOM KILL ou ainda pior, o banco de dados caindo por transferência de pressão e gargalo.