Thread Pinning em Virtual Threads Java 25 - diagrama carrier thread bloqueada, JEP 491 e JNI

Existe uma forma de saber exatamente onde suas Virtual Threads estão "presas" antes que o problema chegue em produção. A maioria dos devs que ativou Virtual Threads no Java 25 nunca viu esse relatório de diagnóstico da JVM — e está voando cego.

O Thread Pinning é o principal problema que ainda existe com Virtual Threads, mesmo após o JDK 25 resolver o caso mais comum com o JEP 491. Nesse artigo você vai aprender a detectar pinning com JVM flags específicas, entender o que o Java 25 corrigiu de verdade e o que ainda pode te pegar em produção.

Uma breve história: de experimento a produção em 3 versões

As Virtual Threads não chegaram prontas. O Project Loom levou mais de 10 anos de desenvolvimento dentro da OpenJDK antes de se tornar estável. Entender essa linha do tempo é importante para calibrar o que você pode confiar hoje:

  • Java 19 (2022): Virtual Threads como preview feature (JEP 425). Não recomendado para produção.
  • Java 21 LTS (2023): Virtual Threads finalizadas (JEP 444). A partir daqui, oficialmente prontas para produção — com ressalvas sobre pinning.
  • Java 25 LTS (2025): JEP 491 elimina o caso de pinning mais frequente (blocos synchronized). Este é o marco que torna Virtual Threads verdadeiramente confiáveis para a maioria dos workloads.

Se você está em Java 21 ou 23/24 e sofre com pinning em blocos synchronized, a solução mais limpa é atualizar para Java 25 — o JEP 491 elimina esse caso por design, sem mudanças no seu código.

Como as Virtual Threads funcionam por baixo: o modelo de carrier threads

Para entender o pinning, você precisa entender o mecanismo que torna as Virtual Threads tão eficientes. A JVM mantém um pool de carrier threads — threads reais do sistema operacional (gerenciadas pelo kernel) — que executam as virtual threads. Por padrão, o número de carrier threads é igual ao número de CPUs disponíveis.

O ciclo de vida de uma virtual thread em operação I/O funciona assim:

  1. Virtual thread A está rodando em carrier thread C1
  2. Virtual thread A chama socket.read() — operação I/O bloqueante
  3. A JVM detecta o bloqueio e desmonta (unmounts) a virtual thread A da carrier C1
  4. Carrier C1 fica livre e executa a virtual thread B imediatamente
  5. Quando o I/O de A completa, a JVM remonta (remounts) A em qualquer carrier disponível

Esse modelo é o que permite ter milhões de virtual threads com apenas dezenas de carrier threads — as carrier threads nunca ficam ociosas esperando I/O. O problema do pinning é exatamente quando esse mecanismo de desmontagem falha.

O que é Thread Pinning e por que ele é silencioso

O pinning acontece quando a JVM não consegue desmontar a virtual thread da carrier thread. A virtual thread fica "colada" (pinned) na carrier, que fica bloqueada enquanto espera o I/O. O comportamento é idêntico ao de uma platform thread tradicional — você perdeu todo o benefício das virtual threads para esse código.

O que torna o pinning silencioso é que sua aplicação continua funcionando. Nenhum erro, nenhuma exceção. O throughput cai gradualmente, o pool de carrier threads vai se esgotando sob carga, e o problema aparece como "lentidão inexplicável" às sextas-feiras — nunca apontando diretamente para o pinning como causa raiz.

Existem dois cenários que causam pinning:

// CENÁRIO 1 — Bloco synchronized com operação de I/O dentro
// Java 21-24: causa pinning | Java 25 (JEP 491): RESOLVIDO
public synchronized void processarPedido(Long id) {
    // No Java 21-24, o synchronized impedia a desmontagem da virtual thread
    // A carrier ficava bloqueada durante todo o findById (I/O de banco)
    Pedido pedido = repository.findById(id).orElseThrow();
    pedido.setStatus(StatusPedido.PROCESSADO);
    repository.save(pedido);
}

// CENÁRIO 2 — Código JNI chamado durante execução de virtual thread
// Java 21-25: AINDA causa pinning (por design da JVM)
public byte[] processarComCriptografia(byte[] dados) {
    // Bouncy Castle em modo JNI, drivers JDBC nativos Oracle/IBM,
    // ou qualquer código que invoque métodos nativos via JNI
    // A JVM não pode desmontar a virtual thread enquanto está em código nativo
    return nativeEncrypt(dados); // chamada JNI = carrier thread bloqueada
}

O incidente real: carrier starvation em produção

Para ilustrar por que isso importa, considere um cenário real que ocorreu em equipes que migraram para Virtual Threads no Java 21 sem monitoramento adequado.

Um serviço de e-commerce com ~200 requisições/segundo migrou de platform threads para virtual threads esperando ganhos de throughput. Nos primeiros dias, tudo funcionou bem. Na primeira sexta-feira com carga de pico (pré-fim de semana), o P99 de latência foi de 120ms para 4.200ms. O sistema não caiu, mas ficou praticamente inutilizável por ~45 minutos.

A causa raiz: o serviço usava uma lib de criptografia de terceiros com código nativo para assinar tokens. Com 16 CPUs, o pool de carrier threads tinha apenas 16 threads. Sob carga de pico, todas as 16 carriers ficavam pinadas esperando a operação nativa de criptografia completar. Novas virtual threads não conseguiam ser executadas — carrier starvation completo.

O diagnóstico foi feito com -Djdk.tracePinnedThreads=full em um ambiente de staging replicado com carga. O stack trace apontou exatamente para a lib de criptografia. A solução foi isolar as operações nativas em um pool dedicado de platform threads — o sistema voltou a P99 de 118ms.

O que torna esse cenário perigoso: a falha não é óbvia. O serviço parece saudável em carga baixa e só colapsa sob pico. Sem monitoramento de pinning, você vai gastar horas investigando banco de dados, rede e GC antes de chegar na causa real.

Como detectar Thread Pinning: as duas ferramentas que você precisa usar

Ferramenta 1: -Djdk.tracePinnedThreads (desenvolvimento e staging)

A JVM fornece uma flag específica que reporta eventos de pinning no log. Essa é a ferramenta mais importante que você provavelmente não está usando em desenvolvimento:

# Opção 1: Stack trace completo (recomendado para diagnóstico)
java -Djdk.tracePinnedThreads=full -jar sua-aplicacao.jar

# Opção 2: Apenas o resumo (menos verboso)
java -Djdk.tracePinnedThreads=short -jar sua-aplicacao.jar

# No Spring Boot (application.properties):
spring.jvm.args=-Djdk.tracePinnedThreads=full

# No pom.xml (plugin Spring Boot Maven):
# <jvmArguments>-Djdk.tracePinnedThreads=full</jvmArguments>

Com essa flag ativa, toda vez que uma virtual thread ficar pinned, a JVM imprime no log:

// Exemplo de output com pinning detectado:
// Thread[#25,ForkJoinPool-1-worker-1,5,CarrierThreads]
//     java.base/java.lang.VirtualThread$PinnedState.run(VirtualThread.java:XXX)
//         com.example.PedidoService.processarPedido(PedidoService.java:42)
//             <-- AQUI está o seu problema — arquivo e linha exatos

// Se você NÃO vê esse log após rodar sua aplicação com carga:
// suas virtual threads não estão sofrendo pinning.

// Se você VÊ: corrija o método apontado no stack trace antes de ir para produção.

Ferramenta 2: Java Flight Recorder — JFR (produção)

Para diagnóstico em produção sem degradar performance, use o JFR com o evento específico de pinning. O overhead do JFR é tipicamente abaixo de 1% — muito mais seguro do que a flag tracePinnedThreads em produção.

import jdk.jfr.consumer.RecordingStream;
import java.time.Duration;

// Ativar monitoramento de pinning em runtime (sem restart da aplicação):
try (RecordingStream rs = new RecordingStream()) {
    rs.enable("jdk.VirtualThreadPinned")
      .withThreshold(Duration.ofMillis(20)); // só reportar pins acima de 20ms

    rs.onEvent("jdk.VirtualThreadPinned", event -> {
        logger.warn("PINNING detectado: duração={}ms, stack={}",
            event.getDuration().toMillis(),
            event.getStackTrace().getFrames().stream()
                .limit(10)
                .map(f -> f.getMethod().getType().getName() + "." + f.getMethod().getName())
                .collect(Collectors.joining(" -> ")));
    });

    rs.startAsync(); // não bloqueia — roda em background
    // Deixar rodando por 5-10 minutos sob carga real e analisar os logs
}

Integrando JFR com Micrometer e Prometheus

Para monitoramento contínuo em produção, integre os eventos de pinning com sua stack de observabilidade. Aqui está um componente Spring Boot que expõe a contagem de pinning como métrica:

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import jdk.jfr.consumer.RecordingStream;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.springframework.stereotype.Component;
import java.time.Duration;

@Component
public class VirtualThreadPinningMonitor {

    private final Counter pinningCounter;
    private RecordingStream recordingStream;

    public VirtualThreadPinningMonitor(MeterRegistry registry) {
        this.pinningCounter = Counter.builder("jvm.virtual.thread.pinned")
            .description("Total de eventos de Thread Pinning detectados")
            .register(registry);
    }

    @PostConstruct
    public void startMonitoring() {
        recordingStream = new RecordingStream();
        recordingStream.enable("jdk.VirtualThreadPinned")
            .withThreshold(Duration.ofMillis(10));

        recordingStream.onEvent("jdk.VirtualThreadPinned", event -> {
            pinningCounter.increment();
            if (event.getDuration().toMillis() > 100) {
                // Pinning longo — log detalhado para investigação
                log.error("PINNING LONGO detectado: {}ms em {}",
                    event.getDuration().toMillis(),
                    event.getStackTrace());
            }
        });

        recordingStream.startAsync();
    }

    @PreDestroy
    public void stopMonitoring() {
        if (recordingStream != null) {
            recordingStream.close();
        }
    }
}

Com esse componente, você tem uma métrica jvm_virtual_thread_pinned_total disponível no endpoint do Prometheus. Configure um alerta no Alertmanager ou Grafana:

# prometheus-alerts.yml
groups:
  - name: virtual-threads
    rules:
      - alert: VirtualThreadPinningHigh
        expr: rate(jvm_virtual_thread_pinned_total[5m]) > 10
        for: 2m
        labels:
          severity: warning
        annotations:
          summary: "Thread Pinning alto detectado em {{ $labels.instance }}"
          description: "Taxa de pinning: {{ $value }} eventos/segundo nos últimos 5 minutos"

      - alert: VirtualThreadPinningCritical
        expr: rate(jvm_virtual_thread_pinned_total[1m]) > 50
        for: 30s
        labels:
          severity: critical
        annotations:
          summary: "CRÍTICO: Carrier starvation em risco em {{ $labels.instance }}"

O que o Java 25 (JEP 491) resolveu — e o que ainda existe

O JEP 491, entregue no Java 25, foi um marco real: ele eliminou o pinning causado por blocos synchronized. A implementação exigiu mudanças profundas no runtime da JVM — agora ela consegue suspender uma virtual thread mesmo estando dentro de um monitor synchronized, algo que parecia impossível nas versões anteriores.

Na prática, isso resolve o caso mais comum de pinning em bibliotecas Java modernas:

  • HikariCP 5.0 e anteriores: tinha casos de pinning em seções synchronized do pool de conexões. Com Java 25, resolvido sem atualizar o HikariCP.
  • Muitas implementações de cache (Caffeine, Guava Cache): usam synchronized internamente. Java 25 resolve.
  • Código legado com synchronized: se você tem código antigo cheio de métodos synchronized, Java 25 resolve o pinning causado por eles sem necessidade de refatoração.

O que ainda causa pinning no Java 25:

  • Código JNI (Java Native Interface): qualquer chamada a código nativo ainda pinna a carrier thread. O mecanismo de suspensão das virtual threads não funciona enquanto a JVM está em código nativo — limitação fundamental do modelo de segurança da JVM.
  • Drivers JDBC com componentes nativos: Oracle JDBC (ojdbc) em certas configurações de SSL, IBM DB2, alguns drivers de bancos legados.
  • Libs de criptografia em modo JNI: versões antigas do Bouncy Castle, implementações nativas de AES/RSA via JNI.
// Como identificar se o problema é JNI no stack trace:
// Procure pela linha "(Native Method)" no output do tracePinnedThreads

// Stack trace de pinning por synchronized (RESOLVIDO no Java 25):
// VirtualThread[#42]/runnable@ForkJoinPool-1-worker-1
//     com.example.OrderService.processOrder(OrderService.java:55)  <-- seu código
//     BLOCKED in synchronized block

// Stack trace de pinning por JNI (AINDA OCORRE no Java 25):
// VirtualThread[#42]/runnable@ForkJoinPool-1-worker-1
//     com.oracle.jdbc.driver.OracleDriver.connect(...)
//     (Native Method)   <-- esta linha confirma JNI: é o problema real

Os três cenários de fix e como aplicar cada um

Cenário A — Pinning em código seu com synchronized (Java 21-24)

Se você está em Java 21-24 e o stack trace aponta para um método synchronized do seu código, a solução é substituir por ReentrantLock:

import java.util.concurrent.locks.ReentrantLock;

// ANTES — causa pinning no Java 21-24:
public class EstoqueService {
    private final Map<Long, Integer> estoque = new HashMap<>();

    public synchronized void atualizarEstoque(Long produtoId, int quantidade) {
        // I/O dentro de synchronized = pinning garantido no Java 21-24
        Produto produto = produtoRepository.findById(produtoId).orElseThrow();
        estoque.put(produtoId, quantidade);
        auditLog.registrar(produto.getNome(), quantidade); // I/O de log
    }
}

// DEPOIS — sem pinning em qualquer versão Java:
public class EstoqueService {
    private final Map<Long, Integer> estoque = new HashMap<>();
    private final ReentrantLock lock = new ReentrantLock();

    public void atualizarEstoque(Long produtoId, int quantidade) {
        // ReentrantLock permite que a JVM desmonte a VT durante o lock
        Produto produto = produtoRepository.findById(produtoId).orElseThrow();

        lock.lock();
        try {
            estoque.put(produtoId, quantidade);
        } finally {
            lock.unlock();
        }

        // I/O fora do lock — virtual thread pode ser desmontada livremente
        auditLog.registrar(produto.getNome(), quantidade);
    }
}

Cenário B — Pinning em dependência externa com JNI

Se o stack trace aponta para uma biblioteca de terceiros com código nativo, você tem duas opções:

Opção B1 — Atualizar para versão pure-Java (preferida):

BibliotecaVersão com JNI (problema)Versão pure-Java (solução)
PostgreSQL JDBCNão aplicável42.x (sempre foi pure-Java) ✅
Oracle JDBCojdbc8 com SSL nativoojdbc11 21.x+ (suporte a VT) ✅
HikariCP5.0.x (synchronized)5.1.0+ (locks explícitos) ✅
Bouncy Castle1.70 modo JNI1.78+ pure-Java provider ✅
Netty< 4.1.100 (OpenSSL JNI)4.1.100+ (melhorias VT) ✅

Opção B2 — Isolar operações JNI em executor dedicado:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.CompletableFuture;
import org.springframework.stereotype.Component;
import jakarta.annotation.PreDestroy;

@Component
public class NativeOperationExecutor {

    // Pool dedicado de platform threads para operações JNI
    // Platform threads não sofrem carrier starvation
    private static final int NATIVE_THREAD_POOL_SIZE =
        Math.max(20, Runtime.getRuntime().availableProcessors() * 2);

    private final ExecutorService nativeExecutor = Executors.newFixedThreadPool(
        NATIVE_THREAD_POOL_SIZE,
        Thread.ofPlatform()
              .name("native-ops-", 0)
              .factory()
    );

    // Executa operação JNI em platform thread dedicada
    // A virtual thread chamadora aguarda sem pinning
    public <T> CompletableFuture<T> executeNative(java.util.function.Supplier<T> nativeOp) {
        return CompletableFuture.supplyAsync(nativeOp, nativeExecutor);
    }

    @PreDestroy
    public void shutdown() {
        nativeExecutor.shutdown();
    }
}

// Uso no serviço:
@Service
public class CriptografiaService {

    private final NativeOperationExecutor nativeExecutor;
    private final LegacyCryptoLib legacyCrypto; // lib JNI que não pode ser trocada

    public CompletableFuture<byte[]> assinarDocumento(byte[] dados) {
        // Delega para platform thread — virtual thread chamadora não fica pinada
        return nativeExecutor.executeNative(() -> legacyCrypto.sign(dados));
    }
}

Cenário C — Sem pinning detectado

Se você rodou o diagnóstico com carga representativa e não apareceu nenhum log de pinning, sua aplicação está usando Virtual Threads corretamente. Com Java 25, se você não tem código JNI significativo, esse é o resultado esperado. Não invente problemas — você pode operar com confiança.

Checklist de produção: antes de habilitar Virtual Threads no Java 25

  • ✅ Rodar com -Djdk.tracePinnedThreads=full em staging com carga representativa por pelo menos 30 minutos
  • ✅ Verificar drivers JDBC: confirmar que estão em versões com suporte explícito a Virtual Threads (ver tabela de versões acima)
  • ✅ Verificar libs de criptografia: identificar qualquer uso de Bouncy Castle, SSLContext com providers nativos, ou libs de HSM
  • ✅ Configurar alerta de pinning no Prometheus/Grafana antes de ir para produção
  • ✅ Testar sob carga de pico, não apenas em carga baixa — pinning é invisível com poucos usuários
  • ✅ Confirmar versão do HikariCP ≥ 5.1.0 se usar Spring Boot com JDBC
  • ⚠️ Atenção especial a workloads de criptografia — são o caso mais comum de JNI pinning em produção

Query Grafana para monitorar pinning em produção

# Taxa de pinning por instância (alertar se > 5/min)
rate(jvm_virtual_thread_pinned_total{application="seu-app"}[5m]) * 60

# Total acumulado de eventos de pinning
sum by (instance) (jvm_virtual_thread_pinned_total{application="seu-app"})

# Comparativo antes/depois de uma mudança de versão
increase(jvm_virtual_thread_pinned_total[1h])

Takeaways e próximos passos

  • Use -Djdk.tracePinnedThreads=full em desenvolvimento — é gratuito, mostra o problema antes de chegar em produção e aponta a linha exata do código
  • Use JFR em produção quando suspeitar de pinning — overhead < 1%, dados precisos, sem restart
  • Java 25 + JEP 491 eliminou o pinning de synchronized, mas código JNI ainda é um risco real
  • Isole código JNI em um pool dedicado de platform threads se não puder migrar a biblioteca
  • Monitore proativamente com Micrometer + Prometheus antes que o carrier starvation apareça em produção sob pico
  • Atualize suas dependências: HikariCP 5.1+, ojdbc11, Netty 4.1.100+ — a maioria dos problemas de pinning em libs populares já foi resolvida

Você já rodou o diagnóstico de pinning na sua aplicação? Encontrou algum caso inesperado com JNI? Conta nos comentários — quero saber o que está aparecendo em produção aí.

Na próxima semana vou mostrar como usar o Structured Concurrency do Java 25 junto com Virtual Threads para cancelamento automático e propagação de escopo — o próximo nível depois de dominar o básico de VTs.