Destino Dev

Blog

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:

  1. Bean ExecutorService de virtual threads (único ponto).
  2. Controller que delega chamadas de I/O para esse executor via CompletableFuture/Callable.
  3. Serviço anotado com @Transactional sendo invocado dentro da virtual thread (evita proxy-bypass).
  4. 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:

  1. 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.
  2. Ignorar SecurityContext: requisições autenticadas perdem contexto e falham em autorização. Use DelegatingSecurityContextExecutorService ou wrappers.
  3. Não ajustar pool de conexões — virtual threads aumentam concorrência aparente e você pode esgotar o pool de DB.
  4. 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