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:
- 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 chamaObject.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étodosynchronizedé fácil de escrever e invisível no ponto de chamada. - 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
synchronizedpara 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
- Troque
synchronizedporReentrantLock.java.util.concurrent.locks.ReentrantLockconhece o Loom: uma virtual thread que bloqueia segurando-o desmonta sem problema. Essa é a correção direta e independente de versão. - Atualize para o JDK 24+. O JEP 491 remove o pin de
synchronizedpor completo, então o exemplo acima para de pinar sem nenhuma mudança de código. Pins de nativo / FFM permanecem. - 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.
- 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.parallelismpara 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.