Usamos cookies essenciais para login e armazenamento local para suas preferências. Para métricas de uso, usamos o Google Analytics 4 — você pode recusar sem perder nada do produto. Saber mais

ThreadMineThreadMine
Voltar

Guia técnico

O que é virtual thread pinning no Project Loom?

Pinning é quando uma virtual thread não consegue soltar seu carrier e transforma o Loom, silenciosamente, de volta num pool limitado. Como identificar, por que acontece e como corrigir.

Testar grátis

O Project Loom prometeu um trato simples: escreva código bloqueante comum, rode milhões de virtual threads e deixe a JVM cuidar do multiplexing. Na maior parte do tempo ele cumpre. Mas existe um modo de falha que silenciosamente cancela o trato e devolve um pool limitado sem avisar — o pinning. O gráfico de throughput estabiliza, a latência sobe sob carga, e o código parece perfeitamente inocente. Este é o único problema do Loom que você não enxerga lendo o fonte; precisa olhar o que os carriers estão fazendo.

Este artigo explica o que é pinning, exatamente por que acontece, o que o JDK 24 mudou, e como detectar e corrigir — incluindo o caso que ainda morde nas JVMs mais novas.

O que é virtual thread pinning

Uma virtual thread não tem thread do SO própria. Ela roda sobre um pequeno pool de platform threads chamado carrier threads. Quando uma virtual thread bloqueia — em I/O, num lock, numa fila — normalmente ela desmonta (unmount): salva sua pilha, sai do carrier e libera esse carrier para rodar outra virtual thread. Esse unmount é todo o truque que permite um punhado de threads do SO atender milhões de virtuais.

Pinning é quando esse unmount não pode acontecer. A virtual thread bloqueia mas continua montada, então o carrier fica parado sem fazer nada útil pela duração toda. Um carrier pinado é um desperdício pequeno. Mas o pool de carriers padrão tem só tantas threads quantos são os núcleos de CPU, então se um caminho quente pina rotineiramente você pode pinar todos os carriers ao mesmo tempo — e aí nenhuma virtual thread em lugar nenhum progride. Isso não é lentidão; é uma inanição do scheduler que se parece exatamente com um deadlock.

Carrier threads: mount e unmount

O pool de carriers é um ForkJoinPool dedicado cujas worker threads vivem em um thread group chamado CarrierThreads. Sua parallelism padrão é igual a Runtime.availableProcessors(). Você pode alargá-lo com -Djdk.virtualThreadScheduler.parallelism=N, mas isso só sobe o teto — não impede o pinning, apenas adia o momento em que todo carrier se esgota.

A contabilidade de mount/unmount é o que torna o bloqueio barato. Qualquer coisa que impeça a JVM de desmontar com segurança uma virtual thread no meio de um bloqueio a força a manter o carrier — e isso é pinning por definição.

Por que o pinning acontece

Existem exatamente duas situações em que a JVM não consegue desmontar uma virtual thread bloqueada:

  1. Bloquear dentro de synchronized. Até o JDK 23 inclusive, o monitor de um objeto fica associado à carrier thread que entrou nele. Se uma virtual thread bloqueia (ou chama Object.wait()) enquanto segura um monitor, a JVM não consegue movê-la do carrier sem quebrar a posse do monitor — então pina. Essa é de longe a causa mais comum em código real, porque uma chamada bloqueante enterrada dentro de um método synchronized é fácil de escrever e invisível no ponto de chamada.
  2. Métodos nativos e foreign functions. Quando uma virtual thread está executando um método nativo (JNI) ou um downcall de foreign function (a API Foreign Function & Memory, ex-Panama) e bloqueia, a JVM tem um frame nativo na pilha que não consegue capturar e restaurar — então pina. Esse caso não tem synchronized para culpar e não é corrigido pelo JDK 24.

Repare no que não está nessa lista: I/O bloqueante comum via JDK (Socket, InputStream, Files), BlockingQueue, ReentrantLock, CompletableFuture,Thread.sleep() — tudo isso foi re-instrumentado para o Loom e desmonta sem problema. Pinning é uma lista curta e específica, e é justamente por isso que é detectável.

O que o JDK 24 mudou (JEP 491)

O JDK 24 entregou o JEP 491, "Synchronize Virtual Threads without Pinning". Ele reformulou como a JVM rastreia a posse de monitor para que uma virtual thread consiga desmontar mesmo bloqueada dentro de um método ou bloco synchronized, e enquanto estacionada em Object.wait(). Em outras palavras, a primeira e mais comum causa de pinning simplesmente desaparece quando você roda no JDK 24 ou posterior — sem mudança de código.

Duas consequências práticas. Primeira: no JDK 24+ a única causa de pinning restante são frames nativos / de foreign function. Segunda: a antiga flag de detecção -Djdk.tracePinnedThreads foi removida — ela não imprime mais nada, porque o caso que ela existia para pegar não pina mais. Se você está no JDK 21–23, o pinning por synchronized é bem real e atualizar costuma ser a correção mais limpa.

Como detectar pinning

A flag legada: jdk.tracePinnedThreads (JDK 21–23)

No JDK 21 a 23, adicione a system property e a JVM imprime um stack trace toda vez que uma virtual thread pina:

java -Djdk.tracePinnedThreads=full -jar app.jar
# use =short para um trace enxuto que só destaca os frames culpados

A saída aponta direto para o frame que causou o pin — a linha anotada com <== monitors:1 é a que segura o monitor:

Thread[#31,ForkJoinPool-1-worker-1,5,CarrierThreads]
    java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:...)
    java.base/java.lang.VirtualThread.parkOnCarrierThread(VirtualThread.java:...)
    app//com.exemplo.PrecoService.buscar(PrecoService.java:42) <== monitors:1
    app//com.exemplo.PrecoController.cotar(PrecoController.java:19)

Essa única linha <== monitors:1 é o diagnóstico inteiro: a linha 42 do PrecoService bloqueou enquanto segurava um monitor.

O caminho compatível com o futuro: JFR

Desde o JDK 21 a JVM emite um evento do JDK Flight Recorder, jdk.VirtualThreadPinned, para cada ocorrência de pinning, com sua duração e pilha. Como esse evento sobrevive ao JDK 24 (pins de nativo/FFM ainda o disparam), é a detecção que você deveria plugar em produção:

java -XX:StartFlightRecording=filename=rec.jfr,settings=profile -jar app.jar
# depois inspecione:
jfr print --events jdk.VirtualThreadPinned rec.jfr

Um evento relacionado, jdk.VirtualThreadSubmitFailed, avisa quando o scheduler nem conseguiu entregar uma virtual thread a um carrier — muitas vezes o sintoma a jusante de pinning crônico.

Pelo thread dump

Um thread dump ciente de virtual threads — jcmd <pid> Thread.dump_to_file -format=json dump.json — lista os carriers e a virtual thread montada em cada um. Um carrier no grupo CarrierThreads que está bloqueado enquanto uma virtual thread montada está num frame synchronized (ou num frame nativo) é a assinatura visual de um pin.

Exemplo: synchronized em torno de I/O bloqueante

O bug de pinning canônico é um cache ou um rate limiter que protege uma chamada lenta com synchronized:

public class PrecoService {
    private final Map<String, BigDecimal> cache = new HashMap<>();

    // Parece inofensivo. No JDK 21-23 pina o carrier pela chamada HTTP inteira.
    public synchronized BigDecimal buscar(String simbolo) {
        return cache.computeIfAbsent(simbolo, s -> {
            // chamada HTTP bloqueante a uma API externa de cotacao
            return clienteHttp.cotar(s);   // <-- bloqueia segurando o monitor
        });
    }
}

Toda chamada a buscar que erra o cache bloqueia na rede enquanto segura o monitor. No JDK 21–23 essa virtual thread pina seu carrier pela ida-e-volta inteira. Rode algumas centenas de requisições concorrentes e você pina todos os carriers de uma vez; o serviço que deveria escalar para milhões de virtual threads agora atende uma requisição por núcleo de CPU, e o resto se enfileira atrás de um monitor que nunca desmonta.

Como corrigir pinning

  1. Troque synchronized por ReentrantLock. java.util.concurrent.locks.ReentrantLock conhece o Loom: uma virtual thread que bloqueia segurando-o desmonta sem problema. Essa é a correção direta e independente de versão.
  2. Atualize para o JDK 24+. O JEP 491 remove o pin de synchronized por completo, então o exemplo acima para de pinar sem nenhuma mudança de código. Pins de nativo / FFM permanecem.
  3. Tire o trabalho bloqueante da seção protegida. Muitas vezes a resposta mais limpa é não segurar lock nenhum durante uma chamada externa — compute o valor fora da seção crítica e só tranque a atualização do map.
  4. Para pins de nativo / foreign function, isole o caminho. Se uma chamada JNI ou FFM bloqueante é inevitável, mantenha-a fora do caminho quente das virtual threads (rode-a num executor dedicado de platform threads) ou dimensione o pool de carriers com jdk.virtualThreadScheduler.parallelism para que alguns pins concorrentes não matem tudo de fome.

A reescrita é pequena — mas tem uma armadilha. O monitor que te pina não precisa ser o seu: não basta jogar a chamada bloqueante para dentro do ConcurrentHashMap.computeIfAbsent, porque a função de mapeamento roda sob o lock synchronized interno do bin do map — no JDK 21–23 você teria reconstruído exatamente o mesmo pin uma camada abaixo. Compute o valor fora do map e só tranque a re-checagem-e-atualização:

public class PrecoService {
    private final Map<String, BigDecimal> cache = new ConcurrentHashMap<>();
    private final ReentrantLock lock = new ReentrantLock();

    public BigDecimal buscar(String simbolo) {
        BigDecimal cached = cache.get(simbolo);
        if (cached != null) return cached;
        lock.lock();                       // ciente do Loom: desmonta se bloquear
        try {
            cached = cache.get(simbolo);   // re-checa sob o lock
            if (cached != null) return cached;
            BigDecimal cotacao = clienteHttp.cotar(simbolo);  // bloqueia; o carrier é liberado
            cache.put(simbolo, cotacao);
            return cotacao;
        } finally {
            lock.unlock();
        }
    }
}

Como o ThreadMine detecta pinning automaticamente

Pinning é invisível nas métricas comuns, então o ThreadMine procura a assinatura dele direto no dump. Quando um dump de virtual threads chega, ele identifica os carriers (os workers do ForkJoinPool no grupo CarrierThreads) e inspeciona o que está montado em cada um. Um carrier bloqueado enquanto sua virtual thread montada segura um monitor dentro de um frame synchronized — ou está estacionada dentro de um frame nativo / de foreign function — é sinalizado como pin, com o frame exato que o causou.

Cada carrier pinado vira um card com a pilha da virtual thread montada e o frame culpado destacado, e o ThreadMine expõe o agregado: quantos carriers estão pinados agora versus o tamanho do pool de carriers, que é o número que diz se você está a um caminho ruim de distância da inanição total do scheduler. É a mesma leitura que você faria à mão a partir do jdk.tracePinnedThreads ou de um evento JFR jdk.VirtualThreadPinned — feita em segundos, em todo dump, sem você ter que lembrar de ligar uma flag antes.

Conclusão

Pinning é o único modo de falha do Loom que transforma sua história de escalabilidade de volta num pool limitado sem um único erro nos logs. As regras são curtas: saiba que só synchronized (pré-JDK 24) e frames nativos / de foreign function pinam; detecte com jdk.tracePinnedThreads nos JDKs antigos e com o evento JFR jdk.VirtualThreadPinned em todos; corrija migrando para ReentrantLock ou atualizando para o JDK 24. E quando os dumps se acumularem, deixe a leitura ser automatizada.

Perguntas frequentes

O que é virtual thread pinning?

Pinning é quando uma virtual thread não consegue desmontar do seu carrier — a platform thread que a executa — enquanto bloqueia. Normalmente uma virtual thread que bloqueia solta o carrier para outra virtual thread rodar ali; a que está "pinada" mantém o carrier refém. Se carriers suficientes ficam pinados ao mesmo tempo, o scheduler inteiro passa fome e você perde justamente a escalabilidade que o Loom deveria dar.

O que causa pinning no Project Loom?

Historicamente, duas coisas. Bloquear dentro de um método ou bloco synchronized, porque o monitor do objeto estava atrelado à carrier thread; e executar um método nativo (JNI) ou uma foreign function (FFM / downcall do Panama) que bloqueia. O JDK 24 (JEP 491) removeu o caso do synchronized, então em JDKs modernos só frames nativos e de foreign function ainda causam pinning.

synchronized ainda causa pinning?

Não a partir do JDK 24. O JEP 491 reformulou a posse de monitor para que uma virtual thread consiga desmontar mesmo bloqueada dentro de synchronized ou em Object.wait(). No JDK 21 a 23, synchronized em torno de uma chamada bloqueante é de longe a causa mais comum de pinning — por isso atualizar costuma ser a correção mais rápida.

Como detecto pinning numa JVM rodando?

No JDK 21 a 23, rode com -Djdk.tracePinnedThreads=full e a JVM imprime um stack trace cada vez que uma virtual thread pina, marcando o frame culpado com <== monitors:N. Essa flag foi removida no JDK 24, então o caminho compatível com o futuro é o evento JFR jdk.VirtualThreadPinned, que registra cada evento de pinning e sua duração. Um thread dump de virtual threads também mostra os carriers e o que está montado em cada um.

Como corrijo pinning?

Troque synchronized por um java.util.concurrent.locks.ReentrantLock em volta da seção bloqueante — ReentrantLock conhece o Loom e permite a virtual thread desmontar. Ou atualize para o JDK 24+, que corrige o caso do synchronized para você. Pinning por código nativo ou foreign function não tem correção só de código além de evitar o caminho nativo bloqueante nas virtual threads quentes ou dimensionar o pool de carriers para ele.

Quer ver tudo isso aplicado ao seu dump?

Faça upload e tenha health score, problemas detectados e sugestões em segundos. Sem cadastro para o primeiro dump.