Você habilitou Virtual Threads no Spring Boot, botou spring.threads.virtual.enabled=true no application.yaml, subiu a aplicação em produção e... nada. O throughput melhorou uns 10%, a latência sob carga continua a mesma, e os alertas de "pool esgotado" ainda aparecem às sextas-feiras. Você fez tudo certo pelo livro. E mesmo assim não funcionou como prometido.
O problema não é que Virtual Threads são uma promessa vazia. O problema é que o gargalo se moveu. O JEP 491, consolidado no Java 25 LTS, resolveu de vez o thread pinning causado por blocos synchronized. Só que agora, com o synchronized fora da equação, o bottleneck real ficou exposto: o connection pool do HikariCP que você configurou para 20 conexões quando tinha 200 threads de plataforma, o ThreadLocal herdado de libs antigas, e as chamadas JNI que ainda prendem o carrier thread. Neste artigo você vai entender por que isso acontece e como configurar sua stack Spring Boot para extrair o potencial real das Virtual Threads no Java 25.
O que o JEP 491 resolveu (e o que ele não resolveu)
Antes de entender o novo gargalo, bora entender o que o JEP 491 realmente fez. Virtual Threads foram introduzidas como feature estável no Java 21, mas tinham um problema sério: quando uma Virtual Thread encontrava um bloco synchronized, ela ficava pinada no carrier thread. Isso significa que o carrier thread ficava bloqueado junto com a Virtual Thread, destruindo toda a vantagem de escalabilidade.
O HikariCP, o driver JDBC do PostgreSQL e várias libs populares usavam synchronized internamente. Na prática, boa parte das aplicações Spring Boot ficava pinada no banco, no pool de conexões, ou nas libs de serialização. O resultado: Virtual Threads ligadas com Spring Boot 3.x pré-JEP 491 frequentemente performavam pior que threads de plataforma em cargas mistas.
O JEP 491 resolveu isso mudando a implementação de synchronized na JVM. A partir do JDK 24 (incluído na LTS Java 25), uma Virtual Thread dentro de um bloco synchronized pode ser desmontada do carrier thread durante operações de I/O bloqueante. Sem pinning, sem carrier thread preso.
Aqui está como verificar se o pinning ainda ocorre na sua app:
import jdk.jfr.consumer.RecordingStream;
public class PinningDetector {
public static void monitorarPinning() {
try (RecordingStream rs = new RecordingStream()) {
// Evento JFR específico para detectar thread pinning
rs.enable("jdk.VirtualThreadPinned").withThreshold(Duration.ofMillis(20));
rs.onEvent("jdk.VirtualThreadPinned", event -> {
System.out.println("[PINNING DETECTADO] Thread: " +
event.getString("thread") +
" por " + event.getDuration("duration").toMillis() + "ms");
System.out.println("Stack: " + event.getStackTrace());
});
rs.startAsync();
// Executar a carga da sua aplicação aqui
// Se não aparecer nenhum evento: stack livre de pinning
}
}
}
Na empresa em que trabalhei como Tech Lead, rodamos esse monitoramento por 48 horas antes de migrar para Java 25 LTS. Zero eventos de pinning com HikariCP 5.1 e Spring Boot 3.3. O synchronized não é mais o problema. Mas aí vieram os alertas de connection pool...
O gargalo que se moveu: connection pool saturation
Pensa assim: no modelo de threads de plataforma, você tinha um pool de 200 threads e um pool de 20 conexões de banco. A relação era razoável porque as threads ficavam bloqueadas aguardando I/O: no máximo 20 threads estavam "trabalhando" de verdade ao mesmo tempo (as outras 180 estavam esperando o banco, a API externa, ou a fila).
Com Virtual Threads, a JVM pode criar milhares de Virtual Threads simultaneamente. Agora a aplicação consegue processar 5.000 requests em paralelo. Cada request quer uma conexão do banco. Seu pool HikariCP tem 20 conexões. Resultado: 4.980 Virtual Threads aguardando na fila do HikariCP, que tem seu próprio pool bloqueante.
A Lei de Little define isso matematicamente: N = λ × W, onde N é o número de requisições simultâneas, λ é o throughput e W é o tempo médio de resposta. Se W (latência do banco) é 50ms e você aceita λ = 1.000 req/s, você precisa de N = 50 conexões simultâneas no mínimo.
// application.yaml — Configuração ERRADA (padrão para threads de plataforma)
// spring:
// datasource:
// hikari:
// maximum-pool-size: 20 // projetado para 200 platform threads, nunca para VT
// application.yaml — Configuração CORRETA para Virtual Threads
// Calcular com a Lei de Little: N = lambda × W
// Exemplo: 1000 req/s × 0.05s latência média = 50 conexões mínimas
// spring:
// datasource:
// hikari:
// maximum-pool-size: 100 // headroom para picos: N × 2
// minimum-idle: 20
// connection-timeout: 5000
// Java: calcular pool mínimo programaticamente
public class PoolSizingCalculator {
public static int calcularPoolMinimo(double throughputRps, double latenciaMediaMs, double fatorSeguranca) {
double latenciaEmSegundos = latenciaMediaMs / 1000.0;
double concorrenciaBase = throughputRps * latenciaEmSegundos;
return (int) Math.ceil(concorrenciaBase * fatorSeguranca);
}
public static void main(String[] args) {
// 1000 req/s com latência média de 50ms: precisa de 100 conexões
int poolSize = calcularPoolMinimo(1000, 50, 2.0);
System.out.println("Pool mínimo recomendado: " + poolSize); // 100
}
}
ThreadLocal: o problema silencioso que você herdou das libs
Tem mais um detalhe que a maioria ignora. ThreadLocal foi criado para armazenar estado por thread de plataforma. Com Virtual Threads, o comportamento técnico é o mesmo: cada Virtual Thread tem seu próprio ThreadLocal. Mas a escala muda tudo.
Se você tem 200 threads de plataforma e cada uma aloca 10KB em ThreadLocal, são 2MB de overhead. Com 100.000 Virtual Threads no mesmo cenário: são 1GB de overhead de ThreadLocal. Libs como Spring Security, MDC (Logback), e OpenTelemetry usam ThreadLocal intensamente. O resultado pode ser OutOfMemoryError inesperado em picos de carga.
// Semáforo para controlar concorrência máxima (evita explosão de ThreadLocal)
@Service
public class PedidoService {
private final Semaphore semaforo = new Semaphore(500); // limite de concorrência
@Transactional
public Pedido processarPedido(Long pedidoId) throws InterruptedException {
semaforo.acquire(); // bloqueia se > 500 chamadas simultâneas
try {
return pedidoRepository.findById(pedidoId)
.orElseThrow(() -> new PedidoNotFoundException(pedidoId));
} finally {
semaforo.release();
}
}
}
Como monitorar Virtual Threads com JFR em produção
Saber que o gargalo se moveu é uma coisa. Saber onde ele está agora na sua aplicação específica é outra. O JDK 25 tem três eventos JFR críticos para monitorar Virtual Threads:
@Component
public class VirtualThreadMonitor implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(VirtualThreadMonitor.class);
@Override
public void run(ApplicationArguments args) {
var rs = new RecordingStream();
// Evento 1: Thread pinning (synchronized ainda causando problema)
rs.enable("jdk.VirtualThreadPinned").withThreshold(Duration.ofMillis(10));
rs.onEvent("jdk.VirtualThreadPinned", e ->
log.warn("[VT-PINNED] {}ms em {}", e.getDuration().toMillis(), e.getStackTrace())
);
// Evento 2: Falha ao criar nova Virtual Thread (pool esgotado)
rs.enable("jdk.VirtualThreadSubmitFailed");
rs.onEvent("jdk.VirtualThreadSubmitFailed", e ->
log.error("[VT-SUBMIT-FAILED] {}", e.getString("exceptionMessage"))
);
rs.startAsync();
log.info("Monitoramento JFR de Virtual Threads ativo");
}
}
Configuração completa para produção: Spring Boot + Java 25
# application.yaml — Configuração completa Virtual Threads Java 25
spring:
threads:
virtual:
enabled: true
datasource:
hikari:
maximum-pool-size: 100 # Lei de Little: throughput_rps × latencia_s × 2
minimum-idle: 20
connection-timeout: 5000
idle-timeout: 30000
max-lifetime: 1800000
keepalive-time: 60000
management:
endpoints:
web:
exposure:
include: health,metrics,hikaricp
@SpringBootApplication
public class MinhaAplicacao {
private static final Logger log = LoggerFactory.getLogger(MinhaAplicacao.class);
public static void main(String[] args) {
SpringApplication.run(MinhaAplicacao.class, args);
}
@EventListener(ApplicationReadyEvent.class)
public void verificarVirtualThreads() {
var executor = Executors.newVirtualThreadPerTaskExecutor();
executor.execute(() -> {
boolean isVirtual = Thread.currentThread().isVirtual();
log.info("Virtual Threads ativas: {} (thread: {})",
isVirtual, Thread.currentThread().getName());
});
}
}
Benchmarks reais: quanto melhora com a configuração correta
Olhando os dados do artigo da Java Code Geeks sobre dois anos de Virtual Threads em produção e da análise do bottleneck pós-JEP 491, os números de quem configurou corretamente são:
- Throughput (req/s): de 400 req/s para 2.200 req/s com a mesma máquina (5.5x)
- Latência P99: de 1.200ms para 380ms sob carga de 1.000 req/s
- Uso de memória: redução de 40% no heap (menos stack overhead de threads de plataforma)
No meu trabalho atual, aplicamos essa migração em um serviço de processamento de pedidos com Spring Boot 3.3 e Java 25. Saímos de 600 req/s para 2.800 req/s apenas com as mudanças de configuração acima, sem alterar uma linha de código de negócio. A chave foi aumentar o pool do HikariCP de 20 para 80 conexões conforme a fórmula da Lei de Little.
Para ir mais fundo: veja também como usar Spring Boot Actuator para monitorar Virtual Threads e configuração avançada do HikariCP para Spring Boot.
Perguntas Frequentes
Preciso estar no Java 25 para usar Virtual Threads sem thread pinning?
O JEP 491 foi integrado ao JDK 24 e consolidado na LTS Java 25. Se você está no Java 21 ou 23, ainda pode ter thread pinning em alguns cenários com synchronized. Para produção corporativa, a migração para Java 25 LTS é o caminho certo.
Virtual Threads funcionam com Spring Security e filtros de servlet?
Sim, desde Spring Boot 3.2. O Spring Security usa SecurityContextHolder com InheritableThreadLocal que propaga corretamente para Virtual Threads. Nenhuma mudança necessária no código de segurança.
Qual o tamanho máximo seguro para o pool HikariCP com Virtual Threads?
Não existe um número fixo. Use a fórmula: N = λ × W × 2. Se seu banco aguenta 500 conexões simultâneas, esse é o limite real. Monitorar o evento jdk.VirtualThreadPinned e as métricas do HikariCP via Actuator dá a resposta mais precisa.
ThreadLocal vai quebrar com Virtual Threads?
Não "vai quebrar": o código continua funcionando. O risco é de consumo excessivo de memória em picos muito altos de concorrência. Para a maioria das apps Spring Boot com até 1.000 req/s, não é problema. Para apps de alta escala, a migração para ScopedValue (JEP 506, Java 25 LTS) é o caminho de longo prazo.
O que você leva daqui
- Reconfigure o HikariCP usando a Lei de Little: N = throughput × latência × 2
- Monitore com JFR antes e depois da migração: eventos
jdk.VirtualThreadPinnedejdk.VirtualThreadSubmitFailedsão seus aliados - Use semáforo para limitar concorrência máxima em serviços com
ThreadLocalintenso - Java 25 LTS é o alvo: JEP 491 + JEP 506 (ScopedValue) formam a stack de concorrência moderna do Java
Você já migrou para Virtual Threads em produção? Qual foi o maior gargalo que você encontrou? Conta nos comentários, quero saber se o seu caso foi connection pool, ThreadLocal, ou algo completamente diferente.
Na próxima semana, vou mostrar como usar Structured Concurrency com Java 25 (JEP 505) para chamar APIs externas em paralelo no Spring Boot sem o inferno do CompletableFuture. Assina o canal para não perder.