W3docs

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 synchronized em torno de I/O. Uma thread virtual dentro de synchronized (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 usa synchronized para proteger uma chamada de banco de dados não escala com threads virtuais. A solução é usar ReentrantLock em vez disso (que a maquinaria de threads virtuais trata corretamente).
  • Armazenamento ThreadLocal com muitas threads. Threads virtuais suportam ThreadLocal, 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:

  1. Blocos synchronized que incluem uma chamada bloqueante.
  2. 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.

java— editable, runs on the server

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 synchronized em 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. Substituir synchronized por ReentrantLock mantém a thread virtual estacionável.
  • A forma try (ExecutorService vexec = ...) fez a coisa certa no fechamento — ela executou shutdown() 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.

Prática

Prática
Em um servidor usando threads virtuais, você envolve uma chamada JDBC em `synchronized (this) { jdbc.execute(sql); }`. Qual é a consequência?
Em um servidor usando threads virtuais, você envolve uma chamada JDBC em `synchronized (this) { jdbc.execute(sql); }`. Qual é a consequência?
Was this page helpful?