Spring Boot 4 Virtual Threads default - codigo blocking escala como reativo - Meu Universo Nerd

Existe uma forma de multiplicar o throughput da sua API sem reescrever uma linha de código de negócio, e a maioria dos devs ainda nem percebeu que ela chegou ligada de fábrica. No Spring Boot 4, Virtual Threads são o comportamento padrão no Tomcat e no Jetty. Aquele mesmo @RestController blocking que você escreve desde 2018 passa a segurar milhares de requisições concorrentes no mesmo hardware.

Durante anos a resposta para "como escalar isso aqui" foi reescrever tudo em WebFlux, com Flux, Mono e uma curva de aprendizado que afastava metade do time. O Spring Boot 4 muda essa conversa. Neste artigo você vai ver, com código de produção, o que realmente muda no seu projeto, quando o ganho é real e quando ele não acontece.

O modelo que segurou o Java por 25 anos (e onde ele trava)

Para entender por que isso é grande, vale lembrar como o Java sempre tratou concorrência em servidor. No modelo clássico, cada requisição HTTP que chega no seu Tomcat pega uma platform thread, que nada mais é do que uma thread Java mapeada um para um com uma thread do sistema operacional. Cada uma dessas threads reserva algo entre 512 KB e 1 MB de stack. Faça a conta: um pool de 200 threads já compromete perto de 200 MB de memória só para ter gente esperando resposta de banco.

O detalhe cruel é que, durante uma chamada de I/O (consulta no banco, request para outro microsserviço, leitura de fila), a thread fica bloqueada. Ela não faz nada útil, mas continua ocupando um slot do pool. Pense num caixa de banco que atende um cliente, pede um documento, e fica parado olhando para o cliente enquanto ele procura o papel na bolsa. O caixa está ocupado sem produzir. É exatamente assim que uma platform thread se comporta esperando um SELECT demorar 300 ms.

@GetMapping("/pedidos/{id}")
public Pedido buscarPedido(@PathVariable Long id) {
    // A thread fica parada aqui enquanto o banco responde.
    // Pool de 200 threads, no maximo 200 requests simultaneos esperando I/O.
    return pedidoRepository.findById(id).orElseThrow();
}

Com 200 threads e 100 ms de I/O por request, o teto teórico fica perto de 2.000 requisições por segundo. Suba a latência do banco para 500 ms, num dia ruim de rede, e você despenca para 400 req/s com a mesma máquina. Foi aqui que muita gente, inclusive eu, partiu para a programação reativa achando que era a única saída.

O problema é que escalar com WebFlux cobra um preço alto: a stack trace vira um quebra-cabeça, o debug fica doloroso, e qualquer dev novo no time leva semanas para ser produtivo com Flux e Mono. Como Tech Leader, aprendi na prática que adotar reactive sem necessidade real é trocar um gargalo de performance por um gargalo de manutenção. O time inteiro paga essa conta todo sprint.

O que o Spring Boot 4 mudou de verdade

Virtual Threads (o resultado do Project Loom) chegaram estáveis no Java 21 e amadureceram de vez no Java 25 LTS. A ideia é simples e poderosa: a virtual thread é gerenciada pela JVM, não pelo SO. Ela custa alguns poucos KB e, quando bate num ponto de I/O, é "desmontada" e tirada da carrier thread, que fica livre para tocar outro trabalho. Quando o I/O termina, a JVM remonta a virtual thread em qualquer carrier disponível e a execução continua de onde parou.

Voltando à analogia do banco: agora o caixa não fica parado olhando o cliente procurar o documento. Ele atende outra pessoa e, quando o primeiro cliente encontra o papel, retoma o atendimento. O mesmo caixa (a carrier thread) serve dezenas de clientes "ao mesmo tempo", porque ninguém o prende durante a espera.

A novidade do Spring Boot 4, anunciada no blog oficial do Spring, é que isso deixou de ser opt-in. Na 3.2, você precisava ligar a chave manualmente. No 4, Virtual Threads são o padrão para o servidor web embarcado (Tomcat e Jetty). O framework passou a tratar isso como cidadão de primeira classe, ao lado de Project Leyden para startup rápido e do Spring AI para integração com modelos.

Na 3.2 o ritual era este, e ele ainda funciona como override explícito:

# application.properties (Spring Boot 3.2: era preciso ligar na mao)
spring.threads.virtual.enabled=true

No Spring Boot 4, esse comportamento já vem ativo, como descreve a documentação oficial do Spring Boot. Você não precisa fazer nada para a maioria das aplicações web. E o ponto que mais surpreende o time na primeira vez: o código do controller continua exatamente igual. Mesma assinatura, mesma interface bloqueante, mesmo estilo que todo mundo já domina.

// Spring Boot 4: o controller NAO muda.
// Cada request agora roda na sua propria virtual thread.
@RestController
public class PedidoController {

    private final PedidoRepository pedidoRepository;

    PedidoController(PedidoRepository pedidoRepository) {
        this.pedidoRepository = pedidoRepository;
    }

    @GetMapping("/pedidos/{id}")
    public Pedido buscarPedido(@PathVariable Long id) {
        // Mesmo codigo blocking de sempre.
        // A virtual thread e suspensa no I/O e liberada a carrier thread.
        return pedidoRepository.findById(id).orElseThrow();
    }
}

Quer confirmar que o request está mesmo rodando numa virtual thread? Um log de uma linha resolve a dúvida no primeiro deploy:

@GetMapping("/health/thread")
public String qualThread() {
    Thread t = Thread.currentThread();
    // Em Spring Boot 4 deve retornar isVirtual=true
    return "thread=" + t.getName() + ", virtual=" + t.isVirtual();
}

Os números que justificam a migração

Sem virtual threads, aquele mesmo cenário de 500 ms de latência de banco com pool de 200 threads dava 400 req/s. Com virtual threads ligadas, você deixa de ter um teto artificial imposto pelo tamanho do pool: a JVM cria uma virtual thread por request e suspende todas elas durante o I/O. Benchmarks independentes publicados em 2026 (não material de marketing, e sim teste sob carga real) mostram a mesma aplicação saltando de algumas centenas de requisições simultâneas para milhares, no mesmo hardware, com latência p95 estável.

O ganho não vem de mágica. Ele vem de parar de desperdiçar memória e slots de pool em threads que só esperam. Quando o gargalo da sua aplicação é I/O (e na maioria dos sistemas de negócio é), o efeito é direto: mais conexões concorrentes seguradas pela mesma instância, menos pods para a mesma carga, conta de cloud menor no fim do mês. Para quem cuida de FinOps, esse é o argumento que fecha a discussão.

Vale a referência cruzada: se você quiser entender o problema oposto, quando o pool não é o vilão e o gargalo é CPU, dá uma olhada no nosso conteúdo sobre observabilidade e tuning de aplicações Java em produção, porque virtual thread não acelera cálculo pesado.

Quando NÃO contar com o ganho (e a armadilha do pinning)

Ligar virtual threads por padrão é ótimo, mas existe um caso onde o ganho some e um caso onde ele pode até virar problema. O primeiro é trabalho CPU-bound: compressão, criptografia, processamento de imagem, cálculo numérico. Aí a thread não está esperando I/O, está usando o processador. Virtual thread não multiplica núcleo de CPU, então o ganho é nulo. Para esse tipo de carga, o modelo de pool tradicional ainda faz todo sentido.

O segundo caso é o thread pinning. Quando seu código (ou uma lib que você usa) executa I/O dentro de um bloco synchronized, a virtual thread fica "presa" à carrier thread e não consegue ser desmontada. Na prática, você perde justamente o benefício que foi buscar. A boa notícia é que o Java 25, via JEP 491, resolveu boa parte desse problema histórico, soltando a virtual thread mesmo dentro de blocos synchronized na maioria dos casos. Ainda assim, libs antigas com locks em I/O merecem um teste de carga antes de subir.

// Padrao de risco: I/O dentro de synchronized pode causar pinning.
public Saldo consultarSaldo(Long contaId) {
    synchronized (lock) {
        // Em Java < 25 isto prende a virtual thread na carrier thread.
        return bancoExterno.buscarSaldo(contaId);
    }
}

// Preferir java.util.concurrent.locks.ReentrantLock para I/O concorrente.
private final ReentrantLock lock = new ReentrantLock();

Se você quer um roteiro de diagnóstico passo a passo desse problema, escrevi um material dedicado em como detectar thread pinning com Virtual Threads no Java 25. Ele mostra como ligar o -Djdk.tracePinnedThreads e ler o output sem se perder. E para quem ainda está decidindo a stack do projeto novo, o nosso comparativo de arquitetura de microsserviços com Spring Boot ajuda a separar o que é hype do que é necessidade real.

Perguntas frequentes

Preciso migrar para o Java 25 para usar isso? Virtual Threads existem desde o Java 21. O Spring Boot 4 exige Java 17 como mínimo, mas para ter virtual threads você precisa de Java 21 ou superior. O Java 25, sendo LTS e com a correção do pinning, é a escolha recomendada para produção em 2026.

Meu WebClient e meu RestTemplate continuam funcionando? Sim. A interface bloqueante deles continua igual. A JVM cuida da suspensão e retomada por baixo. É por isso que a migração é de baixo risco para a maioria das APIs de negócio.

Então o WebFlux morreu? Não morreu, virou nicho. Para streaming, backpressure de verdade e fontes de dados reativas de ponta a ponta, o modelo reativo ainda é a ferramenta certa. Para o CRUD com banco e chamadas a outros serviços, que é a maioria esmagadora dos sistemas, virtual thread entrega escala parecida com código muito mais simples.

Como desligo virtual threads se algo der errado? Basta um override explícito no application.properties com spring.threads.virtual.enabled=false. Você volta ao pool tradicional sem mexer no código, o que torna o rollback trivial.

Funciona com banco via JDBC e JPA? Funciona. JDBC é bloqueante e se beneficia diretamente, porque a virtual thread suspende durante a query. Só fique atento ao tamanho do pool de conexões do HikariCP, que continua sendo um limite real independente das threads.

Conclusão e próximo passo

O Spring Boot 4 fez algo raro: entregou ganho de escala de graça, sem pedir que você reaprenda a programar. Os três pontos para levar para o trabalho amanhã:

  • Virtual Threads são default no SB4, então o seu código blocking passa a escalar sem reescrita.
  • O ganho é real em apps I/O-bound (banco, APIs, filas) e nulo em carga CPU-bound.
  • Fique de olho no pinning com libs legadas, e prefira Java 25 LTS pela correção da JEP 491.

Você já ligou Virtual Threads na sua stack ou ainda está rodando tudo com pool tradicional? Conta nos comentários como está sendo na prática, principalmente se sua empresa já migrou para o Spring Boot 4.

Na próxima publicação eu vou pegar o outro lado dessa moeda: por que, mesmo com tudo isso, ainda existe vida para o WebFlux em 2026, e como decidir entre as duas stacks sem cair no hype. Fica ligado aqui no Meu Universo Nerd.