Virtual Threads no Spring Boot: migrar controllers bloqueantes, JPA e tuning do HikariCP
Por John Pires · 14 de março de 2026

Análise profunda do problema
Aplicações Spring Boot tradicionais geralmente usam um modelo de thread limitado: cada requisição HTTP consome uma thread de container (Tomcat/Jetty/Undertow) até o final do processamento. Quando a aplicação faz I/O bloqueante — chamadas a banco via JDBC, chamadas HTTP síncronas, leitura/escrita em disco — essas threads ficam ociosas aguardando resposta. O resultado é capacidade limitada por número de threads e necessidade de pools grandes, o que aumenta memória, contexto de cache da CPU e latência por trocas de contexto.
Virtual Threads (Projeto Loom) prometem reduzir o custo de threads: milhares de threads virtuais com baixo overhead. Mas adotar virtual threads em um ecossistema existente não é plug-and-play. Há nuances em como os frameworks (Spring MVC, WebFlux, Hibernate/JPA, HikariCP) se comportam. Migrar requer entender onde colocar as virtual threads, como preservar contexto de transação e como ajustar pool de conexões.
Este artigo foca em problemas reais que você encontrará ao migrar um serviço Spring Boot intermediário: controllers bloqueantes, chamadas JPA/Hibernate e tuning do HikariCP. Vou mostrar padrões práticos, código funcional e armadilhas comuns para você aplicar em produção com segurança.
Por que não basta habilitar virtual threads globalmente?
Habilitar virtual threads na JVM (Executors.newVirtualThreadPerTaskExecutor()) não resolve automaticamente: o container servlet continua usando suas threads do pool. Se o request handler executar em uma thread do container e chamar código bloqueante, a thread do container permanece ocupada até a conclusão. É preciso deslocar o trabalho bloqueante para virtual threads ou adotar um modelo reativo que use um Scheduler baseado em virtual threads.
Além disso, contextos dependem de thread: TransactionContext, SecurityContextHolder (thread-local), MDC (logging). Um despacho incorreto entre threads pode perder esses contextos, quebrando segurança, logs ou transações.
Onde virtual threads realmente ajudam?
Virtual threads são especialmente úteis quando há muitas operações I/O bloqueantes independentes: chamadas a bancos, serviços legados via HTTP, filas ou operações de arquivo. Elas permitem que você mantenha um modelo de programação imperativa, mas reduzir o custo por sessão e aumentar concorrência sem inflar o número de threads de plataforma.
Use virtual threads como um recurso para atender bursts de tráfego e simplificar código; não é uma bala de prata. Para fluxo end-to-end reativo, WebFlux ainda tem vantagens em latência determinística e controle de backpressure.
Tip: comece migrando camadas de serviço que fazem I/O bloqueante e preservando controllers até validar comportamento. Mude progressivamente.
Arquitetura e decisões de design
Defina objetivos claros antes de migrar: reduzir uso de threads de plataforma? Aumentar throughput com mesmo número de conexões DB? Ou melhorar densidade de richiesta por CPU? Essas metas determinam onde aplicar virtual threads e o que otimizar no HikariCP.
A decisão central é o modelo de execução das requisições:
- Continuar com Spring MVC e container servlet, mas despachar trabalho bloqueante para virtual threads (usando async DeferredResult/Servlet async).
- Migrar para WebFlux e usar Schedulers baseados em virtual threads para operações bloqueantes.
- Híbrido: manter endpoints críticos em WebFlux/Netty e outros em MVC com virtual threads.
Cada opção tem trade-offs. MVC + async mantém compatibilidade com APIs existentes; WebFlux exige adaptação mas oferece integração nativa com Reactor.
Preservando contexto: transações e segurança
Transações JPA dependem de um EntityManager ligado ao thread via ThreadLocal quando você usa @Transactional. Se você executar o trabalho em uma virtual thread distinta, a anotação @Transactional no controller não terá efeito automaticamente. Você tem duas alternativas práticas:
- Executar a lógica transacional dentro da mesma virtual thread, usando TransactionTemplate dentro dela.
- Propagar contexto manualmente, por exemplo, capturando a função que inicia a transação e executando-a no executor.
Segurança e MDC também precisam ser propagados se você trocar de thread. Use wrappers que capturem SecurityContext e MDC antes do submit e restaurem dentro da virtual thread.
Dimensionamento do pool de conexões
Com virtual threads você pode criar milhares de threads, mas o número de conexões JDBC não aumenta magicamente. O banco tem limites e o HikariCP deve controlar concurrency de conexões. Não aumente maximumPoolSize sem entender o comportamento: o ganho real vem de permitir muitas threads esperando por I/O enquanto apenas um número controlado de conexões acessa o banco.
Diretrizes:
- Defina maxPoolSize baseado em capacidade do banco e latência média das queries.
- Use timeouts conservadores (connectionTimeout, validationTimeout).
- Considere bulkheads e filas internas para limitar concorrência de chamadas JPA por endpoint.
Implementação com foco em escalabilidade
Aqui estão implementações concretas. Código executável e explicado em cada bloco.
Migrando controllers bloqueantes em Spring MVC usando DeferredResult e virtual threads
O exemplo abaixo mostra um controller Spring MVC que delega trabalho bloqueante para um ExecutorService de virtual threads e usa DeferredResult para liberar a thread do container enquanto o trabalho roda. Isso evita esgotar o pool do container.
O código cria um executor de virtual threads, submete a task que faz I/O bloqueante e completa o DeferredResult quando terminar.
package com.example.demo.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.async.DeferredResult;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@RestController
public class BlockingController {
private final ExecutorService vThreads = Executors.newVirtualThreadPerTaskExecutor();
@GetMapping("/blocking")
public DeferredResult<String> blockingEndpoint() {
DeferredResult<String> result = new DeferredResult<>();
vThreads.submit(() -> {
try {
// Simula chamada bloqueante (JDBC, HTTP síncrono etc.)
Thread.sleep(500); // substitua por I/O real
result.setResult("ok from virtual thread");
} catch (Exception e) {
result.setErrorResult(e);
}
});
return result;
}
}
O que acontece: a thread do container retorna imediatamente e fica disponível; a work é executada em uma virtual thread e o resultado é enviado quando pronto. Isso reduz a necessidade de threads de container, especialmente sob carga alta com I/O bloqueante.
Migrando para WebFlux e usando virtual threads como Scheduler
Se sua aplicação já usa WebFlux, você pode executar blocos de código bloqueante em virtual threads usando Schedulers.fromExecutorService. Isso preserva o modelo reativo e evita bloquear o event-loop.
package com.example.demo.webflux;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Scheduler;
import reactor.core.scheduler.Schedulers;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@RestController
public class ReactiveController {
private final ExecutorService vThreads = Executors.newVirtualThreadPerTaskExecutor();
private final Scheduler vScheduler = Schedulers.fromExecutor(vThreads);
@GetMapping("/reactive-blocking")
public Mono<String> reactiveBlocking() {
return Mono.fromCallable(() -> {
// Código JPA bloqueante/HTTP bloqueante
Thread.sleep(300); // substitua por operação real
return "ok reactive virtual thread";
}).subscribeOn(vScheduler);
}
}
O que acontece: o Mono roda a chamada bloqueante em uma virtual thread. O event-loop permanece livre. Esse padrão é simples e seguro quando você tem tarefas independentes que podem ser executadas fora do event-loop.
Executando operações JPA em virtual threads com TransactionTemplate
Executar JPA dentro de uma virtual thread precisa garantir que a transação esteja ativa no mesmo thread que usa o EntityManager. A forma segura é criar e controlar a transação explicitamente com TransactionTemplate dentro da virtual thread.
package com.example.demo.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@Service
public class UserService {
private final TransactionTemplate txTemplate;
private final ExecutorService vThreads = Executors.newVirtualThreadPerTaskExecutor();
public UserService(PlatformTransactionManager txManager) {
this.txTemplate = new TransactionTemplate(txManager);
}
public java.util.concurrent.CompletableFuture<String> fetchUserBlocking(Long id) {
return java.util.concurrent.CompletableFuture.supplyAsync(() -> {
// Aqui a transação é criada/restaurada dentro do virtual thread
return txTemplate.execute(status -> {
// chamar repository JPA aqui — exemplo hipotético
// userRepository.findById(id);
return "user-" + id; // substitua por chamada real
});
}, vThreads);
}
}
O que acontece: a TransactionTemplate cria uma transação no contexto da virtual thread. Como tudo ocorre na mesma thread virtual, o EntityManager thread-local funciona corretamente. Evite @Transactional no método chamador se o chamador estiver em outra thread.
Benchmarks e métricas
Medir antes e depois é obrigatório. Sem números você está no campo das hipóteses. Evite microbenchmarks locais; use ferramentas de carga reais como Gatling, k6 ou wrk em ambiente semelhante à produção.
Métricas chave a coletar
- Throughput (req/s)
- Latência p50/p95/p99
- Uso de CPU e memória
- Contagem de threads da JVM (plataforma vs virtual)
- Pool size ativo do HikariCP, número de conexões em uso
- Taxa de timeouts de conexão ou filas
Implemente métricas com Micrometer e exporte para Prometheus. Monitore Hikari metrics (hikaricp provides MBeans/metrics via Micrometer) e garbage collections.
Exemplo rápido de teste com k6
Segue um script minimal para k6 que simula alta concorrência com endpoints que fazem I/O bloqueante. Execute antes e depois da migração e compare.
// k6 script (javascript) - salvar como script.js
// usa 500 VUs por 60s para testar comportamento sob carga
import http from 'k6/http';
import { check } from 'k6';
export let options = {
vus: 250,
duration: '60s',
};
export default function () {
const res = http.get('http://localhost:8080/blocking');
check(res, { 'status 200': (r) => r.status === 200 });
}
O que fazer com os resultados: compare latências p95/p99, taxa de erros e número de conexões JDBC em uso. Se as requisições bloquearem menos threads de plataforma e as conexões ficarem estabilizadas, você verá aumento de throughput e menor latência sob carga.
Padrões avançados e extensibilidade
Após validar, adote padrões para manter o sistema robusto e extensível.
Bulkhead e limitação de concorrência
Mesmo com virtual threads, limitar concorrência para recursos caros (DB, serviços externos) é crucial. Implemente bulkheads com semáforos ou executors com filas limitadas. Exemplo: um Executor que aceita N tasks para operações JPA; se exceder, retorna 429 ou enfileira com timeout.
Use Resilience4j para circuit-breakers e rate-limiters combinados com bulkheads para proteger o banco.
Propagação de contextos (Security, MDC, Tracing)
Capture e propague SecurityContext, MDC e tracing context (OpenTelemetry) ao submeter para virtual threads. Crie wrappers utilitários que capturem e restaurem esses contextos. Exemplo rápido:
Runnable wrapWithContext(Runnable r) {
var security = org.springframework.security.core.context.SecurityContextHolder.getContext();
var mdc = org.slf4j.MDC.getCopyOfContextMap();
return () -> {
try {
if (mdc != null) org.slf4j.MDC.setContextMap(mdc);
org.springframework.security.core.context.SecurityContextHolder.setContext(security);
r.run();
} finally {
org.springframework.security.core.context.SecurityContextHolder.clearContext();
org.slf4j.MDC.clear();
}
};
}
O que acontece: o runnable preserva contexto de segurança e MDC dentro da virtual thread, evitando perda de informação de autenticação e logs consistentes.
Integração com HikariCP: tuning prático
HikariCP continua sendo o pool recomendado. Com virtual threads, você geralmente mantém o pool de conexões menor do que o número de threads. Algumas propriedades importantes:
- maximumPoolSize — número máximo de conexões simultâneas
- minimumIdle — número mínimo de conexões ociosas
- connectionTimeout — tempo que uma thread aguarda por uma conexão antes de erro
- validationTimeout e leakDetectionThreshold
- keepaliveTime — evita que conexões sejam fechadas em bancos que as descartam por inatividade
Exemplo de configuração no application.yml (Spring Boot):
spring:
datasource:
url: jdbc:postgresql://db:5432/appdb
username: app
password: secret
hikari:
maximum-pool-size: 20
minimum-idle: 2
connection-timeout: 30000
validation-timeout: 5000
leak-detection-threshold: 15000
keepalive-time: 300000
O que acontece: você limita conexões ativas a 20. Virtual threads podem ser milhares, mas apenas 20 estarão simultaneamente usando conexões. Ajuste 20 conforme latência média do banco e recursos.
Erros comuns, armadilhas e dicas práticas
Perder transação por mudar de thread
Erro: colocar @Transactional no controller e despachar a lógica para virtual thread; a transação não será aplicada. Solução: iniciar transação dentro da virtual thread usando TransactionTemplate ou garantir que @Transactional esteja no método executado dentro do executor.
Esquecer de propagar SecurityContext e MDC
Erro: logs sem correlação ou SecurityContext vazio ao acessar resources em virtual thread. Solução: usar wrappers para capturar e restaurar contextos antes do submit.
Dimensionar incorretamente o HikariCP
Erro: aumentar maxPoolSize para milhares em busca de throughput. Resultado: sobrecarregamento do banco, aumento de swaps e latência. Solução: dimensionar pool com base no banco e no tempo médio de query; combine com bulkhead.
Confundir virtual threads com threads não bloqueantes
Armadilha: assumir que tudo é safe. Algumas bibliotecas usam APIs nativas ou bloqueios que não liberam o kernel de forma eficiente; testes são essenciais. Virtual threads aliviam custo de threads, mas não tornam I/O instantâneo.
Dica: faça testes de integração com os drivers reais (JDBC, clientes HTTP) em ambientes que reproduzam latência de rede.
Conclusão prática
Virtual Threads permitem escalar aplicações Spring Boot que ainda usam I/O bloqueante sem reescrever toda a stack para reatividade. Estratégia recomendada:
- Identifique hotspots de I/O bloqueante e comece por migrar serviços internos usando executors de virtual threads.
- Use DeferredResult (MVC) ou Schedulers.fromExecutor (WebFlux) para não bloquear threads do container/event-loop.
- Garanta que transações JPA sejam iniciadas dentro da mesma virtual thread (TransactionTemplate) ou mova @Transactional para métodos que rodem no executor.
- Tune HikariCP com um pool razoável e adicione bulkheads para proteger o banco.
- Automatize testes de carga e monitore métricas (throughput, latências, conexões Hikari, threads).
Implementação gradual, cuidados com contexto e limites no pool de conexões são os pontos chave. Virtual threads reduzem o custo por request e simplificam código imperativo, mas exigem disciplina operacional: métricas, testes e padrões de proteção (bulkhead, circuit-breaker, timeouts).
Aplicando as técnicas mostradas aqui você terá aumento real de throughput e densidade de requests por CPU sem sacrificar a consistência transacional e integridade do sistema.
Assuntos: Java, spring-boot, virtual-threads, jpa, hikaricp, microservices
Leia também
Virtual Threads no Spring Boot: otimizar JDBC/Hikari em microservices
Guia prático para usar virtual threads em Spring Boot e otimizar JDBC/Hikari em microservices: corrija anti-patterns, configure Hikari corretamente e aplique…
Circuit Breaker com Resilience4j no Spring Boot: tutorial passo a passo
Aprenda passo a passo a proteger chamadas remotas em Spring Boot usando Resilience4j. Conceitos, configuração, código comentado, exemplos práticos e…
Evitar exaustão de conexões JDBC ao usar Virtual Threads no Spring Boot
Evite exaustão do pool JDBC ao adotar virtual threads no Spring Boot: identifique o erro comum (criar virtual threads sem limites) e aplique padrões práticos…
Processando grandes consultas JPA sem OOM: Stream, fetchSize e batches
Técnicas práticas para processar grandes consultas JPA sem OOM: use stream, ajuste fetchSize e processe em batches com limpeza do persistence context para…
Outbox transacional com Spring Boot, JPA e Kafka — implementação prática
Implementação prática do padrão Outbox com Spring Boot, JPA e Kafka: gravação atômica de eventos, poller confiável, idempotência do consumidor e técnicas para…
Migrando endpoints bloqueantes para Virtual Threads no Spring Boot (passo a passo)
Guia prático para migrar endpoints bloqueantes no Spring Boot para virtual threads (Java 21). Padrões, código funcional, tratamento de transações, segurança e…