Threads Virtuais em Java
Threads leves agendadas pela JVM (Java 21+) para aplicações concorrentes de alta vazão — o que corrigem e como mudam o dimensionamento de pools.
Cada capítulo desta parte do livro até agora descreveu uma thread de plataforma — uma Thread Java que mapeia um-a-um para uma thread do sistema operacional. Threads de plataforma são poderosas, mas custosas: cada uma ocupa cerca de 1 MB de pilha nativa, e o SO limita um processo a aproximadamente dezenas de milhares delas. Para trabalho ligado à CPU, isso é suficiente. Para trabalho ligado a I/O — um servidor web com uma thread por requisição que passa a maior parte do tempo esperando um banco de dados — é um teto rígido que tem sido a tensão central no design de servidores Java há duas décadas.
O Java 21 introduziu threads virtuais para resolver exatamente esse caso. Uma thread virtual é uma Thread Java agendada pela JVM (não pelo SO) sobre um pequeno pool de threads de sistema operacional chamadas carriers. Elas são baratas — milhões por JVM são rotina — e bloquear em I/O estaciona a thread virtual sem estacionar o carrier. O código parece igual ao de antes; o modelo de custo é diferente.
O que muda (e o que não muda)
Threads virtuais são java.lang.Threads. A classe é a mesma; os métodos são os mesmos; Thread.currentThread() ainda funciona. O que é diferente é como elas são agendadas e quanto custam:
- Uma thread de plataforma custa cerca de 1 MB de pilha nativa e é agendada pelo SO.
- Uma thread virtual custa cerca de 1 KB inicialmente (cresce conforme necessário) e é agendada pela JVM.
- Bloquear uma thread de plataforma bloqueia a thread do SO subjacente.
- Bloquear uma thread virtual estaciona a thread virtual; a thread carrier do SO vai executar uma thread virtual diferente.
Esse quarto ponto é o destaque. Quando uma thread virtual chama Socket.read(), Thread.sleep(), BlockingQueue.take(), Lock.lock(), ou basicamente qualquer API JDK bloqueante, a JVM a desacopla de seu carrier e o carrier pega outra thread virtual para executar. A thread virtual bloqueada custa quase nada enquanto espera.
Criando threads virtuais
Três formas:
// 1. Direct
Thread t = Thread.ofVirtual().start(() -> doWork());
// 2. Builder
Thread t2 = Thread.ofVirtual().name("vt-", 0).start(this::work); // names "vt-0", "vt-1", ...
// 3. Executor — the production form
try (ExecutorService es = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
es.submit(() -> handleRequest());
}
}A forma com executor é o que quase todo servidor usa. Ela entrega uma thread virtual por tarefa submetida; não há pool para dimensionar porque o pool de carriers se dimensiona sozinho.
Você também pode obter uma thread de plataforma quando quiser especificamente uma:
Thread t = Thread.ofPlatform().name("compute").start(() -> doCpu());Útil para trabalho genuinamente ligado à CPU, onde o mapeamento um-a-um com o SO é o que você quer.
Quando threads virtuais vencem
O perfil para o qual threads virtuais são otimizadas:
- Muitas tarefas concorrentes (centenas, milhares, milhões).
- Cada tarefa passa a maior parte do tempo bloqueada em I/O, filas ou locks.
- O trabalho não é dominado pela CPU.
Esse é exatamente o perfil de servidor web: cada requisição é uma tarefa que principalmente aguarda um banco de dados, um serviço upstream ou o cliente. Com threads de plataforma, um servidor com 1000 requisições lentas concorrentes precisa de 1000 threads de plataforma — 1 GB de pilha nativa e carga significativa no agendador do SO. Com threads virtuais, a mesma carga de trabalho roda em 8 ou mais carriers; as 1000 threads virtuais custam alguns MB no total.
O modelo mental: pare de pensar em thread pools para trabalho de I/O. Submeta uma thread virtual por requisição e deixe o runtime cuidar do resto.
Quando threads virtuais não vencem
Alguns casos em que elas não ajudam ou prejudicam ativamente:
- Trabalho ligado à CPU. Uma thread virtual executando computação pura não pode ser estacionada — ela tem de rodar em um carrier o tempo todo. Você não será mais rápido do que a contagem de carriers, que é a contagem de CPUs. Para trabalho de CPU, threads de plataforma (e fork/join) continuam sendo a ferramenta certa.
- Blocos
synchronizedem torno de I/O. Uma thread virtual dentro desynchronized (obj) { blockingIO(); }prende ao seu carrier — a JVM não pode desmontá-la durante a chamada bloqueante porque o monitor está vinculado à thread do SO. Essa é uma armadilha real: um servidor que usasynchronizedpara proteger uma chamada de banco de dados não escala com threads virtuais. A solução é usarReentrantLockem vez disso (que a maquinaria de threads virtuais trata corretamente). - Armazenamento
ThreadLocalcom muitas threads. Threads virtuais suportamThreadLocal, mas a contagem pode explodir — milhões de threads virtuais × N thread-locals × tamanho do valor = muita memória. O Java 21 adicionou scoped values (ScopedValue) como uma alternativa estruturada. - Código que assume que uma thread é rara (por exemplo, que cria uma conexão por thread). Uma conexão por thread virtual é uma conexão por requisição, o que o banco de dados detesta. Use um pool de conexões real.
O resumo: threads virtuais tornam a concorrência ligada a I/O barata, mas não transformam trabalho ligado à CPU e expõem caminhos de código que prendem carriers.
Pinning: a única coisa a monitorar
Uma thread virtual presa (pinned) não pode ser desmontada. As duas causas de pinning:
- Blocos
synchronizedque incluem uma chamada bloqueante. - Chamadas de métodos nativos que bloqueiam em JNI.
Você pode detectar pinning via a propriedade do sistema:
java -Djdk.tracePinnedThreads=full ...Se uma thread virtual bloquear enquanto está presa, a JVM imprime um stack trace. Em produção, a solução é substituir synchronized por ReentrantLock em torno da região bloqueante. JDKs futuros estão trabalhando em desafixar synchronized (JEP 491 em progresso); por enquanto, trate qualquer synchronized em torno de uma chamada de I/O como um anti-pattern de threads virtuais.
E quanto a wait, notify e join?
Todos eles funcionam — threads virtuais podem aguardar em monitores intrínsecos, ser notificadas e ter join chamado. O runtime trata o estacionamento e a desmontagem da forma correta. A restrição é apenas em blocos synchronized: manter o monitor durante uma chamada bloqueante dentro do bloco prende; chamar wait() para liberar o monitor e estacionar é seguro.
synchronized (lock) {
lock.wait(); // OK — releases monitor, parks, no pin
}
synchronized (lock) {
socket.read(buf); // BAD — holds monitor through blocking read; pins
}Dimensionando o pool — não há pool
A mudança conceitual que threads virtuais permitem: pare de dimensionar. Todo executor que você configurou neste livro tinha um knob de contagem de threads. Com newVirtualThreadPerTaskExecutor, a contagem é "quantas requisições estão em andamento". O pool de carriers (que você não configura diretamente) se dimensiona com base na contagem de CPUs; as threads virtuais são contabilidade.
Em um servidor usando threads virtuais:
- Pools de conexão ainda importam. Uma thread virtual esperando por uma conexão é aceitável; criar 10.000 delas todas querendo um pool de 5 conexões apenas torna o gargalo visível.
- Limites de taxa ainda importam. Threads virtuais removem o limite de threads, não o limite do serviço downstream.
- A memória ainda importa. Cada thread virtual tem uma pilha e quaisquer
ThreadLocals. Milhões delas são milhões de pilhas.
Threads virtuais removem o teto da contagem de threads; elas não removem as restrições subjacentes que o teto escondia.
Um exemplo prático: um milhão de threads virtuais vs. uma thread de plataforma
O programa abaixo coloca 100.000 tarefas para dormir 200 ms cada uma, em paralelo. Com threads de plataforma (limitadas a uma contagem razoável), isso leva muito tempo e usa muita RAM. Com threads virtuais, termina em pouco mais do que o próprio sleep por tarefa.
O que extrair da execução:
- As 100.000 threads virtuais terminaram em aproximadamente um segundo de tempo real — próximo ao único sleep de 200 ms mais o overhead de criar e agendar 100.000 delas, não 100.000 × 200 ms. Esse é o ponto central das threads virtuais: a concorrência (quantas coisas estão em andamento) é desacoplada do paralelismo (quantos núcleos estão executando trabalho de CPU). O número exato varia por máquina, mas permanece na mesma faixa de poucos segundos independentemente de quão alto você empurre a contagem de tarefas.
- A execução com pool de plataforma de 5.000 tarefas, com 100 threads worker, levou aproximadamente
5000 / 100 * 200 = ~10 segundos— as tarefas ficaram na fila porque o pool só conseguia executar 100 por vez. Para terminar no mesmo tempo de relógio que a versão com threads virtuais, o pool de plataforma precisaria de 100.000 threads, o que está próximo ou além do limite do SO na maioria dos sistemas. Thread.currentThread().isVirtual()distinguiu os dois tipos de thread em tempo de execução. Os nomes também diferem — threads virtuais tipicamente têm uma representação genérica em vez de um nome definido pelo usuário, a menos que você defina um via o builder. Útil para logging quando você mistura os dois tipos.- O aviso de pinning é o único aviso mais importante para threads virtuais em produção. Um bloco
synchronizedem torno de qualquer chamada bloqueante (I/O de banco de dados, I/O de arquivo, rede) anula a maior parte do benefício porque o carrier não pode ser liberado durante a espera. SubstituirsynchronizedporReentrantLockmantém a thread virtual estacionável. - A forma
try (ExecutorService vexec = ...)fez a coisa certa no fechamento — ela executoushutdown()e aguardou que todas as tarefas submetidas terminassem. Com 100.000 tarefas em andamento, essa espera foi real (200 ms cada, todas estacionadas juntas, todas completando quase ao mesmo tempo). Sem o try-with-resources, o executor teria ficado vivo mantendo threads não-daemon e o programa teria travado.
Fim da parte 15
Este é o último capítulo da parte de Multithreading e Concorrência. Fomos de "uma thread é uma coisa do SO" pelos locks, atomics e coleções concorrentes que você usa para tornar o estado compartilhado correto, até o framework de executores que esconde o gerenciamento de threads, depois CompletableFuture e ForkJoinPool para composição, e finalmente threads virtuais para a carga de trabalho pesada em I/O que servidores modernos realmente enfrentam.
O padrão em tudo isso: escolha a menor ferramenta que resolve seu problema específico. Um contador? AtomicInteger. Uma flag? volatile. Um produtor/consumidor? BlockingQueue. Muitas chamadas de I/O paralelas? Threads virtuais. A palavra-chave synchronized ainda é certa quando é certa; Lock é para quando não é; os executores e futures de alto nível são para quando você superou ambos. Desça na pilha apenas quando a abstração acima não está fazendo o que você precisa.
A próxima parte do livro é Annotations — o que os marcadores @ anexados a classes, métodos e campos realmente fazem, os embutidos em java.lang, e as regras para escrever os seus próprios.