Destino Dev

Blog

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:

  1. Executar a lógica transacional dentro da mesma virtual thread, usando TransactionTemplate dentro dela.
  2. 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:

  1. Identifique hotspots de I/O bloqueante e comece por migrar serviços internos usando executors de virtual threads.
  2. Use DeferredResult (MVC) ou Schedulers.fromExecutor (WebFlux) para não bloquear threads do container/event-loop.
  3. Garanta que transações JPA sejam iniciadas dentro da mesma virtual thread (TransactionTemplate) ou mova @Transactional para métodos que rodem no executor.
  4. Tune HikariCP com um pool razoável e adicione bulkheads para proteger o banco.
  5. 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