Todos os artigos

// Knowledge.log — 技術記事

O pinning de virtual threads acabou no JDK 24? O que ainda prende a carrier thread

JEP 491 livrou o synchronized do pinning; chamada nativa ainda prende a carrier. JDK 21 vs 25 medidos, JFR e quando a regra do ReentrantLock ainda vale.

Muito guia interno de Java ganhou, na época do JDK 21, uma linha parecida com esta: "em código que roda em virtual thread, troque synchronized por ReentrantLock por causa do pinning". Fazia sentido. É o tipo de linha que entra no guia e nunca mais é relida, porque o guia também nunca mais é aberto.

Agora, com o JDK 25 LTS chegando às bases, essa linha está sendo apagada em bloco, com uma justificativa curta: o JEP 491, entregue no JDK 24, resolveu o pinning, a regra morreu e synchronized é seguro em qualquer lugar. Essa é a tese em julgamento. Metade dela está certa. O problema é o "em qualquer lugar", que o próprio JEP não assina. O que deixou de prender, o que ainda prende uma carrier thread e como enxergar isso num recording de JFR é o que decide se aquela linha do guia sai ou fica.

De onde veio a manchete

A tese não nasceu em conversa de corredor. O recibo mais direto é de Marvin Richter, em Java 24 Fixes the Last Virtual-Threads Problem: synchronized Without Pinning (18/06/2026): ele escreve que o código synchronized que prendia "no longer pins under Java 24. No code needs to change", chama a objeção de "technically obsolete" e fala em "no manual ReentrantLock migrations".

Antes dele, Dan Vega já resumia a novidade, num post de 09/04/2025, como poder "use synchronized methods and blocks without pinning", e o Inside Java Newscast #80 (nov/2024) tinha saído com o título "Java 24 Stops Pinning Virtual Threads (Almost)". Quando a notícia foi recontada, o "(Almost)" ficou pelo caminho. E é justamente essa palavra que interessa a quem opera um serviço.

O que o JEP 491 mudou, e o que ele mesmo diz que continua

A regra antiga tem origem conhecida. O JEP 444, que entregou virtual threads no JDK 21, recomendava "avoid frequent and long-lived pinning by revising synchronized blocks or methods that run frequently and guard potentially long I/O operations to use java.util.concurrent.locks.ReentrantLock instead."

O JEP 491 muda a implementação de synchronized na JVM:

> We will change the JVM's implementation of the synchronized keyword so that virtual threads can acquire, hold, and release monitors, independently of their carriers.

Sobre a migração que os times fizeram, ele também é direto:

> We previously recommended solving frequent and long-lived pinning problems by migrating code from using synchronized to using ReentrantLock. Once the synchronized keyword no longer pins virtual threads, such migration will no longer be necessary. You need not revert code that has been migrated to use ReentrantLock back to using synchronized.

Até aqui, a tese se sustenta. Só que o resumo do JEP diz que a mudança "will eliminate nearly all cases of virtual threads being pinned to platform threads". Todo o peso está no "nearly", e o mesmo documento lista o que sobra:

> if a virtual thread calls native code, either through a native method or the Foreign Function & Memory API, and that native code calls back to Java code that performs a blocking operation or blocks on a monitor, then the virtual thread will be pinned

Na seção "Future Work", entram o carregamento e a inicialização de classes. Por exemplo: "When waiting for a class to be initialized by another thread (JVMS §5.5). This is a special case where the virtual thread blocks in the JVM, thus pinning the carrier."

A documentação de virtual threads do JDK 25 é ainda mais simples: a virtual thread fica presa quando executa um método nativo ou uma foreign function. Pinning não deixa a aplicação incorreta, mas pode atrapalhar a escalabilidade. Como a API de FFM é final desde o JDK 22 (JEP 454), esse caminho nativo já não é exótico. A base de comparação daqui para frente é o JDK 25, a LTS atual, com GA em 16/09/2025.

A medição

Os números abaixo foram medidos em 2026-09-26, num Ubuntu 26.04 LTS com 4 vCPU (AMD EPYC-Rome Processor) e 7,6 GiB de RAM, sem Docker. Rodamos com Temurin 21.0.12+8 LTS e Temurin 25.0.4+7 LTS. A matriz inteira levou 44 s.

O desenho é pequeno de propósito: 8 virtual threads, cada uma bloqueando 250 ms, com o scheduler limitado a uma carrier (-Djdk.virtualThreadScheduler.parallelism=1). Com uma carrier só, qualquer pinning aparece como fila: as 8 tarefas deixam de levar ~250 ms juntas e passam a levar ~2 s em sequência. Cada execução grava um recording de JFR com jdk.VirtualThreadPinned habilitado e threshold de 10 ms. É um instrumento para distinguir comportamentos. Não serve para estimar capacidade de produção.

Os cenários que importam no PinningDemo.java:

case "sleep-sync-permon":
    synchronized (PER_TASK[idx]) { Thread.sleep(BLOCK_MS); }
    break;
case "sock-sync-shared":
    synchronized (SHARED) { socketRoundtrip(idx); }
    break;
case "ffm-nanosleep": {
    Class<?> c = Class.forName("FfmSleep");
    c.getMethod("sleep", long.class).invoke(null, BLOCK_MS);
    break;
}

permon usa um monitor por tarefa, o que isola o pinning da disputa pelo lock. shared usa um monitor único. Os cenários sock fazem um round-trip de socket em localhost, contra um servidor em threads de plataforma que responde depois de 250 ms. O ffm-nanosleep chama o nanosleep da libc via FFM (o stub declara só o primeiro parâmetro, req, embora o protótipo em C também receba rem):

Linker linker = Linker.nativeLinker();
SymbolLookup std = linker.defaultLookup();
NANOSLEEP = linker.downcallHandle(
        std.find("nanosleep").orElseThrow(() -> new RuntimeException("nanosleep symbol not found")),
        FunctionDescriptor.of(JAVA_INT, ADDRESS));

O harness é feito de dois arquivos Java e um script curto que roda a matriz: PinningDemo.java (cenários, medição e recording) e FfmSleep.java (o downcall nativo), enquanto o ListJfrEvents.java que aparece na compilação só lista os eventos registrados na JVM e não participa das medições. Para reproduzir, a compilação foi esta. No JDK 21, o FfmSleep só compila com --release 21 --enable-preview, porque ali a FFM ainda era preview:

# JDK 25
$JDK25/bin/javac -d build25 src/ListJfrEvents.java src/PinningDemo.java src/FfmSleep.java
# JDK 21
$JDK21/bin/javac -d build21 src/ListJfrEvents.java src/PinningDemo.java
$JDK21/bin/javac --release 21 --enable-preview -d build21-preview src/FfmSleep.java

Cada execução tem este formato (o último argumento é o diretório do .jfr):

$JDK25/bin/java -Djdk.virtualThreadScheduler.parallelism=1 -Ddemo.jdkTag=25 -cp build25 PinningDemo <scenario> jfr

No JDK 25, o downcall do nanosleep imprime no stderr quatro linhas WARNING de método restrito, a começar por "A restricted method in java.lang.foreign.Linker has been called", a menos que você rode com --enable-native-access=ALL-UNNAMED. São avisos, não erros, e não afetam a medição.

Resultados (JDK 21 / JDK 25). Com só 8 tarefas, p95 e p99 são o próprio máximo; o tempo total basta. "Em voo" é o máximo de tarefas iniciadas ao mesmo tempo, contando as que estão estacionadas esperando um monitor:

CenárioTempo total (21 / 25)Em voo (21 / 25)Tarefas/s (21 / 25)Eventos pinned (21 / 25)
sleep (controle, sem monitor)265,8 / 260,7 ms8 / 830,1 / 30,70 / 0
sleep-sync-permon2018,1 / 260,7 ms1 / 83,96 / 30,78 / 0
sleep-sync-shared2015,5 / 2013,8 ms1 / 83,97 / 3,978 / 0
sock-sync-permon2023,0 / 270,2 ms1 / 83,96 / 29,68 / 0
sock-sync-shared2022,4 / 2016,1 ms1 / 83,96 / 3,978 / 0
ffm-nanosleep (nativo, só JDK 25)— / 2078,6 ms— / 8— / 3,85— / 1

Nenhum cenário teve erro.

O que os números dizem

Monitor não prende mais. No JDK 21, bloquear dentro de um monitor por tarefa serializou as 8 tarefas na única carrier: ~2,0 s, uma por vez, 8 eventos pinned. No JDK 25 o mesmo código roda sobreposto: ~0,26–0,27 s, 8 em voo e zero eventos, com sleep ou com socket. Essa é a mudança real do JEP 491, e ela é grande: de 3,96 para 30,7 tarefas/s no mesmo desenho.

Monitor compartilhado continua serializando, e isso não é pinning. Com um lock único, o JDK 25 levou 2013,8 ms e o JDK 21 levou 2015,5 ms. A diferença está na coluna "em voo". No 25, as 8 tarefas começam logo, porque a carrier está livre para montá-las, e sete delas estacionam esperando o monitor. O JEP 491 liberou a carrier, mas a exclusão mútua continua lá. Trocar esse synchronized por ReentrantLock daria o mesmo tempo. O próprio JEP recomenda "avoid, where possible, doing I/O or other blocking operations while holding locks", e o motivo agora é contenção.

Um caso que o JDK 21 já aguentava, o cenário wait-sync-shared: Object.wait(250) dentro de um monitor compartilhado não prendeu no 21.0.12 (266,8 ms, 8 em voo, 0 eventos). O JEP 491 explica por quê: o scheduler já compensa o Object.wait() garantindo uma thread de plataforma de reserva enquanto a virtual thread espera.

A chamada nativa ainda prende a carrier, e o JFR não marcou isso. No JDK 25, as 8 tarefas ffm-nanosleep começaram nos primeiros ~65 ms, mas terminaram uma a cada ~250 ms (324, 575, 825 ... 2078 ms). Com uma carrier só, isso significa que cada nanosleep segurou a carrier inteira durante a chamada. O recording teve exatamente um evento jdk.VirtualThreadPinned, e ele não vem da chamada nativa:

jdk.VirtualThreadPinned {
  duration = 36.3 ms
  blockingOperation = "Object.wait"
  pinnedReason = "Waited for initialization of FfmSleep by another thread"
  carrierThread = "ForkJoinPool-1-worker-2" (javaThreadId = 36)

Esse evento é da inicialização da classe FfmSleep, um dos casos listados em "Future Work". Os 2 s que a carrier passou dentro do nanosleep não geraram evento nenhum. Então "zero eventos pinned" não prova que nada está segurando carrier. Para descobrir se há serialização, olhe tempo total e throughput sob concorrência. Procurar no recording não basta.

Monitorar em vez de adivinhar

A ferramenta continua sendo o JFR com o evento jdk.VirtualThreadPinned. No JDK 25 ele traz pinnedReason, blockingOperation e carrierThread, que são justamente os campos que respondem "por que essa virtual thread não desmontou". Dois comandos bastam:

jfr summary app.jfr
jfr print --events jdk.VirtualThreadPinned --stack-depth 8 app.jfr

Antes de confiar no resultado, confirme que o evento estava ligado. A documentação da Oracle diz que ele vem habilitado por padrão com threshold de 20 ms. Nesta rodada não foi assim. No JDK 21, com pinning real acontecendo (2017,4 ms, uma tarefa por vez), um recording criado pela API sem habilitar o evento terminou com isto:

 jdk.VirtualThreadPinned                     0             0

Um jfr summary com zero, enquanto a aplicação estava serializada de ponta a ponta. Com o evento habilitado explicitamente, o mesmo cenário deu 8:

rec.enable("jdk.VirtualThreadPinned").with("threshold", "10 ms").with("stackTrace", "true");
 jdk.VirtualThreadPinned                     8           112

Pela linha de comando, -XX:StartFlightRecording capturou os 8 eventos sem nenhuma configuração extra, e também com settings=default e settings=profile:

$JDK21/bin/java -Djdk.virtualThreadScheduler.parallelism=1 -XX:StartFlightRecording:filename=app.jfr -cp build21 PinningDemo sock-sync-permon raw

Na prática: habilite o evento explicitamente quando o recording vier da API, e confira no jfr summary que a linha jdk.VirtualThreadPinned existe antes de tirar qualquer conclusão. Lembre também do threshold: um pin mais curto que ele não aparece.

Com código nativo no caminho quente, nem o evento ligado resolve, como o nanosleep mostrou. Ali, o recurso prático é um thread dump das carriers, que existe tanto no JDK 21 quanto no 25:

jcmd <pid> Thread.dump_to_file -format=json carriers.json

Tirado durante a carga, ele mostra a carrier ocupada durante o downcall, justamente quando o JFR fica calado.

A flag antiga não ajuda mais. O JEP 491 diz sobre jdk.tracePinnedThreads: "We will therefore remove this system property; setting it on the command line will have no effect." No mesmo cenário com pinning, rodado com -Djdk.tracePinnedThreads=full, o JDK 21 imprime o dump no stdout:

VirtualThread[#32,vt-0]/runnable@ForkJoinPool-1-worker-1 reason:MONITOR
    ...
    PinningDemo.runScenario(PinningDemo.java:132) <== monitors:1

O JDK 25 não imprime nada. Nos dois JDKs, java -Djdk.tracePinnedThreads=full -version sai com código 0, sem nenhum aviso. Um script de diagnóstico que dependia dessa flag continua "passando" no 25. Só que agora ele não verifica nada.

Quando a regra antiga ainda vale

  • O serviço ainda roda no JDK 21. Veja a coluna do 21 na tabela: ali a regra vale inteira, e migrar synchronized que protege bloqueio longo para ReentrantLock ainda compensa.
  • Há chamada nativa no caminho quente. Pode ser JNI ou FFM. Aqui a preocupação da regra continua válida, mas o remédio antigo não serve: o nanosleep prendeu a carrier sem nenhum lock envolvido. A saída é tirar a chamada do caminho quente ou limitar quantas rodam ao mesmo tempo, e validar com a mesma medição de tempo total e throughput.
  • Há inicialização de classe pesada no caminho quente. O único evento do cenário nativo foi uma virtual thread esperando 36,3 ms pela inicialização de FfmSleep feita por outra thread. Se a primeira requisição concorrente dispara uma inicialização cara, faça essa inicialização na subida da aplicação.

Recomendação

No JDK 25, a DevDojo aposentaria a regra como está escrita ("troque synchronized por ReentrantLock por causa de pinning") e poria duas no lugar:

  1. Não faça I/O nem bloqueio longo segurando um lock compartilhado, seja qual for o tipo de lock — a serialização das linhas shared da tabela continua igual nos dois JDKs.
  2. Chamadas nativas (JNI/FFM) e inicialização pesada de classe no caminho quente são suspeitas de prender carrier até que uma medição mostre o contrário, e a regra antiga, ou uma variante dela, segue de pé no serviço que cair em uma das três condições da seção anterior.

Código já migrado para ReentrantLock fica como está. O JEP diz que não precisa reverter, e reverter só para seguir a moda gera diff sem ganho.

O teste que decide é rodar o lote sob concorrência e olhar o relógio: com uma carrier presa, as 8 tarefas terminam perto de N × 250 ms e o throughput cai para ~4 tarefas/s; sem carrier presa, o mesmo lote fecha perto de 250 ms, a ~30 tarefas/s.

Próximo passo

Escolha o serviço que motivou a regra no guia interno e compare o throughput com o que o caminho deveria entregar. Só então mexa nos locks, e aí com o número na mão.

javavirtual-threadsconcurrency

// Continue.training — 次のステップ

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos