Java Virtual Threads em Profundidade
Uma análise aprofundada das threads virtuais do Java: pinning, escalonamento e como migrar código de threads de plataforma.
Threads de plataforma — o único tipo que o Java possuía até o JDK 21 — mapeiam um a um nos threads do sistema operacional. Elas são caras: cada uma reserva aproximadamente um megabyte de pilha e o escalonador do SO consegue gerenciar apenas alguns milhares antes que a troca de contexto consuma sua CPU. As threads virtuais, entregues pelo Project Loom, quebram esse limite. São threads leves gerenciadas pela JVM, não pelo SO, portanto um único programa pode executar milhões delas. Este capítulo vai além da introdução: como elas são escalonadas, o que é pinning, como a concorrência estruturada vincula seus ciclos de vida e onde elas ajudam (e onde não ajudam).
Se você é novo no assunto, leia primeiro a introdução às threads virtuais; este capítulo pressupõe que você já sabe como iniciar uma. Uma base em multithreading em Java e no framework executor também será útil.
Threads de plataforma vs. threads virtuais
Uma thread virtual ainda é uma java.lang.Thread — a mesma API, o mesmo Runnable. A diferença está no que a suporta. Uma thread de plataforma é uma thread do SO por toda a sua vida. Uma thread virtual é executada em um pequeno pool de threads de plataforma chamadas de carriers: quando ela bloqueia em I/O, a JVM a desmonta do seu carrier, libera esse carrier para outra thread virtual e remonta a thread virtual quando o I/O é concluído. Bloquear uma thread virtual é barato; bloquear uma thread de plataforma desperdiça um recurso escasso.
| Aspecto | Thread de plataforma | Thread virtual |
|---|---|---|
| Suportada por | Uma thread do SO | Uma thread carrier em pool |
| Custo de memória | ~1 MB de pilha fixo | Alguns centenas de bytes, cresce sob demanda |
| Quantidade prática | Milhares | Milhões |
| Melhor para | Trabalho CPU-bound | Trabalho I/O-bound e de alta concorrência |
| Quando bloqueia | Desperdiça a thread do SO | Desmonta; o carrier é reutilizado |
| Ciclo de vida | Usar pool e reutilizar | Criar uma por tarefa, descartável |
O modelo mental muda. Com threads de plataforma, você dimensiona cuidadosamente um pool e reutiliza threads. Com threads virtuais, você cria uma por tarefa e a deixa morrer — elas são baratas o suficiente para serem descartáveis.
Criando threads virtuais
Existem três pontos de entrada idiomáticos. Para uma única tarefa, use o builder Thread.ofVirtual() ou o atalho Thread.startVirtualThread; para muitas tarefas, use um executor virtual-thread-per-task.
// One-off, started immediately.
Thread t = Thread.startVirtualThread(() ->
System.out.println("hi from " + Thread.currentThread()));
t.join();
// Builder: configure before starting.
Thread named = Thread.ofVirtual().name("worker-", 0).unstarted(() -> doWork());
named.start();
// Many tasks: the executor creates a fresh virtual thread per submitted task.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000; i++) {
executor.submit(() -> handleRequest());
}
} // close() waits for every task to finishNunca coloque threads virtuais em pool. Um pool de threads fixo tradicional limita a concorrência propositalmente; envolver threads virtuais em Executors.newFixedThreadPool(...) joga fora toda a vantagem delas. A ferramenta correta é newVirtualThreadPerTaskExecutor(), que não impõe limite de tamanho.
Escalonamento, carriers e pinning
As threads virtuais são escalonadas por um ForkJoinPool dedicado cujo número de workers padrão é igual ao número de núcleos de CPU. Esses workers são as threads carriers. Quando uma thread virtual atinge uma chamada bloqueante na JDK — Thread.sleep, leituras de socket, BlockingQueue.take — o runtime a desmonta para que o carrier possa executar outra coisa.
Às vezes uma thread virtual não pode ser desmontada e permanece presa ao seu carrier. Isso é pinning, e derrota o propósito: uma thread virtual bloqueada mas fixada mantém um carrier como refém. Duas situações causam isso:
| Causa do pinning | Por que acontece | Solução |
|---|---|---|
Dentro de um bloco/método synchronized | O monitor está vinculado ao carrier | Substituir por ReentrantLock |
| Dentro de uma chamada nativa (JNI) | O runtime não consegue capturar a pilha nativa | Evitar bloqueios em código nativo |
// Pins the carrier while sleeping — bad.
synchronized (lock) {
Thread.sleep(1000); // the virtual thread cannot unmount here
}
// Does not pin — good.
lock.lock();
try {
Thread.sleep(1000); // the virtual thread unmounts freely
} finally {
lock.unlock();
}Você pode diagnosticar o pinning executando com -Djdk.tracePinnedThreads=full, que imprime um stack trace sempre que uma thread virtual fixa seu carrier.
Concorrência estruturada
Criar threads de forma ad hoc causa vazamentos: se uma subtarefa falhar, suas irmãs continuam em execução e você precisa se lembrar de cancelá-las. A concorrência estruturada (StructuredTaskScope, uma API de preview) faz com que um grupo de subtarefas se comporte como uma única unidade de trabalho — elas são bifurcadas juntas, aguardadas juntas e canceladas juntas. Quando o escopo pai sai, todas as filhas têm a garantia de estar concluídas.
import java.util.concurrent.StructuredTaskScope;
Response handle() throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var user = scope.fork(() -> fetchUser()); // subtask 1
var order = scope.fork(() -> fetchOrder()); // subtask 2
scope.join(); // wait for both
scope.throwIfFailed(); // propagate the first failure, cancel the rest
return new Response(user.get(), order.get());
} // both subtasks are guaranteed finished or cancelled here
}ShutdownOnFailure cancela as subtarefas restantes no momento em que uma lança uma exceção; ShutdownOnSuccess retorna assim que a primeira subtarefa for concluída (útil para disputar chamadas redundantes). De qualquer forma, não há threads órfãs. Para a API completa e mais padrões, consulte o capítulo de concorrência estruturada.
Um exemplo prático: dez mil tarefas simultâneas
O programa abaixo submete 10.000 tarefas I/O-bound — cada uma dorme 50 ms para simular uma chamada de rede — a um executor virtual-thread-per-task. Ele conta quantas threads carriers distintas realmente executaram o trabalho e compara o tempo de parede contra a execução das mesmas tarefas uma após a outra.
O que observar na execução:
- 10.000 tarefas são concluídas, mas toda a execução termina em bem menos de um segundo — nada perto dos ~500.000 ms que os mesmos sleeps levariam executados sequencialmente, porque toda a espera se sobrepõe.
- O número de threads carriers é igual ao número de núcleos de CPU (
Carrier threadscorresponde aAvailable cores): milhares de threads virtuais são multiplexadas nesse pequeno conjunto de threads de plataforma. Thread.sleepdesmonta a thread virtual do seu carrier, que é exatamente o motivo pelo qual tão poucos carriers conseguem atender tantas tarefas de uma vez — o carrier nunca fica ocioso esperando.- Fechar o
newVirtualThreadPerTaskExecutor()em um bloco try-with-resources bloqueia até que todas as tarefas submetidas terminem, portanto a contagem de tarefas concluídas sempre chega a 10.000 antes que o tempo seja impresso. isVirtual()retornatrueeisDaemon()retornatrue— threads virtuais são sempre threads daemon, portanto nunca mantêm a JVM ativa por conta própria.
Quando usar threads virtuais (e quando não usar)
Threads virtuais são vantajosas quando suas tarefas passam a maior parte do tempo esperando — na rede, em um banco de dados, em um arquivo ou em um serviço externo. Esse é o padrão comum de trabalho no lado do servidor, portanto o conselho típico é simples: use uma thread virtual por requisição e escreva código bloqueante simples.
Elas não são um ganho de velocidade para trabalho CPU-bound. Uma tarefa que realiza cálculos nunca bloqueia, portanto nunca é desmontada; executar um milhão delas apenas adiciona overhead de escalonamento. Para computação pura, dimensione um pool para a contagem de núcleos. Mais duas coisas a ter em mente:
- Audite os hot paths em busca de blocos
synchronizedque envolvam chamadas bloqueantes e migre-os paraReentrantLockpara evitar pinning. - Não coloque threads virtuais em cache ou pool, e não dependa de estado local de thread para limitar a concorrência — use um semáforo ou outro limitador explícito se precisar controlar a taxa de execução.