Todo backend Java que precisa aguentar muita requisição simultânea esbarra no mesmo problema: threads de sistema operacional são caras. Cada uma reserva memória de stack, e o sistema operacional não segura mais que algumas milhares delas sem sofrer. Por anos, a resposta pra isso foi migrar pra uma stack reativa como: WebFlux, Reactor e callbacks, trocando código imperativo simples por um estilo bem mais difícil de ler e depurar. Foi tentando resolver esse tipo de gargalo sem pagar esse preço que as Virtual Threads chegaram, e o Java 25 finalmente deixou essa história bem mais madura do que era em 2023, quando o recurso foi lançado.

O problema com threads tradicionais

Uma thread de plataforma é um wrapper (empacotador) fino em cima de uma thread do sistema operacional. Isso custa caro de duas formas:

No modelo clássico de uma thread por requisição do Spring MVC, isso significa que o tamanho do pool de threads do Tomcat vira o teto de concorrência da aplicação. Aumentar esse pool ajuda até certo ponto mas depois disso, a própria máquina começa a sofrer antes mesmo do código de negócio ser o gargalo.

O que são Virtual Threads

Virtual Threads (JEP 444, finalizada no Java 21) são threads leves gerenciadas pela própria JVM, não pelo sistema operacional. Elas rodam por cima de um pool pequeno de threads de plataforma, chamadas de threads carrier e por padrão, um ForkJoinPool com uma thread carrier por núcleo de CPU disponível.

A mecânica central é essa: quando uma virtual thread bloqueia numa operação de I/O (uma leitura de socket, uma query no banco, um Thread.sleep), a JVM desmonta essa virtual thread da sua carrier e libera a carrier pra rodar outra virtual thread enquanto espera. Quando a operação bloqueante termina, a virtual thread é remontada em alguma carrier disponível pra continuar de onde parou.

Na prática, isso significa: você pode ter centenas de milhares de virtual threads bloqueadas em I/O ao mesmo tempo, ocupando só um punhado de threads reais de sistema operacional e continuar escrevendo código imperativo, bloqueante e fácil de ler, sem migrar pra reativo.

Habilitando no Spring Boot 4

A configuração é uma linha só:

spring.threads.virtual.enabled=true

Com essa propriedade ligada:

Pra comprovar isso rodando, faremos um endpoint simples que loga em qual thread está executando:

Controller:

package com.exemplo.pedidos.adapter.in.web;

import com.exemplo.pedidos.application.service.PedidoConsultaService;
import com.exemplo.pedidos.domain.PedidoDetalhes;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/pedidos")
public class PedidoConsultaController {

    private final PedidoConsultaService service;

    public PedidoConsultaController(PedidoConsultaService service) {
        this.service = service;
    }

    @GetMapping("/{id}")
    public PedidoDetalhes buscar(@PathVariable String id) {
        return service.buscarComDetalhes(id);
    }
}

Service:

package com.exemplo.pedidos.application.service;

import com.exemplo.pedidos.domain.PedidoDetalhes;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.time.Duration;

@Service
public class PedidoConsultaService {

    private static final Logger log = LoggerFactory.getLogger(PedidoConsultaService.class);

    public PedidoDetalhes buscarComDetalhes(String id) {
        log.info("Processando pedido {} na thread {}", id, Thread.currentThread());

        // Simula uma chamada bloqueante real: HTTP pra outro serviço, query no banco, fila...
        try {
            Thread.sleep(Duration.ofMillis(300));
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("Consulta interrompida", e);
        }

        return new PedidoDetalhes(id, "PROCESSADO");
    }
}

Domínio de detalhes do pedido (record):

package com.exemplo.pedidos.domain;

public record PedidoDetalhes(String id, String status) {}

Com spring.threads.virtual.enabled=true, o log mostra algo como VirtualThread[#48]/runnable@ForkJoinPool-1-worker-3. Sem a propriedade, o de sempre: http-nio-8080-exec-3. É a forma mais rápida de confirmar que a configuração pegou.

O que ainda ‘pina’ uma virtual thread no Java 25 (e o que já foi corrigido)

Pinning acontece quando uma virtual thread bloqueia mas não consegue desmontar da sua carrier. A carrier fica presa junto, esperando, exatamente como uma thread de plataforma comum. Se isso acontecer com frequência, você perde o principal benefício das virtual threads e pode até travar a aplicação, se todas as carriers ficarem ‘pinadas’ ao mesmo tempo.

Até o Java 23, o vilão número um disso era o synchronized: qualquer bloqueio dentro de um método ou bloco synchronized pinava a virtual thread inteira. Era tão comum que virou regra geral: “troque synchronized por ReentrantLock”. Boa parte dos artigos que você vai encontrar sobre o assunto ainda repete essa recomendação.

No Java 25, essa regra já não se aplica na maioria dos casos. A JEP 491, entregue no Java 24, reescreveu como o monitor de um synchronized é associado, agora ele pertence à virtual thread, não à carrier. Isso significa que hoje uma virtual thread desmonta normalmente mesmo bloqueando dentro de um synchronized, de um Object.wait(), ou esperando pra entrar num monitor.

O que ainda ‘pina’ no Java 25:

O segundo caso é o mais fácil de introduzir sem perceber:

public class ConfiguracaoExterna {

    // Isso roda uma única vez, na inicialização da classe e pina
    // a virtual thread que primeiro acessar ConfiguracaoExterna.
    private static final String CHAVE_API = carregarDeServicoExterno();

    private static String carregarDeServicoExterno() {
        try {
            Thread.sleep(Duration.ofMillis(200)); // I/O bloqueante durante o class loading
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return "chave-simulada";
    }

    public static String chaveApi() {
        return CHAVE_API;
    }
}

O efeito prático costuma ser só uma primeira requisição um pouco mais lenta (a classe carrega uma vez só), mas em sistemas com muita concorrência logo na inicialização, isso pode pinar várias carriers ao mesmo tempo. A saída é simples: não faça I/O dentro de um inicializador estático. Carregue esse tipo de configuração de forma explícita, num @PostConstruct ou num passo de startup, fora do caminho de requisições concorrentes.

Pra observar pinning em produção, o evento de Java Flight Recorder jdk.VirtualThreadPinned continua sendo a ferramenta certa. Ele dispara toda vez que uma virtual thread fica pinada, com a stack trace de onde isso aconteceu.

Contexto entre chamadas: Scoped Values no lugar do ThreadLocal

Propagar um dado como um correlationId, um usuário autenticado, entre métodos sem passar como parâmetro sempre foi trabalho da ThreadLocal. Ele continua funcionando com virtual threads, mas tem atritos: é mutável, exige remove() manual pra não vazar dado entre execuções, e sua propagação pra threads filhas quando você divide trabalho em paralelo é implícita e fácil de fazer errado.

O Java 25 finaliza os Scoped Values (JEP 506), pensados desde o início pra esse cenário: um valor imutável, com escopo de vida explícito e visível no próprio código.

package com.exemplo.pedidos.application;

public final class ContextoRequisicao {

    public static final ScopedValue<String> CORRELATION_ID = ScopedValue.newInstance();

    private ContextoRequisicao() {}

    public static String correlationIdAtual() {
        return CORRELATION_ID.orElse("desconhecido");
    }
}

Atualizando o controller pra vincular um correlationId durante a execução da requisição:

package com.exemplo.pedidos.adapter.in.web;

import com.exemplo.pedidos.application.ContextoRequisicao;
import com.exemplo.pedidos.application.service.PedidoConsultaService;
import com.exemplo.pedidos.domain.PedidoDetalhes;
import org.springframework.web.bind.annotation.*;

import java.util.UUID;

@RestController
@RequestMapping("/pedidos")
public class PedidoConsultaController {

    private final PedidoConsultaService service;

    public PedidoConsultaController(PedidoConsultaService service) {
        this.service = service;
    }

    @GetMapping("/{id}")
    public PedidoDetalhes buscar(@PathVariable String id) {
        String correlationId = UUID.randomUUID().toString();

        return ScopedValue.where(ContextoRequisicao.CORRELATION_ID, correlationId)
                .call(() -> service.buscarComDetalhes(id));
    }
}

E lendo o valor em qualquer método chamado dentro desse escopo, sem precisar recebê-lo como parâmetro, basta atualizar a linha de log do PedidoConsultaService mostrado antes:

public PedidoDetalhes buscarComDetalhes(String id) {
    log.info("Processando pedido {} | correlationId={}", id, ContextoRequisicao.correlationIdAtual());

    try {
        Thread.sleep(Duration.ofMillis(300));
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new IllegalStateException("Consulta interrompida", e);
    }

    return new PedidoDetalhes(id, "PROCESSADO");
}

Quando o .call(...) termina, o valor é automaticamente desvinculado, não existe remove() pra esquecer. Uma pegadinha comum: esse vínculo só é visível pra código que roda dentro do mesmo bloco where(...).run(...)/.call(...), no mesmo fluxo lógico (incluindo tarefas filhas abertas via StructuredTaskScope). Se você submeter uma tarefa pra um ExecutorService comum de dentro do bloco, essa tarefa roda numa thread separada que não herda o valor. CORRELATION_ID.get() lá dentro lançaria NoSuchElementException.

Quando não vale a pena?

Virtual threads resolvem um problema específico: código que bloqueia esperando I/O (banco, chamadas HTTP pra outros serviços, filas, disco). Nesse cenário, você ganha a concorrência que antes só vinha com uma stack reativa, mantendo código imperativo simples de ler.

Onde isso não ajuda:

No fim, virtual threads não substituem WebFlux em todo cenário, pra streaming com backpressure fina ou controle muito específico de concorrência, reativo ainda tem seu lugar. Mas pra maioria dos backends CRUD com integrações que eu já vi, a pergunta que eu faria primeiro é: seu gargalo é rede e banco, ou é CPU? Se for o primeiro, virtual threads te dão o ganho de concorrência do reativo sem abrir mão do código simples. Se for o segundo, nenhuma das duas abordagens resolve sozinha, aí o problema é outro e neste caso, seria necessário analisar qual é o seu cenário antes de partir pra decisão.