Virtual Threads Depois do JEP 491: O Gargalo Se Moveu
O JEP 491 removeu o problema de pinning do `synchronized` que mantinha as virtual threads fora da produção. A pergunta interessante agora não é se habilitar ou não — é qual gargalo aparece em seguida. Um guia de campo para serviços Spring Boot / Kotlin rodando em JDK 24+.
Por três anos, a resposta honesta para "devo ligar virtual threads?" era "depende, e provavelmente ainda não". A ressalva que matou a maioria dos rollouts era o pinning: qualquer thread que entrasse em um bloco synchronized ficava presa ao seu carrier, e como metade dos drivers JDBC, frameworks de logging e pools de conexão do ecossistema ainda usa synchronized em algum lugar profundo, um teste de carga modesto podia transformar seu pool de carriers em um engarrafamento.
O JDK 24 lançou o JEP 491 e silenciosamente removeu essa ressalva. synchronized não prende mais o carrier. A objeção principal se foi. O Spring Boot 4 está à vontade para tornar spring.threads.virtual.enabled=true o padrão sensato — agora é o default para handlers de Tomcat e Jetty no Java 21+.
O que significa que o problema de engenharia interessante se moveu. Não é mais "meu driver vai causar pinning?". É "o que vai quebrar em seguida assim que eu puder criar um milhão de threads barato?". Este post percorre onde as virtual threads realmente te compram escalabilidade em um serviço Spring Boot 4 / Kotlin, onde elas silenciosamente não compram, e o que medir antes de confiar na flag.
Por que pinning mantinha virtual threads fora da produção
O modelo mental pré-JEP-491 era simples: virtual threads são ótimas, exceto quando prendem, e elas prendem em todas as bibliotecas com que você se importa. Então eu tratava como curiosidade. Serviços que precisavam de concorrência iam reativos — WebFlux, corrotinas Kotlin em Dispatchers.IO, pipelines do Project Reactor — e o imposto do "color of your function" era o preço por não bloquear threads de plataforma.
Pós-JEP-491, você pode deletar boa parte desse andaime reativo e voltar a escrever código bloqueante em linha reta. Tentador. A armadilha é supor que o gargalo algum dia foi a JVM.
Não era. A JVM era o teto mais visível porque o pinning era fácil de identificar em um flame graph. Abaixo dele havia três outros tetos que ninguém batia porque a JVM batia primeiro:
- Saturação do pool de conexões. Um pool
HikariCPcom 20 conexões ainda são 20 conexões. Virtual threads deixam 10.000 requisições enfileirarem para essas 20 conexões em vez de quebrar seu executor — o que parece escalabilidade até você olhar a latência p99. - Bibliotecas pesadas em
ThreadLocal. Implementações de MDC que fazem cache por thread, caches de sessão de ORM, alguns agentes de tracing — todas assumem que threads são caras e de longa duração. Virtual threads não são nem uma nem outra. - Chamadas nativas bloqueantes. JNI, alguns caminhos de criptografia, algumas operações de filesystem ainda prendem o carrier. Raras, mas de alta variância: um pinning inesperado por requisição é suficiente para achatar o throughput.
Ligar virtual threads sem saber qual desses está mais perto é como times acabam reportando que "virtual threads não ajudaram" ou, pior, que pioraram as coisas.
Um modelo mental em duas camadas
O modelo mental que uso agora tem duas camadas.
Camada um: virtual threads são uma feature de modelo de programação, não de performance. Elas permitem que código síncrono e imperativo escale até o número de requisições em voo que suas dependências downstream de fato conseguem atender. Elas não criam capacidade. Se seu banco consegue atender 200 queries/s, virtual threads te dão uma forma mais agradável de enfileirar para essa capacidade — nada mais.
Camada dois: todo serviço tem um próximo gargalo. Antes de ligar virtual threads, você deveria conseguir nomeá-lo. Se não consegue, o movimento honesto é construir um teste de carga que o encontre, não virar a flag e torcer.
O workflow prático:
- Decida qual concorrência você realmente precisa (requisições em voo, não RPS).
- Identifique as restrições downstream — tamanhos de pool, rate limits, capacidade upstream.
- Vire
spring.threads.virtual.enabled=trueem um ambiente de staging. - Rode um teste de carga sustentado com gravação JFR.
- Procure por
jdk.VirtualThreadPinned, eventos de exaustão de pool e drift na latência p99. - Decida se o teto remanescente vale a pena consertar, ou se o comportamento atual já é aceitável.
O objetivo é chegar a um serviço onde o gargalo é explícito, documentado e com dono — não um que por acaso roda rápido hoje.
Subindo virtual threads no JDK 24+
O que o JEP 491 realmente mudou
Antes do JDK 24, entrar em um bloco synchronized a partir de uma virtual thread montava essa thread em seu carrier e se recusava a desmontar até o bloco sair. Se o código dentro do bloco bloqueasse em I/O, o carrier se ia. Você podia ficar sem carriers (padrão: número de cores de CPU) tendo milhões de virtual threads ociosas.
O JEP 491 refez a implementação do monitor de forma que uma virtual thread bloqueada dentro de synchronized agora desmonta do seu carrier da mesma forma que faria dentro de um ReentrantLock. O pool de carriers permanece livre. O evento JFR VirtualThreadPinned ainda dispara nos poucos casos que genuinamente causam pinning (frames nativos, bordas específicas de inicialização de classe), mas o caso comum de synchronized { blockingIO() } não mais.
Essa é a parte que a maioria dos times esperava. É a razão pela qual você agora pode ligar a flag sem uma auditoria manual de toda dependência transitiva. O JDK 25 desde então refinou as bordas restantes ainda mais; o cenário só melhora à medida que você avança.
Habilitando virtual threads no Spring Boot 4
No Spring Boot 4 a flag fica ligada por default para Tomcat, Jetty, @Async e executores de tarefas agendadas quando rodando no Java 21+. Se precisar ser explícito, a propriedade ainda está ali:
spring.threads.virtual.enabled=trueSeus handlers @RestController agora rodam em virtual threads por padrão. Chamadas bloqueantes dentro deles estão bem — esse é o ponto.
O que isso não faz: reconfigurar seus pools de conexão, seus thread pools de HTTP client ou qualquer ExecutorService que você tenha construído. Esses ainda são pools de threads de plataforma. Se quiser eles em virtual threads, construa explicitamente:
val scope = Executors.newVirtualThreadPerTaskExecutor()A medição que importa
JFR é a fonte da verdade. Inicie uma gravação:
jcmd <pid> JFR.start name=vt duration=120s \
settings=profile filename=vt.jfrEntão filtre pelos eventos que importam:
jfr print --events jdk.VirtualThreadPinned,jdk.VirtualThreadSubmitFailed vt.jfrVirtualThreadPinned dispara quando um carrier ficou preso por mais de 20 ms (o threshold padrão do JFR) — isso te diz onde o pinning remanescente acontece. VirtualThreadSubmitFailed te diz que seu pool de carriers está faminto — normalmente porque algo está genuinamente causando pinning por longos períodos.
Se ambos estão quietos e seu throughput ainda não escala, o gargalo não está no modelo de threading. Está downstream.
Um exemplo concreto em Kotlin
Um controller fino batendo em um repositório JDBC com uma query de 100ms:
@RestController
class OrdersController(private val repo: OrderRepository) {
@GetMapping("/orders/{id}")
fun get(@PathVariable id: Long): OrderDto =
repo.findById(id).toDto()
}
@Repository
class OrderRepository(private val jdbc: JdbcTemplate) {
fun findById(id: Long): Order =
jdbc.queryForObject(
"SELECT pg_sleep(0.1), id, total FROM orders WHERE id = ?",
orderRowMapper, id
)!!
}Com threads de plataforma e o executor padrão de 200 threads do Tomcat, isso teto em torno de 2.000 req/s: 200 threads × 10 queries/s por thread. Adicionar threads piora — context switching e pressão do GC sobem mais rápido que o throughput.
Vire spring.threads.virtual.enabled=true, mantenha o HikariCP em 20 conexões, e o throughput vai para aproximadamente 200 req/s — 20 conexões × 10 queries/s. Pior. Todo mundo enfileira limpo no pool, mas o pool é o teto.
Suba o HikariCP para 100 conexões e você consegue 1.000 req/s, limpinho. Suba para 400 e você descobre se sua instância de Postgres gosta de 400 conexões simultâneas. (Spoiler: normalmente não.)
A lição: virtual threads não tornaram o serviço mais rápido. Elas tornaram o teto real visível. Em threads de plataforma, a JVM estava absorvendo carga recusando-se a aceitar. Em virtual threads, a carga chega ao banco, e o banco te conta a verdade.
Dimensionando pools para virtual threads
O velho conselho do HikariCP — "cores × 2, mais um pouco" — foi escrito quando threads eram caras e você estava protegendo a JVM. Em virtual threads, você está protegendo o banco. A conta muda:
- Meça a concorrência ativa que seu banco de fato sustenta sob queries realistas. Raramente é o número que o material de marketing sugere.
- Configure o pool para esse número. Não mais alto. Um pool maior não deixa o banco mais rápido; só permite que mais queries se acumulem antes de dar timeout.
- Use
connectionTimeoutcomo sinal de back-pressure, não como um erro para suprimir. Se requisições estão estourando timeout esperando uma conexão, a resposta normalmente é fazer shed de carga, não crescer o pool.
A Lei de Little ainda vale: concorrência = throughput × latência. Se você quer 1.000 req/s a 100ms cada, você precisa de 100 conexões em voo, ponto. Virtual threads não mudam isso. Mudam como a fila acima parece.
A sobreposição com corrotinas Kotlin
Corrotinas Kotlin em Dispatchers.IO eram a resposta pragmática para "preciso de concorrência e não quero reativo". Ainda funcionam. O que vale pensar é se empilhá-las sobre virtual threads faz sentido.
Dispatchers.IO respaldado por um pool de threads de plataforma mais virtual threads no nível da requisição: tudo bem. O dispatcher de corrotina entrega trabalho bloqueante para um pool, o pool agora roda sobre virtual threads, e você ganha a escalabilidade.
Dispatchers.IO substituído por um dispatcher respaldado por um executor de virtual threads: também ok, mas agora você tem duas camadas de scheduling — o scheduler de continuation da corrotina e o scheduler de virtual thread — e depurar stack traces fica interessante. Eu iria por esse caminho só se tivesse um motivo específico, como querer comportamento de ThreadLocal através de suspensões de corrotina, e documentaria o porquê.
O que ainda morde depois do JEP 491
A armadilha da auditoria de synchronized. Times às vezes gastam uma semana auditando cada bloco synchronized na árvore de dependências antes de habilitar virtual threads. No JDK 24+ esse trabalho é em boa parte desperdício. Migrações para ReentrantLock ainda são razoáveis para código que você controla, por outros motivos (interruptibilidade, fairness), mas não são mais pré-requisito.
Vazamentos de ThreadLocal parecem diferentes. Um ThreadLocal que acumulava valores ao longo da vida de uma thread de plataforma costumava vazar devagar. Em virtual threads, o ThreadLocal é criado e destruído com cada requisição — o que soa melhor, até você perceber que qualquer ThreadLocal usado como cache tem sua hit rate despencando. Procure por bibliotecas (agentes de tracing mais antigos, versões de Hibernate anteriores à 6.x) que assumiam que threads eram de longa duração. Considere ScopedValue para código novo.
Debuggers e profilers ficam para trás. Ferramentas que assumiam que contagem de threads correlaciona com carga vão te dar bobagem. Um serviço saudável pode mostrar 50.000 virtual threads durante um pico. Isso não é vazamento. Os principais fornecedores de APM se atualizaram, mas profilers de amostragem configurados com limites de thread apertados ainda descartam eventos.
Pinning não é a única forma de bloquear um carrier. Frames de JNI, inicializadores de classe e Object.wait em caminhos de código legados ainda podem causar pinning. O evento VirtualThreadPinned vai te contar. Não assuma que JEP 491 significa "sem pinning nunca" — significa "sem pinning por synchronized".
Trabalho CPU-bound não se beneficia. Virtual threads ajudam quando você tem muitas requisições esperando em I/O. Uma requisição fazendo computação pesada em um core não liga para modelos de threading. Se seu serviço é CPU-bound, foque no algoritmo ou no número de cores, não na flag.
Structured concurrency ainda é preview. É a companheira natural das virtual threads e é genuinamente agradável. O JDK 26 traz o sexto preview (JEP 525); a finalização está mirada no JDK 27. Planeje para algum churn de API se adotar agora.
Conclusões Práticas
- O JEP 491 remove a principal razão pela qual os times adiavam virtual threads. No JDK 24+, virtual threads ligadas por default no Spring Boot 4 é uma escolha razoável para novos serviços.
- Virtual threads não criam capacidade. Elas expõem seu próximo gargalo — geralmente um pool de conexões, às vezes uma suposição de
ThreadLocal, ocasionalmente uma chamada nativa. - Use JFR (
jdk.VirtualThreadPinned,jdk.VirtualThreadSubmitFailed) como fonte da verdade, não posts de blog ou benchmarks. - Redimensione pools de conexão com base no que seu banco de fato sustenta, não nas antigas regras de bolso da JVM. A Lei de Little ainda governa.
- Não substitua
Dispatchers.IOpor um dispatcher respaldado por virtual thread sem um motivo específico. Duas camadas de scheduling são overhead de debug que você não precisa. - Serviços CPU-bound não são afetados. Não prometa ganhos de performance que você não pode entregar.
- Antes de virar a flag em produção, rode um teste de carga sustentado em staging com gravação JFR. Encontre o próximo teto lá, não às 3 da manhã.
Quando virar a flag, quando não virar
Virtual threads não são uma feature de performance. São uma feature de modelo de programação que por acaso destrava escalabilidade assim que você remove o teto que elas substituem. O JEP 491 finalmente baixou esse teto o suficiente para que os outros tetos se tornem os interessantes — e esses estão nos seus pools, suas bibliotecas e seus serviços downstream, não na JVM.
Ligue. Meça o que acontece. Se o throughput subir, você tinha folga downstream. Se a latência piorar, você acabou de achar onde seu gargalo real mora — e essa é a informação que você queria de qualquer jeito.
Use virtual threads quando: seu serviço é I/O-bound, suas dependências downstream têm mais capacidade do que seu modelo de threads atual expõe, e você está escrevendo código síncrono que é legível e testável.
Pule quando: você é CPU-bound, seu downstream já é o teto e você não tem como crescê-lo, ou você está em um JDK anterior ao 24 e a auditoria de pinning dominaria o esforço.
A conclusão não é que virtual threads são boas ou ruins. É que "devo habilitar virtual threads?" virou uma pergunta de medição em vez de compatibilidade de biblioteca. Esse é um lugar muito melhor para estar.
Curtindo? Talvez goste disso aqui.
Nada parecido — quer tentar outro ângulo?
Posts Relacionados
Expondo Agentes Spring AI via o Protocolo A2A: O Que a Interoperabilidade Realmente Te Dá
A integração A2A do lado servidor no Spring AI já está estável o suficiente para colocar em produção, mas o protocolo é mais útil em fronteiras organizacionais, não como substituto de RPC interno. Este post percorre o que de fato muda em uma codebase Spring AI, onde ainda existem arestas afiadas, e um framework prático de decisão entre A2A, MCP e REST puro.
DBOS vs Temporal: Quando o Postgres É Suficiente para Execução Durável de Workflows
O DBOS reutiliza o Postgres como camada de durabilidade para workflows, enquanto o Temporal roda um cluster dedicado. A escolha certa depende do tamanho do time, da forma do workload e de onde você quer que seu orçamento operacional vá. Este é um critério prático para escolher entre eles.
JetBrains Tracy: Observabilidade Pragmática de IA para Kotlin
JetBrains Tracy é uma biblioteca Kotlin que conecta tracing ciente de LLM na sua aplicação em cima do OpenTelemetry. Este post percorre como eu integrei no serviço Spring Boot, as decisões de design que importam, e os modos de falha que times encontram quando chamadas de LLM se tornam o caminho mais quente do sistema.