1. Em poucas palavras
Você já está um pouco familiarizado com as classes do pacote java.util.concurrent, especialmente com o ExecutorService. Ele é um “gerenciador de tarefas”: você envia um trabalho para ele (por exemplo, via submit()), e ele mesmo decide quando e em qual thread executá-lo. Normalmente, por baixo dos panos, funciona um pool de threads de tamanho fixo, que economiza recursos e não cria uma nova thread para cada tarefa.
No entanto, com threads virtuais tudo muda! Agora dá para se dar ao luxo: uma thread por tarefa — e sem medo de a JVM estourar.
Nova forma: Executors.newVirtualThreadPerTaskExecutor()
No Java 21 surgiu uma nova forma de criar um ExecutorService que executa cada tarefa em uma thread virtual separada:
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Diferença importante:
- Os pools de threads antigos (Executors.newFixedThreadPool, Executors.newCachedThreadPool) limitavam a quantidade de tarefas simultâneas por causa do alto custo das threads do SO.
- O novo Executor virtual é quase sem limites: uma thread virtual própria para cada tarefa.
Exemplo simples
Vamos enviar 10 tarefas para o Executor virtual:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualExecutorDemo {
public static void main(String[] args) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 1; i <= 10; i++) {
int taskId = i; // capturamos a variável para a lambda
executor.submit(() -> {
System.out.println("Task " + taskId + " is running in thread: " +
Thread.currentThread());
});
}
executor.shutdown();
}
}
O que acontece?
Cada tarefa será executada na sua própria thread virtual, e você verá linhas como:
Task 1 is running in thread: VirtualThread[#24]/runnable@ForkJoinPool-1-worker-1
...
2. Paralelismo em massa: milhares de tarefas — sem problema!
Para sentir toda a força das threads virtuais, vamos tentar enviar para o ExecutorService não 10, mas, digamos, 100_000 tarefas. Em pools clássicos, isso seria como tentar enfiar um elefante na geladeira: a JVM logo ficaria sem memória ou começaria a travar feio. Com threads virtuais — é diferente!
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualExecutorMassiveDemo {
public static void main(String[] args) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 1; i <= 100_000; i++) {
int taskId = i;
executor.submit(() -> {
// Para o exemplo — apenas dormimos 1 ms
try {
Thread.sleep(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// Não imprimimos, caso contrário haverá linhas demais!
});
}
executor.shutdown();
}
}
Atenção: imprimir 100_000 linhas na tela é uma má ideia: o console vai “engasgar” mais rápido do que as threads virtuais. É melhor não escrever no console ou imprimir apenas as primeiras poucas tarefas.
3. Como funciona newVirtualThreadPerTaskExecutor
Resumindo: este ExecutorService cria uma nova thread virtual para cada tarefa que você envia a ele. Ao contrário de um pool fixo, aqui não há fila de tarefas nem limites rígidos para a quantidade de threads simultâneas (exceto os limites da sua JVM e do hardware).
Arquitetura:
- As threads virtuais são mapeadas para um pequeno pool de threads reais (carrier) do SO.
- A JVM decide quando e qual thread virtual iniciar, suspender e retomar.
- Se a thread bloquear (por exemplo, na leitura de arquivo ou esperando rede), a JVM pode “congelar” a thread virtual e liberar a thread carrier para outras tarefas.
4. Exemplo: processamento de resultados com Future
O ExecutorService retorna um objeto do tipo Future quando a tarefa retorna um resultado. Tudo funciona da mesma forma que com threads “normais”:
import java.util.concurrent.*;
public class VirtualExecutorWithResult {
public static void main(String[] args) throws InterruptedException, ExecutionException {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Future<String> future = executor.submit(() -> {
Thread.sleep(500);
return "Hello from virtual thread!";
});
System.out.println("Result: " + future.get()); // Aguardamos o resultado
executor.shutdown();
}
}
Tudo como de costume: você pode enviar tarefas que retornam valor, esperar o resultado via get(), e as exceções são tratadas de forma padrão.
5. Como encerrar corretamente o Executor
É muito importante não esquecer de encerrar o ExecutorService para que o programa não congele (mesmo que as threads sejam virtuais, e não “reais”).
shutdown() e awaitTermination
executor.shutdown(); // Dizemos: não aceitamos mais tarefas
executor.awaitTermination(1, TimeUnit.MINUTES); // Aguardamos a conclusão de todas as tarefas (no máximo 1 minuto)
Por que isso é importante?
Se não chamar shutdown(), as threads virtuais podem continuar vivas, e o programa não vai terminar mesmo após a execução de main(). Esse é um erro típico de iniciantes.
6. Nuances úteis
Comparação: Executor virtual vs. pool de threads clássico
| Pool clássico (newFixedThreadPool) | Executor virtual (newVirtualThreadPerTaskExecutor) | |
|---|---|---|
| Quantidade de threads | Limitada pelo tamanho do pool | Uma thread virtual por tarefa, quase sem limite |
| Tarefas na fila | Sim, se todas as threads estiverem ocupadas | Em geral, não: a tarefa recebe uma thread imediatamente |
| Custo da thread | Alto (stack, recursos do SO) | Muito baixo (agendamento pela JVM) |
| Escalabilidade | Limitada | Quase ilimitada |
| Para que é indicado | Tarefas CPU-bound, paralelismo limitado | Tarefas I/O-bound, paralelismo massivo |
Integração com servidores web
Servidores web modernos (por exemplo, Tomcat, Jetty, Undertow) já começam a oferecer suporte a threads virtuais. Isso significa que é possível processar cada requisição HTTP em uma thread virtual separada, sem medo de “engasgar” com a enxurrada de usuários.
Vantagem: não é preciso inventar esquemas assíncronos complexos com callbacks e CompletableFuture; o código fica mais simples — você pode escrever o código bloqueante de sempre, mas o aplicativo ainda assim escala.
Testes em massa e simulação de carga
As threads virtuais são ótimas para testes em que é preciso “simular” milhares de usuários, requisições ou operações simultâneas. Por exemplo, um teste que envia 10_000 requisições paralelas para um servidor, cada uma em sua própria thread virtual.
Processamento paralelo de arquivos e conexões de rede
Se o aplicativo trabalha com muitos arquivos ou conexões de rede, você pode processar cada conexão em uma thread virtual separada, sem se preocupar com o gerenciamento manual de pools.
7. Erros comuns ao trabalhar com Executors virtuais
Erro nº 1: esqueceu de chamar shutdown(). Se não encerrar o Executor, o programa não vai terminar — as threads virtuais ainda estarão aguardando novas tarefas. Se necessário, adicione awaitTermination(...).
Erro nº 2: usar threads virtuais para cálculos pesados. As threads virtuais não aceleram tarefas que consomem 100% de CPU. Para CPU-bound, é melhor usar um pool fixo (Executors.newFixedThreadPool) e ajustar cuidadosamente o tamanho.
Erro nº 3: ignorar exceções dentro das tarefas. Se a tarefa lançar uma exceção, ela não chegará à thread principal — trate via Future (método get()) ou com try/catch dentro da lambda.
Erro nº 4: confundir a sintaxe/versão antiga com a nova do JDK. Verifique se você está usando a versão correta do JDK (Java 21+) e se a IDE está configurada para dar suporte a threads virtuais. O método específico é Executors.newVirtualThreadPerTaskExecutor().
Erro nº 5: depender de ThreadLocal para transportar contexto. As threads virtuais frequentemente são criadas e destruídas; o ThreadLocal pode não se comportar como esperado. Para transporte de contexto, use ScopedValue (Scoped Values; mais detalhes — na próxima aula).
GO TO FULL VERSION