Destino Dev

Blog

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:

  1. jstack/jcmd para thread dumps (virtual threads aparecem com prefixo Virtual no jstack moderno)
  2. flight recorder (JFR) para latência e bloqueios
  3. 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