Virtual Threads no Java 25 - as 4 armadilhas de producao (pinning, pool, ThreadLocal, CPU-bound) e o que o JEP 491 corrigiu - Meu Universo Nerd

Sexta à tarde, pico de tráfego, e a aplicação que ia voar depois que você habilitou Virtual Threads congelou. Carriers presos, requisições penduradas sem resposta, o pool do banco no talo e o throughput despencando em vez de subir. Você jurou que era só ligar uma flag, né?

Pois é. O Loom não mentiu, só que ligar Virtual Threads no Java 25 não é mágica de configuração. Tem um punhado de armadilhas que ninguém conta nos tutoriais e que só aparecem na escala de produção: pinning de carrier, pool de conexão que vira o teto real, ThreadLocal multiplicado por milhões e carga CPU-bound disfarçada de I/O. A boa notícia: o JDK 25 já traz o JEP 491, que matou a pior delas. Bora ver as quatro, com código do jeito errado e do jeito certo, pra você não descobrir na marra.

Por que ligar Virtual Threads não dobra o throughput de graça

Antes de falar do que quebra, um modelo mental rápido. Uma virtual thread não roda sozinha: ela precisa montar em uma carrier thread, que é uma platform thread de verdade do sistema operacional. Por baixo, a JVM mantém um ForkJoinPool de carriers, e o número padrão de carriers é igual ao número de núcleos da sua máquina.

Pensa num restaurante. As mesas são as virtual threads, você pode ter mil. Os garçons são os carriers, e você tem só uns oito. Enquanto a mesa espera a cozinha (I/O), o garçom larga ela e atende outra. É isso que faz Virtual Thread escalar: o garçom nunca fica parado esperando o prato. O problema começa quando alguma coisa algema o garçom na mesa.

Habilitar no Spring Boot é uma linha, e o seu código de controller não muda nada:

# application.yaml
spring:
  threads:
    virtual:
      enabled: true
@RestController
public class PedidoController {

    private final PedidoRepository repository;

    public PedidoController(PedidoRepository repository) {
        this.repository = repository;
    }

    @GetMapping("/pedidos/{id}")
    public Pedido buscar(@PathVariable Long id) {
        // mesma interface bloqueante de sempre, agora numa virtual thread
        return repository.findById(id)
                .orElseThrow(() -> new PedidoNaoEncontrado(id));
    }
}

Funciona lindamente no benchmark da sua máquina, com um request por vez. Aí chega a produção, com dez mil requests concorrentes batendo no mesmo banco, e o restaurante inteiro trava. O motivo nunca é o Loom. É uma destas quatro armadilhas.

Armadilha 1: o pinning que prendia o carrier (e o que o JEP 491 matou)

Essa é a war story clássica, a que derrubou times grandes rodando alto tráfego. Até o Java 21, quando uma virtual thread entrava num bloco synchronized e fazia uma operação bloqueante lá dentro (uma chamada de rede, uma query), ela não conseguia desmontar da carrier. Isso é o pinning: o garçom fica algemado na mesa esperando a cozinha.

Com poucos carriers (lembra, um por núcleo) e muitas virtual threads presas em synchronized esperando um recurso limitado, você chega no pior cenário possível: todos os carriers algemados, ninguém livre pra fazer progresso, e a aplicação em deadlock de fato. Foi exatamente esse padrão (virtual threads bloqueadas em synchronized disputando um pool de conexões pequeno) que gerou os relatos de deadlock em produção que circularam na comunidade.

// Antes do JEP 491 (Java < 24): este I/O dentro do synchronized
// PRENDIA a carrier thread inteira (pinning)
public Saldo consultar(String conta) {
    synchronized (lock) {
        return gateway.buscarSaldo(conta); // chamada de rede bloqueante
    }
}

O conselho antigo, repetido em todo tutorial de 2023, era trocar synchronized por ReentrantLock, porque o lock explícito não causava pinning:

private final ReentrantLock lock = new ReentrantLock();

public Saldo consultar(String conta) {
    lock.lock();
    try {
        return gateway.buscarSaldo(conta);
    } finally {
        lock.unlock();
    }
}

E aqui está a parte que ninguém atualizou: esse conselho está praticamente aposentado. O JEP 491 (Synchronize Virtual Threads without Pinning) foi entregue no Java 24, em março de 2025, e vem de fábrica no Java 25 LTS. A partir dele, blocos synchronized não prendem mais a carrier: a virtual thread desmonta normalmente mesmo dentro do synchronized. Ou seja, metade das war stories de pinning viraram história. Como o Java 25 é a LTS que o seu time vai de fato adotar, é nele que esse motivo de medo finalmente evapora.

Cuidado com o que ainda prende: chamadas nativas via JNI e código em frames nativos continuam fazendo pinning, porque a JVM não consegue desmontar uma pilha nativa. Se você tem uma lib que chama código C por baixo, ela ainda é suspeita. Para diagnosticar quem está prendendo sem ficar adivinhando, vale ler o passo a passo em como usar Virtual Threads do jeito certo no Spring Boot, onde mostro a configuração que evita esse tipo de surpresa.

E como você descobre que ainda tem pinning depois de migrar? Esqueça o palpite. A JVM moderna emite um evento de JFR (Java Flight Recorder) toda vez que uma virtual thread fica presa: o jdk.VirtualThreadPinned. Você liga a gravação, joga carga, e o JFR te entrega o stack trace exato de onde o pin aconteceu, com o tempo que a thread ficou algemada. É bem mais confiável do que o antigo -Djdk.tracePinnedThreads, que poluía o log e foi deixando de existir. Coloque esse evento no seu dashboard de observabilidade e o pinning deixa de ser fantasma.

Armadilha 2: o pool de conexão é o teto que você esqueceu

Mataram o pinning, beleza. Aí o time comemora, sobe a versão e descobre a segunda armadilha na primeira madrugada de carga: o pool de conexão continua sendo o gargalo real, e Virtual Thread não muda isso em nada.

Virtual Thread te deixa criar dez mil tarefas concorrentes sem suar. Mas se o seu HikariCP tem dez conexões, nove mil novecentas e noventa dessas tarefas vão ficar paradas esperando, não o banco, mas uma conexão livre. Você trocou um gargalo visível (pool de threads pequeno) por um gargalo invisível (pool de conexões pequeno).

// 10.000 virtual threads disparadas de uma vez...
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i ->
        exec.submit(() -> repository.relatorioPesado(i)));
}
// ...mas o HikariCP com 10 conexoes deixa 9.990 esperando no gargalo

O reflexo errado é inchar o pool: "ah, então boto 500 conexões". Não. Quem define o teto saudável de conexões é o banco, não a sua euforia com Virtual Thread. Um Postgres não fica mais rápido com 500 conexões concorrentes, ele fica mais lento, porque a contenção interna sobe. A regra de produção que eu sigo: dimensione o pool pela capacidade real do banco e trate Virtual Thread como o que ela é, um jeito barato de esperar, não um jeito de furar o limite do recurso lá embaixo.

Na prática, o ponto de partida que funciona é manter o tamanho do pool perto do que você já tinha antes das virtual threads, derivado dos núcleos do banco, e medir a fila. O HikariCP expõe a métrica de threads aguardando conexão. Se essa fila vive cheia, o gargalo é o banco, e mais virtual thread só vai engrossar a fila de espera, não resolver. Se a fila fica vazia mesmo sob carga, aí sim o pool está saudável e as virtual threads estão fazendo o trabalho delas, que é esperar de graça. O número certo sai da métrica, não do chute. A própria documentação do HikariCP sobre dimensionamento de pool defende manter o pool pequeno pela mesma razão: contenção no banco custa mais caro que fila na aplicação.

Quando você precisa coordenar várias dessas chamadas com cancelamento e timeout decentes, a ferramenta certa não é sair disparando CompletableFuture solto, é Structured Concurrency. Eu cubro o padrão completo do StructuredTaskScope em Structured Concurrency no JDK 25, e ele combina muito bem com Virtual Threads aqui.

Armadilha 3: ThreadLocal em escala e a saída via ScopedValue

Terceira armadilha, e essa é silenciosa: ThreadLocal. No modelo antigo, com um pool de 200 platform threads, você tinha no máximo 200 cópias de cada ThreadLocal. Tranquilo. No mundo Virtual Thread, com um milhão de threads vivas ao mesmo tempo, cada uma carregando o seu contexto de usuário, o seu tenant, o seu trace id em ThreadLocal, são um milhão de cópias na heap. A memória sobe sem explicação óbvia e o garbage collector começa a sofrer.

// O hábito antigo: cada virtual thread carrega a sua copia mutavel
private static final ThreadLocal<Usuario> USUARIO = new ThreadLocal<>();

A resposta do Java 25 é o ScopedValue (JEP 506), que ficou final nesta versão. Em vez de uma cópia mutável por thread, você tem um valor imutável, compartilhado e com tempo de vida amarrado a um escopo. Mais barato, mais seguro e desenhado justamente para o cenário de milhões de virtual threads:

private static final ScopedValue<Usuario> USUARIO = ScopedValue.newInstance();

// o valor existe so durante o run, depois some, sem copia presa na thread
ScopedValue.where(USUARIO, usuarioAutenticado)
    .run(() -> processarPedido(id));

// la dentro, qualquer metodo le sem passar parametro nem usar ThreadLocal
Usuario atual = USUARIO.get();

Se você quer entender por que o time de plataforma deveria empurrar essa migração agora, montei o raciocínio de custo e de runtime em o business case do Java 25. ScopedValue não é firula, é menos heap por request.

Armadilha 4: CPU-bound disfarçado de I/O-bound

A última é a mais traiçoeira, porque o código parece certo. Virtual Thread brilha quando a tarefa fica esperando (I/O): banco, rede, fila. Para trabalho que fica calculando (CPU), ela não dá ganho nenhum, e ainda atrapalha. Você não tem mais núcleos só porque criou um milhão de threads. O que você ganha é overhead de escalonamento.

// ERRADO: redimensionar imagem e 100% CPU, nao I/O
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
    for (Imagem img : imagens) {
        exec.submit(() -> redimensionar(img)); // disputa os mesmos nucleos
    }
}
// CERTO: trabalho de CPU vai pra um pool de plataforma limitado aos nucleos
var nucleos = Runtime.getRuntime().availableProcessors();
try (var cpuPool = Executors.newFixedThreadPool(nucleos)) {
    for (Imagem img : imagens) {
        cpuPool.submit(() -> redimensionar(img));
    }
}

A pergunta que resolve a dúvida na hora: a sua tarefa passa a maior parte do tempo esperando ou calculando? Esperando, manda pra virtual thread. Calculando, pool de plataforma do tamanho dos núcleos. Misturar os dois no mesmo executor é o atalho mais rápido pra um throughput que cai em vez de subir.

Quando usar e quando evitar Virtual Threads em produção

Como Tech Leader, numa migração de uma API de pagamentos que eu conduzi, a regra que a gente fixou no time foi simples: Virtual Thread entra por padrão na borda HTTP, onde tudo é espera, e fica longe do núcleo de cálculo. Funcionou. O ganho real veio de parar de manter pool gigante de threads, não de algum número mágico de benchmark.

Use Virtual Threads quando:

  • A app é I/O-bound: muitas chamadas a banco, APIs externas, filas, cache remoto.
  • Você hoje mantém um pool grande de platform threads só pra aguentar concorrência.
  • Está em Java 21 ou superior, de preferência Java 25 LTS já com o JEP 491.

Evite (ou tome cuidado) quando:

  • O trabalho é CPU-bound: cálculo, compressão, criptografia, processamento de imagem.
  • Existe lib chamando código nativo via JNI, que ainda causa pinning.
  • Você ainda trata o pool de conexão como infinito, ele continua sendo o teto.

Perguntas frequentes (FAQ)

O JEP 491 acabou de vez com o pinning?

Acabou com a causa mais comum, que era o bloco synchronized. Desde o Java 24, e portanto no Java 25 LTS, synchronized não prende mais a carrier. Mas chamadas nativas via JNI e frames nativos ainda fazem pinning, então não é zero, é muito menos.

Preciso trocar synchronized por ReentrantLock no Java 25?

Para evitar pinning, não precisa mais, esse era o motivo principal e o JEP 491 resolveu. ReentrantLock ainda vale por outras razões, como tryLock e fairness, mas não é mais obrigatório só por causa de virtual threads.

Virtual Threads aumentam o throughput de qualquer aplicação?

Não. Só de aplicações I/O-bound, que passam o tempo esperando. Para trabalho de CPU, o ganho é nulo ou negativo, porque você não cria núcleos novos, só adiciona overhead de escalonamento entre as virtual threads.

Quantas conexões devo colocar no HikariCP ao usar Virtual Threads?

O mesmo número saudável de antes, definido pela capacidade do banco, não pela quantidade de virtual threads. Inchar o pool para 500 deixa o banco mais lento por contenção. Virtual Thread só barateia a espera, não fura o limite do recurso.

Vale migrar para Virtual Threads agora ou espero?

Com Java 25 LTS e o JEP 491, o maior risco histórico saiu de cena. Para apps I/O-bound em Spring Boot 3.2 ou superior, é uma linha de configuração com ganho real. O trabalho de verdade é revisar pool de conexão, ThreadLocal e tarefas CPU-bound.

Conclusão: o que levar pra sua próxima sexta-feira

Virtual Thread não é mentira nem hype, é uma das melhores coisas que aconteceram no Java moderno. Só que ligar a flag é o passo um de quatro. O resto é não cair nas armadilhas que só a escala revela.

  • Pinning de synchronized acabou com o JEP 491, entregue no Java 24 e presente no Java 25 LTS. Atualize o seu conhecimento, não só o seu JDK.
  • O pool de conexão é o teto real, e ThreadLocal em escala vira custo de heap. Troque por ScopedValue.
  • CPU-bound não é pra virtual thread. Espera vai pra VT, cálculo vai pra pool de plataforma.

Você já habilitou Virtual Threads em produção? Bateu em alguma dessas armadilhas, ou achou outra que eu não citei? Conta nos comentários, quero saber como foi na sua stack.

Na próxima, eu pego a armadilha do pool de conexão e mostro o número que de verdade importa na configuração do HikariCP com Virtual Threads. Se ainda não viu, comece por como usar Virtual Threads do jeito certo no Spring Boot e veja também como o código blocking escala como reativo no Spring Boot 4. Fonte original que inspirou este artigo: Java Code Geeks, Virtual Threads two years in production.