DEV Community

Dhellano Castro
Dhellano Castro

Posted on

Virtual Threads em Produção de Verdade: Docker, Kubernetes e o que os Dashboards não te Contam

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)
Enter fullscreen mode Exit fullscreen mode

Com Virtual Threads, o stack das threads entrou na Heap:

Memória Total ≈ Heap (inclui stacks das VTs) + MetaSpace + Carrier Thread stacks
Enter fullscreen mode Exit fullscreen mode

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 -Xmx que 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

⚠️ Alinhamento crítico: O valor de virtualThreadScheduler.parallelism deve ser coerente com o limits.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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

🔍 Dica de ouro: Se VirtualThreadPinned disparar, você tem Thread Pinning em produção. Se CPUThrottling disparar 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 synchronized em caminhos críticos de I/O — migre para ReentrantLock
  • [ ] Defina limites de concorrência para recursos escassos via Semaphore ou Resilience4j Bulkhead

Configuração Docker

  • [ ] Adicione 20–30% de margem no limite de memória do container acima do -Xmx
  • [ ] Configure -Djdk.virtualThreadScheduler.parallelism explicitamente 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_total no cAdvisor — throttling é o inimigo silencioso das Carrier Threads
  • [ ] Alinhe virtualThreadScheduler.parallelism com resources.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_total acima de 25%
  • [ ] Dashboard com jvm_threads_states_threads_total{state="runnable"} para volume de VTs ativas
  • [ ] Health checks que considerem saturação do Bulkhead como 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

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.

🔗 github.com/DheCastro/java-virtual-threads-pitfalls

Top comments (2)

Collapse
 
mensonones profile image
Emerson Vieira

Muito bom!!

Qual foi o maior problema que você só descobriu depois de colocar Virtual Threads em produção?

Collapse
 
dhellano_castro_c5aba0c56 profile image
Dhellano Castro

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.