Migrando endpoints bloqueantes para Virtual Threads no Spring Boot (passo a passo)
Por John Pires · 10 de março de 2026

Cenario pratico do dia a dia
Você tem endpoints REST que realizam operações bloqueantes: chamadas a banco via JPA, chamadas HTTP síncronas a serviços externos, leitura/gravação de arquivos ou processamento pesado. Em produção, sob carga, a latência sobe e o número de threads necessárias para manter o throughput explode. A consequência direta: filas de requisições, aumento do GC, timeouts e conexões de banco esgotadas.
Com Java 21 e Project Loom, virtual threads entregam uma alternativa operacional: threads leves que permitem multiplexar um grande número de tarefas bloqueantes sem a mesma pressão sobre o kernel. Mas migrar endpoints bloqueantes para virtual threads em um aplicativo Spring Boot envolve decisões arquiteturais e armadilhas práticas — transações, thread-local, e pools de conexões continuam existindo.
Este cenário assume um serviço monolítico ou microserviço Spring Boot que usa Spring MVC (imperativo) e Spring Data JPA com HikariCP. O objetivo é reduzir latência sob carga alta e aumentar concorrência aparente sem mudar toda a stack para reativo.
Problema típico
Endpoints do tipo GET /users/{id} chamam userRepository.findById(id) dentro do controller. Cada chamada bloqueia uma thread do container (Tomcat/Jetty/Undertow). Quando o sistema recebe milhares de requisições simultâneas, você precisa de milhares de threads ou terá filas. Virtual threads permitem executar cada requisição em sua própria virtual thread barata, mas o banco ainda limita simultaneidade via pool de conexões: a solução precisa coerência entre threads e recursos externos.
Alternativas avaliadas
- Migrar para WebFlux (reactive) — rejeitado por custo de refatoração e por bibliotecas bloqueantes (JPA) que exigiriam drivers/outra abordagem.
- Usar thread pool grande com platform threads — não escala bem e aumenta GC/overhead.
- Usar virtual threads para offload dos pontos bloqueantes mantendo Spring MVC — escolhido por menor blast radius e compatibilidade com JPA.
Tip: virtual threads resolvem o problema de multiplexação de threads, mas não aumentam recursos externos (DB, serviços). Planeje ajuste de pool e observabilidade.
Arquitetura da solucao
A arquitetura proposta mantém Spring MVC e JPA. Diferença: todas as operações bloqueantes são executadas em virtual threads gerenciadas por um ExecutorService criado com Executors.newVirtualThreadPerTaskExecutor().
Componentes principais:
- Bean ExecutorService de virtual threads (único ponto).
- Controller que delega chamadas de I/O para esse executor via CompletableFuture/Callable.
- Serviço anotado com @Transactional sendo invocado dentro da virtual thread (evita proxy-bypass).
- Mecanismos para propagar segurança/RequestContext/ThreadLocal quando necessário.
Fluxo
Requisição HTTP chega ao container — container cria uma carrier thread (plataform thread) que faz pouca coisa: monta a tarefa, submete ao virtual executor e retorna resposta de forma assíncrona quando a futura completar. O trabalho bloqueante (JPA, IO) roda em virtual thread, liberando carrier threads para aceitar novas conexões.
Decisões importantes
- Transaction scope: executar a lógica @Transactional dentro da virtual thread (invocando bean do serviço) para que a transação seja aberta naquela thread.
- Thread-locals: SecurityContext e RequestAttributes não são automaticamente replicados; usar utilitários do Spring Security ou wrappers.
- Pool de conexões: dimensionar HikariCP para refletir a nova concorrência (virtual threads não aumentam conexões de DB).
Codigo completo com explicacoes
Segue um conjunto mínimo e funcional: configuração do executor, controller, serviço JPA e ajuste do HikariCP. Código pronto para ser copiado em um projeto Spring Boot com Java 21.
Configuração do Executor de Virtual Threads
O bean abaixo cria um ExecutorService de virtual threads e garante shutdown no encerramento do contexto Spring. Esse é o único executor compartilhado por controllers/serviços que precisarem executar bloqueios.
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 virtualThreadPerTaskExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
}
O bean cria um executor que lança uma nova virtual thread por tarefa. O destroyMethod garante que o executor será encerrado no shutdown do Spring.
Controller que delega para virtual thread
Controller simples que retorna CompletableFuture. Observe que a chamada ao serviço é feita dentro da lambda, garantindo que a transação (anotada no serviço) seja aberta na virtual thread.
package com.example.web;
import com.example.service.UserService;
import com.example.web.dto.UserDto;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
@RestController
@RequestMapping("/users")
public class UserController {
private final UserService userService;
private final ExecutorService virtualExecutor;
public UserController(UserService userService, ExecutorService virtualExecutor) {
this.userService = userService;
this.virtualExecutor = virtualExecutor;
}
@GetMapping("/{id}")
public CompletableFuture<ResponseEntity<UserDto>> getUser(@PathVariable Long id) {
return CompletableFuture.supplyAsync(() -> userService.findDtoById(id), virtualExecutor)
.thenApply(dto -> dto != null ? ResponseEntity.ok(dto) : ResponseEntity.notFound().build());
}
}
Contexto: método userService.findDtoById executa JPA e é @Transactional. Executando-o dentro da virtual thread garante que a transação é criada naquela thread.
Service com transacao e mapeamento
Exemplo do service que acessa o repository. Importante: o método é público e chamado via bean proxy (não chamar métodos internos diretamente).
package com.example.service;
import com.example.data.UserRepository;
import com.example.data.entity.User;
import com.example.web.dto.UserDto;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Transactional(readOnly = true)
public UserDto findDtoById(Long id) {
User user = userRepository.findById(id).orElse(null);
if (user == null) return null;
return new UserDto(user.getId(), user.getName(), user.getEmail());
}
}
Resultado: o acesso ao banco e mapeamento ocorrem na virtual thread. A transação e o EntityManager ficam vinculados a essa thread e funcionam normalmente.
Tratamento de erros e edge cases
Migrar para virtual threads introduz riscos operacionais. Aqui listamos erros comuns e soluções práticas.
Transacoes e proxy Spring
Armadilha: chamar um método @Transactional do mesmo bean por retorno direto (this.method()) contorna o proxy e a transação não é aplicada. Solução: invocar o serviço via bean injetado, como demonstrado acima; ou usar ApplicationContext.getBean(YourService.class) para obter o proxy e invocar nele.
Propagacao de SecurityContext e RequestAttributes
ThreadLocal como SecurityContextHolder e RequestContextHolder não são automaticamente copiadas para o executor. Duas estratégias:
- Usar DelegatingSecurityContextExecutorService do Spring Security para propagar SecurityContext.
- Capturar/replicar RequestAttributes manualmente se necessário (RequestContextHolder.setRequestAttributes).
Exemplo de wrapper que cria um executor de segurança:
package com.example.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.concurrent.DelegatingSecurityContextExecutorService;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@Configuration
public class SecurityExecutorConfig {
@Bean(destroyMethod = "shutdown")
public ExecutorService secureVirtualExecutor() {
ExecutorService v = Executors.newVirtualThreadPerTaskExecutor();
return new DelegatingSecurityContextExecutorService(v);
}
}
Ao usar esse executor, o SecurityContext do thread principal é replicado para as virtual threads executadas por ele. Se você precisa propagar também RequestAttributes, implemente wrapper que capture e aplique RequestContextHolder.
Timeouts, cancelamento e excecoes
Erros em virtual threads se manifestam como exceção em CompletableFuture. Trate exceções, forneça timeouts e converta para respostas HTTP adequadas.
return CompletableFuture.supplyAsync(() -> userService.findDtoById(id), virtualExecutor)
.orTimeout(2, java.util.concurrent.TimeUnit.SECONDS)
.handle((dto, ex) -> {
if (ex != null) {
// log e mapeamento para status
throw new org.springframework.web.server.ResponseStatusException(
org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR, "Erro interno", ex);
}
return dto != null ? ResponseEntity.ok(dto) : ResponseEntity.notFound().build();
});
Colocar um orTimeout previne que futures rodem indefinidamente (e acabem ocupando conexões de banco).
Performance e otimizacao
Virtual threads reduzem a necessidade de platform threads, mas não substituem tuning de recursos externos. Antes de migrar, meça e após migrar, compare com métricas reais.
Tunando HikariCP e DB
Virtual threads permitem milhares de requisições concorrentes, mas cada operação JPA consome uma conexão. Ajuste o pool de conexões com base em latência média e throughput alvo.
package com.example.config;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.sql.DataSource;
@Configuration
public class DataSourceConfig {
@Bean
public DataSource dataSource() {
HikariConfig cfg = new HikariConfig();
cfg.setJdbcUrl("jdbc:postgresql://db:5432/app");
cfg.setUsername("app");
cfg.setPassword("secret");
cfg.setMaximumPoolSize(50); // ajustar conforme teste de carga
cfg.setConnectionTimeout(30000);
return new HikariDataSource(cfg);
}
}
Regra prática: dimensione pool = (Rps * latencia_media_por_operação) / percentual_de_concorrencia_ativa. Use testes de carga para validar.
Medidas de observabilidade e testes
Recomendações práticas:
- Use ferramentas como Gatling, k6 ou wrk para simular carga realista.
- Monitore métricas: latência 95/99 perc, número de conexões ativas, uso de CPU, GC e filas do container.
- Compare cenários: sem virtual threads, com virtual threads, e com diferentes tamanhos de pool de DB.
Exemplo de resultado esperado: menor latência sob picos curtos e mais throughput observado antes de saturar o pool de conexões. Em alguns cenários o ganho é marginal se o DB for o gargalo absoluto.
Erros comuns, armadilhas e dicas práticas
Listagem direta de problemas que vi na prática e como contornar:
- Esquecer de executar @Transactional dentro da virtual thread — transações não são iniciadas se o método for chamado no thread original e depois a lógica executada em outro thread sem proxy.
- Ignorar SecurityContext: requisições autenticadas perdem contexto e falham em autorização. Use DelegatingSecurityContextExecutorService ou wrappers.
- Não ajustar pool de conexões — virtual threads aumentam concorrência aparente e você pode esgotar o pool de DB.
- Usar long-running CPU-bound tasks em virtual threads indiscriminadamente — virtual threads são ótimas para I/O bloqueante, mas CPU-bound deve ficar em um pool com tamanho controlado.
Tip: separar executors — um executor de virtual threads para I/O e outro executor com tamanho fixo para processamento CPU-bound evita competição por CPU.
Conclusao
Virtual threads oferecem uma forma pragmática de aumentar concorrência para endpoints bloqueantes mantendo a base de código imperativa do Spring Boot. A migração é, em grande parte, uma mudança de execução: criar um executor de virtual threads, submeter tarefas bloqueantes a ele e garantir que transações e contextos sejam corretamente propagados.
Não é mágica: os limites do mundo externo (banco, serviços remotos) continuam válidos e precisam ser dimensionados. A principal vantagem é reduzir o custo por thread e simplificar o design em comparação com arquiteturas reativas completas.
Checklist prático ao migrar:
- Identificar pontos bloqueantes (JPA, HTTP sync, IO).
- Criar ExecutorService de virtual threads e expor como bean.
- Garantir que @Transactional seja executado dentro da virtual thread.
- Propagar SecurityContext/RequestAttributes quando necessário.
- Ajustar pool de conexões e testar com carga real.
Seguindo este passo a passo você terá endpoints mais resilientes sob alta concorrência com um esforço de refatoração contido, mantendo os benefícios da pilha Spring e JPA.
Assuntos: Java, spring-boot, virtual-threads, jpa, Performance
Leia também
Multi-tenant em Spring Boot: schema-per-tenant com Hibernate, Flyway e routing
Implementação prática de schema-per-tenant em Spring Boot: routing do tenant, configuração Hibernate (MultiTenantConnectionProvider), e aplicação de migrações…
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…
Paginação por cursor (keyset) com Spring Boot + JPA: evitar OFFSET passo a passo
Aprenda passo a passo como substituir OFFSET por paginação por cursor (keyset) em Spring Boot + JPA. Exemplos práticos, entidade, repositório nativo…
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…
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…
Spring Boot 3: habilitando Virtual Threads para APIs REST bloqueantes
Guia prático para habilitar virtual threads em APIs REST bloqueantes com Spring Boot 3: configuração do executor, exemplos com CompletableFuture…