Spring Boot 3: habilitando Virtual Threads para APIs REST bloqueantes
Por John Pires · 10 de março de 2026

Spring Boot 3: habilitando Virtual Threads para APIs REST bloqueantes
Motivacao e trade-offs
Porque usar virtual threads em APIs bloqueantes
Virtual threads (Project Loom) permitem criar milhões de threads leves, reduzindo a necessidade de modelos reativos quando a maior parte do trabalho é bloqueante — por exemplo, JDBC, chamadas HTTP a serviços legados ou bibliotecas sincrônicas. Em vez de aumentar um pool fixo de threads e correr risco de esgotamento ou alta latência por fila, você pode delegar tarefas bloqueantes para virtual threads e manter alta concorrência com latência previsível.
Trade-offs e limitações práticas
Virtual threads não são mágica: elas aliviam contenção de threads, mas não aceleram I/O. Sistemas com muitos locks grossos, conteúdo intensivo de CPU ou bibliotecas que fazem uso indevido de ThreadLocal podem não ter ganhos ou podem até piorar. Implementar virtual threads implica revisar o gerenciamento de transações, propagação de contexto (SecurityContext, MDC), e integração com containers/servlets.
Resumo prático: se sua API faz muito bloqueio (JDBC, redis-sync, chamadas SOAP antigas) e você quer simplicidade de programação imperativa, virtual threads podem reduzir custos operacionais. Se você já usa WebFlux e a maior parte do caminho é não bloqueante, ganhos são menores.
Fundamentos tecnicos
Como o Spring lida com async em MVC
Spring MVC aceita retornos assíncronos: Callable, DeferredResult, WebAsyncTask e CompletableFuture. Quando o controller retorna um CompletableFuture, o container é liberado e o resultado é escrito quando a future completa. Isso é o ponto chave: você precisa executar o trabalho bloqueante em virtual threads e devolver a CompletableFuture imediatamente.
Coisas a ajustar: transacoes, Security, MDC
Transações em Spring (e sessão do Hibernate) são normalmente associadas ao thread atual. Se você dispara uma tarefa em outra thread, o @Transactional do controller não alcança essa thread. Você tem duas opções: (1) criar/propagar a transação dentro da virtual thread (usando TransactionTemplate/PlatformTransactionManager), ou (2) manter a execução transacional no mesmo thread (não recomendado, porque você perderá o benefício do offload).
Segurança e logging também dependem de ThreadLocal. Spring Security fornece utilitários para propagar SecurityContext, e MDC (SLF4J) precisa ser copiado manualmente. Sem isso, logs e autorizações podem falhar silenciosamente.
Exemplo 1: caso simples
Contexto: controller com chamada bloqueante simulada
Objetivo: mostrar a forma mínima e funcional de liberar o thread container e executar trabalho bloqueante em virtual threads usando CompletableFuture e newVirtualThreadPerTaskExecutor(). Vamos simular um call blocking I/O com Thread.sleep e ver o comportamento.
// Configuração de executor de virtual threads (Bean Spring)
package com.example.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@Configuration
public class VirtualThreadConfig {
@Bean(destroyMethod = "shutdown")
public ExecutorService virtualThreadExecutor() {
// Cria um ExecutorService que cria uma virtual thread por tarefa
return Executors.newVirtualThreadPerTaskExecutor();
}
}
O código acima cria um ExecutorService que produz uma nova virtual thread por tarefa. O bean é encerrado no shutdown do contexto.
// Controller simples que usa o executor para trabalho bloqueante
package com.example.web;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
@RestController
public class SimpleController {
private final ExecutorService virtualExecutor;
public SimpleController(ExecutorService virtualExecutor) {
this.virtualExecutor = virtualExecutor;
}
@GetMapping("/slow")
public CompletableFuture<ResponseEntity<String>> slow() {
// Retorna imediatamente; o container thread é liberado
return CompletableFuture.supplyAsync(() -> {
// trabalho bloqueante rodando em virtual thread
try {
Thread.sleep(2000); // simula I/O bloqueante
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
return ResponseEntity.ok("done");
}, virtualExecutor);
}
}
Ao acessar /slow, o servlet container não fica preso: a requisição retorna control para o pool do container e a resposta é enviada quando a CompletableFuture completa na virtual thread.
O que acontece na prática
Executando localmente com um servidor embarcado (Tomcat/Jetty), você verá threads do container com nomes normais e muitas virtual threads criadas pelo executor. Latência por requisição é aproximadamente o tempo do bloqueio. Número de requisições simultâneas que o servidor aguenta aumenta dramaticamente em comparação com um pool de threads limitado.
Exemplo 2: caso avancado do mundo real
Contexto: JPA + transacao + SecurityContext
Cenário: um endpoint que consulta banco via Spring Data JPA (EntityManager/Hibernate) e precisa rodar em virtual thread. É preciso garantir que a transação e o EntityManager estejam abertos na virtual thread, e que o SecurityContext (Spring Security) esteja disponível para autorização/logging.
Solução: TransactionTemplate + Delegacao de Security + copia de MDC
Abordagem recomendada: não confie no @Transactional do controller ao offloadar. Use PlatformTransactionManager / TransactionTemplate dentro da virtual thread para abrir uma transação local. Capture e restaure SecurityContext e MDC no wrapper do executor.
// Helper que propaga SecurityContext e MDC para o executor
package com.example.util;
import org.slf4j.MDC;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class ContextAwareVirtualExecutor {
private final ExecutorService delegate;
public ContextAwareVirtualExecutor() {
this.delegate = Executors.newVirtualThreadPerTaskExecutor();
}
public <T> Future<T> submit(java.util.concurrent.Callable<T> task) {
SecurityContext ctx = SecurityContextHolder.getContext();
Map<String, String> mdc = MDC.getCopyOfContextMap();
return delegate.submit(() -> {
SecurityContextHolder.setContext(ctx);
if (mdc != null) MDC.setContextMap(mdc);
try {
return task.call();
} finally {
SecurityContextHolder.clearContext();
MDC.clear();
}
});
}
public void shutdown() {
delegate.shutdown();
}
}
O helper acima captura SecurityContext e MDC do thread que submeteu a tarefa e os aplica na virtual thread antes de executar. Isso evita problemas com logs sem identificação do usuário e autorizações que dependem do contexto atual.
// Service que executa consulta JPA em transacao dentro da virtual thread
package com.example.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionCallback;
import org.springframework.transaction.support.TransactionTemplate;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
@Service
public class PersonService {
@PersistenceContext
private EntityManager em;
private final TransactionTemplate txTemplate;
private final ExecutorService virtualExecutor;
public PersonService(PlatformTransactionManager txManager, ExecutorService virtualExecutor) {
this.txTemplate = new TransactionTemplate(txManager);
this.virtualExecutor = virtualExecutor;
}
public CompletableFuture<Person> findPersonAsync(Long id) {
return CompletableFuture.supplyAsync(() ->
txTemplate.execute((TransactionCallback<Person>) status -> {
// Esta consulta ocorre dentro de uma transacao ativa na virtual thread
return em.find(Person.class, id);
})
, virtualExecutor);
}
}
Neste exemplo, TransactionTemplate abre uma transação dentro da execução da virtual thread, garantindo que o EntityManager/Hibernate funcionem corretamente.
Controller usando o serviço
O controller agora pode retornar a CompletableFuture diretamente; a transação é criada no momento da execução na virtual thread.
// Controller que retorna o Person assíncrono
package com.example.web;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.CompletableFuture;
@RestController
public class PersonController {
private final PersonService personService;
public PersonController(PersonService personService) {
this.personService = personService;
}
@GetMapping("/person/{id}")
public CompletableFuture<ResponseEntity<Person>> getPerson(@PathVariable Long id) {
return personService.findPersonAsync(id)
.thenApply(person -> person == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(person));
}
}
Note que o controller não tem @Transactional; a transação é controlada explicitamente no serviço.
Debugging e troubleshooting
Erros comuns e armadilhas
Listagem de problemas que aparecem frequentemente ao migrar para virtual threads:
- Transacao perdida: usar @Transactional no controller e então offloadar faz com que a transação não esteja presente na virtual thread — resultado: LazyInitializationException.
- Contexto de seguranca ausente: sem propagar SecurityContext, autenticação/autorização falham em pontos do código que dependem disso.
- MDC vazio: logs perdem correlação (requestId, userId) porque MDC não é copiado automaticamente.
- Bibliotecas nativas com dependência de thread: algumas bibliotecas usam identificadores de thread nativo ou dependem de endereço de thread; verificar compatibilidade.
- Monitoramento e thread dumps: virtual threads aparecem de forma diferente em ferramentas; aprenda a interpretar stack traces com muitas virtual threads.
Dica: sempre escreva testes de carga com condições reais (DB, clientes externos) antes de migrar produção. Um teste unitário não revela contention de locks e problemas de conexão.
Como diagnosticar problemas específicos
Para LazyInitializationException, verifique se a transação está ativa dentro da virtual thread. Para problemas de autorização, verifique o SecurityContext com pontos de log antes de entrar no código que exige autorização. Para problemas de logging, inspecte MDC no início da execução na virtual thread.
Ferramentas úteis:
- jstack/jcmd para thread dumps (virtual threads aparecem com prefixo Virtual no jstack moderno)
- flight recorder (JFR) para latência e bloqueios
- actuator /metrics e /threaddump do Spring Boot para exposição de métricas e diagnóstico
Conclusao pratica
Checklist para habilitar virtual threads em um projeto Spring Boot MVC
Use esta lista como roteiro mínimo para aplicar virtual threads com segurança:
- Crie um ExecutorService com newVirtualThreadPerTaskExecutor() e exponha como bean.
- Submeta trabalho bloqueante com CompletableFuture.supplyAsync(..., virtualExecutor) ou use Callable/DeferredResult e executar no executor.
- Garanta transações explicitamente dentro da virtual thread com TransactionTemplate ou gerencie PlatformTransactionManager.
- Propague SecurityContext e MDC para as virtual threads (utilitários ou wrappers).
- Teste carga real e monitore conexões de DB para evitar esgotamento de recursos externos.
Resumo final e recomendações
Virtual threads são uma ferramenta prática para modernizar APIs REST bloqueantes sem reescrever a aplicação para um stack reativo. A transição exige disciplina: criar executor, propagar contexto e ajustar transações. Em muitos casos, é a solução de menor custo para melhorar concorrência. Se o sistema for fortemente não-bloqueante e já for reativo, mantenha WebFlux; se for bloqueante com libs legadas, considere virtual threads.
Implementar os exemplos deste artigo em um projeto real envolve validar a compatibilidade das bibliotecas e garantir que o banco e clientes externos aguentem o aumento de concorrência. Planeje testes de carga, monitore limites e não copie padrões sem considerar os pontos citados.
Próximo passo prático: adicione o bean de ExecutorService, adapte um endpoint crítico para CompletableFuture com TransactionTemplate e execute um teste de carga controlado. Ajustes finos virão depois do profiling.
Assuntos: spring-boot, Java, virtual-threads, loom, jpa
Leia também
Virtual Threads no Spring Boot: crie uma API concorrente passo a passo
Aprenda a usar virtual threads com Spring Boot para criar APIs concorrentes sem reescrever tudo. Veja code-review: o jeito errado, o certo, configuração…
Virtual Threads no Spring Boot: migrar controllers bloqueantes, JPA e tuning do HikariCP
Guia prático para migrar controllers bloqueantes e JPA para virtual threads no Spring Boot, com exemplos reais, setup do HikariCP, armadilhas e padrões para…
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…
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…
JPA Embeddables: criar e usar Value Objects (Address, Money) passo a passo
Aprenda passo a passo a criar e usar JPA Embeddables para value objects (Address, Money). Exemplos Java, boas práticas, armadilhas comuns e código funcional…
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…