Série: Java em Produção de Verdade — Este é o primeiro de dois artigos. Aqui cobrimos os fundamentos, o modelo mental correto e as duas armadilhas que derrubam aplicações silenciosamente. No segundo, descemos para Docker, Kubernetes e observabilidade com JFR.
Imagine um restaurante sofisticado. Cada mesa — uma requisição HTTP — precisa de um garçom dedicado. O garçom anota o pedido, vai até a cozinha... e fica parado lá, esperando o chef terminar o prato. Enquanto isso, novas mesas chegam. Mas não tem garçom disponível. O maître começa a recusar clientes na porta.
O restaurante está cheio de garçons parados na cozinha sem fazer nada — e o salão, vazio de atendimento.
Esse é o modelo clássico de Platform Threads em Java. Cada thread consome cerca de 1MB de stack no sistema operacional. Num servidor com 4GB dedicados a threads, você tem no máximo ~4.000 garçons. Parece muito? Para uma aplicação moderna com alto volume de I/O — chamadas a banco, HTTP externo, mensageria — não é.
O Project Loom, introduzido como preview no Java 19 e estável a partir do Java 21, mudou as regras do jogo. A ideia central é elegante: e se o garçom pudesse largar a mesa na cozinha, voltar ao salão para atender outras mesas, e retornar quando o prato ficasse pronto?
Isso são as Virtual Threads. Milhões delas. Com custo de memória na casa dos kilobytes. O restaurante agora pode ter 1.000 garçons reais atendendo 1.000.000 de mesas simultâneas.
Mas — e sempre tem um "mas" — um restaurante com 1 milhão de garçons e uma única cozinha com 4 fogões ainda vai entupir. É aqui que a história começa a ficar interessante.
O Motor por Baixo do Capô
Antes de sair criando Virtual Threads por aí, vale entender o que está acontecendo embaixo dos panos. A JVM gerencia três conceitos distintos que vivem juntos nesse ecossistema.
Platform Threads são o modelo antigo e honesto: uma thread Java mapeada 1:1 para uma thread do sistema operacional. O SO agenda, o SO bloqueia, o SO paga a conta de memória. São caras, poderosas e limitadas em número.
Virtual Threads são threads gerenciadas pela própria JVM, não pelo SO. São leves, baratas e podem existir em quantidades absurdas. Quando uma Virtual Thread precisa esperar por I/O, ela é desmontada (unmounted) da thread do SO e o seu contexto fica salvo na heap — como objetos Java comuns, sujeitos ao GC.
Carrier Threads são o elo perdido que a maioria dos artigos ignora. São Platform Threads do SO que o ForkJoinPool interno da JVM usa para executar as Virtual Threads. Pense nelas como os trilhos de um metrô: os vagões (Virtual Threads) rodam em cima dos trilhos (Carrier Threads). Você pode ter 1.000 vagões, mas se tiver apenas 4 trilhos, só 4 vagões andam ao mesmo tempo.
┌─────────────────────────────────────────────────────┐
│ JVM │
│ │
│ Virtual Thread 1 ──┐ │
│ Virtual Thread 2 ──┤ │
│ Virtual Thread 3 ──┼──► Carrier Thread 1 ──► OS │
│ Virtual Thread 4 ──┤ │
│ Virtual Thread ...──┘ │
│ ──► Carrier Thread 2 ──► OS │
│ ──► Carrier Thread N ──► OS │
│ │
│ (N = número de CPUs disponíveis, por padrão) │
└─────────────────────────────────────────────────────┘
O número padrão de Carrier Threads é igual ao número de CPUs disponíveis. Em produção, dentro de um container Docker com --cpus=2, você tem 2 trilhos para potencialmente milhões de vagões. Isso vai importar — muito — no segundo artigo desta série.
Armadilha 1 — Thread Pinning: O Parafuso no Chão
Lembra do garçom que podia largar a mesa na cozinha e ir atender outras? Pois bem. Existe uma situação onde ele não consegue largar. Alguém parafusou a cadeira dele no chão da cozinha. Esse parafuso se chama synchronized.
Quando uma Virtual Thread entra em um bloco ou método synchronized e encontra um ponto de bloqueio — I/O, por exemplo — ela não consegue ser desmontada da Carrier Thread. Ela pina. A Carrier Thread fica presa junto com ela, esperando. Se todas as Carrier Threads ficarem pinadas, sua aplicação congela. Completamente.
⚠️ Importante:
synchronizednão é vilão por natureza. É perfeitamente seguro usá-lo para proteger operações rápidas em memória, como manipulação de umaHashMapcompartilhada. O problema surge quando dentro do blocosynchronizedexiste uma operação de I/O demorada — consulta ao banco, chamada HTTP, leitura de arquivo.
Veja a diferença na prática:
// ❌ PROBLEMÁTICO: synchronized + I/O = Thread Pinning garantido
// A Carrier Thread fica presa enquanto o banco responde
public synchronized User findById(Long id) {
return jdbcTemplate.queryForObject(
"SELECT * FROM users WHERE id = ?",
userRowMapper,
id
);
}
// ✅ CORRETO: ReentrantLock é "Virtual Thread aware"
// A Virtual Thread pode ser desmontada enquanto aguarda o banco
// A Carrier Thread fica livre para executar outras Virtual Threads
private final ReentrantLock lock = new ReentrantLock();
public User findById(Long id) throws InterruptedException {
lock.lock();
try {
return jdbcTemplate.queryForObject(
"SELECT * FROM users WHERE id = ?",
userRowMapper,
id
);
} finally {
lock.unlock();
}
}
Por que o ReentrantLock resolve? Porque ele não usa monitores de objeto nativos do SO. Quando a Virtual Thread precisa aguardar dentro de um ReentrantLock, a JVM consegue desmontá-la da Carrier Thread normalmente. O garçom finalmente consegue sair da cadeira.
Para identificar pinning em produção, ative o diagnóstico com a JVM flag:
-Djdk.tracePinnedThreads=full
💡 Nota para quem usa frameworks: Drivers JDBC antigos e algumas implementações de
DataSourceainda usamsynchronizedinternamente. Verifique suas versões. O driver do PostgreSQL removeu ossynchronizedproblemáticos a partir da versão 42.6.
Armadilha 2 — O Efeito Manada
Você resolveu o pinning. Sua aplicação está rodando com Virtual Threads lisas como manteiga. Requisições entrando, threads respondendo. Aí você olha para o banco de dados e vê isso:
ERROR: FATAL: remaining connection slots are reserved
for replication superuser connections
Max connections: 100. Active: 100. Waiting: 4.847.
Bem-vindo ao Efeito Manada.
O problema é sutil e cruel: com Platform Threads, o pool de threads era o limitador natural de conexões com o banco. Se você tinha 200 threads no pool, no máximo 200 conexões simultâneas chegavam ao banco. Era uma contenção acidental, mas funcionava como um freio de mão.
Com Virtual Threads, esse freio sumiu. A JVM pode criar ilimitadas Virtual Threads. Cada uma, ao encontrar um ponto de I/O, fica "parada" aguardando a resposta — mas continua existindo e segurando uma conexão aberta com o banco. Uma enxurrada de 50.000 requisições simultâneas pode se transformar em 50.000 conexões tentando abrir no banco ao mesmo tempo.
O banco colapsa. Não foi a Virtual Thread que foi lenta — foi a ausência de governança sobre o recurso compartilhado.
🎯 A mudança de paradigma central do Project Loom: Com Virtual Threads, o controle sai da thread e vai para o recurso. Você não limita mais threads. Você limita acesso a recursos escassos.
Mitigação — O Freio de Mão Inteligente
Semaphore: O Porteiro do Banco
A solução mais direta é usar um Semaphore como controlador de acesso. Pense nele como um porteiro na porta do banco de dados: independente de quantos clientes cheguem, só N entram ao mesmo tempo.
@Repository
public class ProductRepository {
// Porteiro: máximo 80 conexões simultâneas ao banco
private final Semaphore dbGatekeeper = new Semaphore(80);
public List<Product> findAllByCategory(String category) {
try {
dbGatekeeper.acquire(); // Aguarda permissão do porteiro
try {
return jdbcTemplate.query(
"SELECT * FROM products WHERE category = ?",
productRowMapper,
category
);
} finally {
dbGatekeeper.release(); // Libera a vaga ao sair
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new DatabaseAccessException("Interrupted while waiting for DB slot", e);
}
}
}
A beleza aqui: Semaphore.acquire() é um ponto de bloqueio virtual-thread-friendly. A Virtual Thread que aguarda a vaga do porteiro é desmontada da Carrier Thread, que fica livre para executar outras Virtual Threads. Zero desperdício de CPU.
Resilience4j: O Controle de Missão
Para produção real, o Semaphore puro é o mínimo. O Resilience4j oferece um conjunto completo de primitivas para resiliência, todas compatíveis com Virtual Threads.
O BulkheadConfig é essencialmente um Semaphore com superpoderes: métricas, fallbacks, timeouts e integração nativa com Micrometer e Prometheus.
// Configuração do Bulkhead
@Bean
public BulkheadRegistry bulkheadRegistry() {
BulkheadConfig config = BulkheadConfig.custom()
.maxConcurrentCalls(80) // Máximo de chamadas simultâneas
.maxWaitDuration(Duration.ofSeconds(2)) // Timeout na fila de espera
.build();
return BulkheadRegistry.of(config);
}
// Uso no serviço
@Service
public class ProductService {
private final Bulkhead dbBulkhead;
private final ProductRepository repository;
public ProductService(BulkheadRegistry registry, ProductRepository repository) {
this.dbBulkhead = registry.bulkhead("database-bulkhead");
this.repository = repository;
}
public List<Product> getProductsByCategory(String category) {
return Bulkhead.decorateSupplier(
dbBulkhead,
() -> repository.findAllByCategory(category)
).get();
}
}
Combine isso com um CircuitBreaker para que, se o banco começar a rejeitar conexões, o circuito abra automaticamente — dando tempo para o banco se recuperar antes de a situação escalar.
@Bean
public CircuitBreakerConfig circuitBreakerConfig() {
return CircuitBreakerConfig.custom()
.failureRateThreshold(50) // Abre se 50% das calls falharem
.waitDurationInOpenState(Duration.ofSeconds(30))// Espera 30s para tentar de novo
.slidingWindowSize(20) // Avalia as últimas 20 chamadas
.build();
}
Quer Ver os Números na Prática?
Há um demo completo e autocontido disponível no repositório — Java 21, zero dependências — que mostra os dois cenários rodando e imprimindo os resultados. O output é brutal:
CENÁRIO 1 — SEM controle:
✅ Sucesso: 80 requisições
❌ Rejeitadas: 420 requisições ← 84% das requisições perdidas
CENÁRIO 2 — COM Semaphore:
✅ Sucesso: 500 requisições
❌ Rejeitadas: 0 requisições
📈 Pico: 80 conexões (nunca ultrapassou o limite)
🔗 [https://github.com/DheCastro/java-virtual-threads-pitfalls]
O Que Vem no Próximo Artigo
Agora que o modelo mental está correto, vamos descer para onde a maioria das aplicações Java realmente vive: containers em produção.
No próximo artigo desta série, vamos cobrir:
-
O custo do Stack em Docker: por que o
-Xmxque era suficiente antes pode não ser mais — e como calcular a margem correta para evitar OOM Kill - CPU Throttling no Kubernetes: como limites de CPU afetam as Carrier Threads e causam latência alta com CPU aparentemente baixa nos dashboards
- Observabilidade com JFR: os eventos exatos para monitorar Thread Pinning e saturação em produção
- Checklist completo do desenvolvedor moderno para uma migração segura
Continue lendo: Parte 2 — Virtual Threads em Produção de Verdade: Docker, Kubernetes e o que os Dashboards não te Contam
Se este artigo foi útil, deixa uma reação — ajuda muito a saber se vale continuar a série. 🙌
Referências
JEP 444 — Virtual Threads (Java 21)
Especificação oficial do Project Loom. Documenta o modelo de mount/unmount, o comportamento dosynchronizede o papel das Carrier Threads.
https://openjdk.org/jeps/444JEP 491 — Synchronize Virtual Threads without Pinning (Java 24)
A evolução direta da armadilha do Thread Pinning discutida neste artigo. A partir do Java 24, osynchronizedcom I/O deixa de causar pinning na maioria dos casos — relevante se você já está ou planeja estar no Java 24+.
https://openjdk.org/jeps/491Spring Boot 3.2 Release Notes — Virtual Threads
Documentação oficial da propriedadespring.threads.virtual.enablede o que ela configura automaticamente (Tomcat, Jetty,@Async, executores).
https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.2-Release-NotesResilience4j — Documentação oficial do Bulkhead
Referência para oSemaphoreBulkheadeBulkheadConfigusados na seção de mitigação.
https://resilience4j.readme.io/docs/bulkhead
Código-fonte
Todos os exemplos deste artigo — e mais — estão disponíveis no repositório abaixo.
Cada classe é autocontida e roda com um único comando (java NomeClasse.java).
Nenhuma dependência externa, apenas Java 21.
Top comments (1)
Ótimo post!!! Esse nível de detalhe faz muita diferença ao se fazer uso desses recursos que parecem simples mas ao mesmo tempo são poderosos 👏🏾👏🏾