Destino Dev

Blog

Virtual Threads no Spring Boot: crie uma API concorrente passo a passo

Por John Pires · 19 de março de 2026

Virtual Threads no Spring Boot: crie uma API concorrente passo a passo

1) Por que aprender isso em 2026

Contexto técnico

Em 2026 virtual threads já estão maduros: JDK 21+ estabilizou Project Loom, e frameworks como Spring Boot adaptaram-se para tirar proveito dessa alternativa leve aos threads nativos (platform threads). Virtual threads reduzem o custo de criar milhares de threads para operações de I/O bloqueante, tornando designs antigos mais simples e seguros para cargas concorrentes elevadas.

Analogia simples

Pense em platform threads como carros grandes que consomem muita gasolina e virtual threads como bicicletas: uma bicicleta (virtual thread) é barata e rápida de encomendar quando precisa atravessar a cidade, mas ainda permite que você pare em frente a uma loja (bloqueio). A analogia ajuda iniciantes a entender por que criar 10.000 carros é inviável, mas 10.000 bicicletas é plausível.

Aprender virtual threads hoje significa entender como reescrever trechos bloqueantes sem migrar imediatamente para um modelo reativo (non-blocking). Em microservices com muitas integrações legadas (bancos, APIs externas), essa é uma ferramenta prática que simplifica código.

Do ponto de vista de arquitetura, virtual threads mudam trade-offs: menos complexidade em código, menos necessidade de callbacks/fluxos reativos, mas você precisa conhecer armadilhas como usar bibliotecas que assumem platform threads (ex.: certas implementações de thread-local).

2) Configuração do ambiente

Requisitos mínimos

Para seguir os exemplos use JDK 21+ (ou superior), Maven 3.8+, e Spring Boot 3.2+ (versão hipotética compatível com Loom; em 2026 as releases principais já oferecem suporte). IDE como IntelliJ IDEA ou VS Code ajudam, mas o exemplo roda via linha de comando.

Estrutura do projeto

Crie um projeto Spring Boot básico com dependências: spring-boot-starter-web, spring-boot-starter-data-jpa (se for usar banco), e opcionalmente spring-boot-starter-test. Para simular I/O, usaremos chamadas bloqueantes (Thread.sleep) em serviços: assim demonstramos ganho real com virtual threads.

Organização sugerida de pacotes: com.example.vthreaddemo com subpacotes controller, service, config, util. Código de exemplo a seguir assume Maven e empacotamento padrão do Spring Boot.

3) Hello World progressivo

O jeito errado (código de revisão)

Primeiro exemplo mostra uma prática ruim: criar platform threads por requisição. Em revisão de código recomendamos evitar criar threads manualmente dentro de endpoints — isso resulta em vazamentos e consumo descontrolado.

Contexto do bloco abaixo: é um Controller Spring Boot que cria um Thread manualmente para tratar a requisição. O código funciona, mas escala mal.

package com.example.vthreaddemo.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class BadController {

    @GetMapping("/bad")
    public String bad() throws InterruptedException {
        Thread t = new Thread(() -> {
            try {
                // simula trabalho bloqueante
                Thread.sleep(200);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
        t.start();
        // retorna imediatamente, mas o servidor criou uma platform thread por requisição
        return "accepted";
    }
}

O que acontece: esse código cria platform threads arbitrariamente. Em carga alta você abre milhares de threads nativas, consumindo memória e causando GC/oom. Em code review, apontamos: "não crie threads manualmente dentro de endpoints".

O jeito pior (executor sem limite)

Outro erro comum é usar um ExecutorService sem controle, por exemplo newCachedThreadPool. Funciona em poucos testes, mas explode sob carga.

package com.example.vthreaddemo.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

@RestController
public class UglyExecutorController {
    private final ExecutorService exec = Executors.newCachedThreadPool();

    @GetMapping("/ugly")
    public String ugly() {
        exec.submit(() -> {
            try { Thread.sleep(300); } catch (InterruptedException ignored) {}
        });
        return "accepted";
    }
}

O que acontece: newCachedThreadPool cria threads nativas ilimitadas se necessário, causando os mesmos problemas. Em revisão: prefira executors dimensionados ou virtual threads.

Correto com virtual threads

Agora o código certo: forneça um ExecutorService baseado em virtual threads e reutilize-o como bean Spring. Isso permite criar milhares de tarefas bloqueantes com baixo custo.

Contexto: configuramos um bean ExecutorService usando Executors.newVirtualThreadPerTaskExecutor() e usamos CompletableFuture.supplyAsync para delegar trabalho bloqueante. Spring serializará a resposta corretamente quando retornar CompletableFuture.

package com.example.vthreaddemo.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 virtualExecutor() {
        // cria um executor que aloca virtual threads por tarefa
        return Executors.newVirtualThreadPerTaskExecutor();
    }
}

O que acontece: o bean cria um ExecutorService que usa virtual threads. Ele deve ser reutilizado em toda a aplicação para evitar criar pools desnecessários.

package com.example.vthreaddemo.controller;

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 GoodController {

    private final ExecutorService vExec;

    public GoodController(ExecutorService vExec) {
        this.vExec = vExec;
    }

    @GetMapping("/virtual")
    public CompletableFuture<String> virtual() {
        return CompletableFuture.supplyAsync(() -> {
            try { Thread.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            return "done";
        }, vExec);
    }
}

O que acontece: cada requisição aciona uma virtual thread barata. Sob carga, a latência se mantém melhor sem esgotar memória com threads nativas.

4) Mini-projeto prático

Objetivo e requisitos

Vamos construir um micro serviço simples que recebe N requisições concorrentes que executam uma operação bloqueante simulada (chamada a um banco). O objetivo é comparar três abordagens: threads manuais, executor nativo ilimitado, e virtual threads.

Premissas: Spring Boot app com endpoints /bad, /ugly, /virtual. Uso de in-memory e simulação com Thread.sleep para I/O.

Implementação: serviço bloqueante simulado

Contexto: serviço que representa um repositório legado que bloqueia. É usado pelos controllers acima. O exemplo abaixo é uma classe utilitária que simula latência de I/O.

package com.example.vthreaddemo.service;

public class BlockingService {

    public String query() {
        // simula latência de banco
        try {
            Thread.sleep(150);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return "interrupted";
        }
        return "ok";
    }
}

O que acontece: cada chamada bloqueia a thread atual por 150ms. Esse comportamento é comum em drivers JDBC clássicos.

Teste de carga simples com Java HttpClient

Contexto: carrego a API enviando 200 requisições concorrentes para comparar resultados. Esse snippet é um cliente Java que roda localmente para gerar carga.

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;

public class LoadTest {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newHttpClient();
        List<CompletableFuture<HttpResponse<String>>> list = new ArrayList<>();
        for (int i = 0; i < 200; i++) {
            HttpRequest req = HttpRequest.newBuilder()
                    .uri(new URI("http://localhost:8080/virtual"))
                    .GET()
                    .build();
            list.add(client.sendAsync(req, HttpResponse.BodyHandlers.ofString()));
        }
        CompletableFuture.allOf(list.toArray(new CompletableFuture[0])).join();
        System.out.println("done");
    }
}

O que acontece: o cliente dispara 200 requisições paralelas. Compare memória, tempo de resposta e erros entre endpoints /bad, /ugly e /virtual para ver o impacto das escolhas.

Observações práticas

Ao executar o load test, observe o uso de memória, criação de threads e latência. Com virtual threads a aplicação costuma manter latência estável e usar menos heap para estado de thread.

Dica: Sempre reproduza cenários reais de I/O. Simular apenas CPU-bound não revela ganho de virtual threads.

5) Recursos para continuar estudando

Referências oficiais

Leia documentação do JDK sobre Project Loom, notas de release do JDK 21+ e guias do Spring Boot que mencionam virtual threads e suporte a executors. Esses recursos explicam internals e limitações.

Artigos e leitura adicional

Procure por posts técnicos que comparam virtual threads e modelos reativos, exemplos de bibliotecas ainda não compatíveis e benchmarks realistas. Repositórios no GitHub com exemplos práticos ajudam a entender trade-offs.

  • Documentação JDK Project Loom
  • Guia do Spring Boot sobre integração com executors
  • Exemplos de benchmark e microservices

Erros comuns, armadilhas e dicas práticas

Erros comuns

1) Assumir que toda biblioteca é segura para virtual threads. Algumas usam APIs internas ou thread-local que supõem platform threads.

2) Criar executors virtual-thread por requisição em vez de reutilizar um bean global. Isso causa overhead na criação de pools e confunde lifecycle.

Dicas práticas

1) Reutilize um ExecutorService virtual como bean. Dê destroyMethod para shutdown.

2) Não misture blocking-code com frameworks que exigem non-blocking sem entender compatibilidade. Virtual threads permitem manter blocking-code sem reescrever, mas não resolvem bibliotecas que usam locks pesados.

Exemplo de revisão: "Substituir newCachedThreadPool por Executors.newVirtualThreadPerTaskExecutor() e expor como @Bean para reaproveitamento".

Conclusão prática

Virtual threads são uma ferramenta pragmática para modernizar serviços que ainda dependem de I/O bloqueante. Em code review, a recomendação é clara: não crie platform threads manualmente; prefira um Executor virtual bem gerenciado. Isso simplifica código e melhora escalabilidade sem a complexidade do modelo reativo.

Resumo das ações imediatas que você pode aplicar hoje:

  1. Atualize para JDK 21+ e Spring Boot compatível.
  2. Exponha um ExecutorService virtual como bean e reutilize-o.
  3. Substitua criadores de threads manuais e executors ilimitados por virtual threads.
  4. Teste com carga realista e monitore thread count, GC e latência.

Finalizando: a migração para virtual threads é um passo incremental e reversível. A estratégia recomendada é começar por endpoints que fazem chamadas bloqueantes e iterar: primeiro trocar o modelo de threads, depois avaliar possíveis refactors para alternativas reativas apenas quando realmente necessário.

Se precisar, forneço um repositório de exemplo com todo o código pronto para rodar e scripts de carga para comparar os endpoints. Em code review, a mudança é simples e de alto impacto: menos recursos, menos complexidade, mesma lógica.

Assuntos: Java, spring-boot, virtual-threads, loom, concurrency

Leia também