Java Virtual Threads: guia prático
1. Visão Geral
Virtual threads é uma funcionalidade oficial do Java 21 (JEP 444), que ajuda você a escrever código síncrono (blocking style) mas ainda alcançar throughput alto como I/O não-bloqueante. Este artigo vai desde o conceito, como criar virtual threads, comparar performance com platform threads, até como integrar no Spring Boot. Após ler, você saberá quando deve usar virtual threads e quando não.
2. Problema com Platform Threads Tradicionais
Cada platform thread em Java é mapeado 1-1 para um thread do SO. O thread do SO consome memória (geralmente 1MB de stack) e criar/trocar contexto (context switch) tem um custo alto. Portanto, aplicações que lidam com dezenas de milhares de requisições simultâneas (por exemplo, servidores esperando I/O) geralmente são limitadas pela quantidade de thread pools que podem ser criados.
ExecutorService executor = Executors.newFixedThreadPool(200);
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> {
try {
Thread.sleep(1000); // simular chamada de I/O
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
executor.shutdown();
Com pool de 200 threads, 10.000 tarefas devem ficar na fila esperando, o tempo total de conclusão se estende porque cada thread consegue processar apenas uma tarefa por vez, apesar de a maioria do tempo ser esperando (bloqueado), não computação.
3. O que é Virtual Thread
Virtual thread é um thread gerenciado pela JVM (não pelo SO), que é “montado” em um platform thread (chamado de carrier thread) apenas quando está executando código. Quando um virtual thread chama uma operação bloqueante (I/O, Thread.sleep, lock…), a JVM automaticamente o “desmonta” do carrier thread, liberando o carrier thread para processar outro virtual thread.
Resultado: você pode criar milhões de virtual threads sem se preocupar em exaurir recursos, enquanto o código ainda é escrito no estilo blocking familiar — sem precisar de CompletableFuture, sem precisar de reactive.
3.1. Criar um Virtual Thread Simples
Thread vThread = Thread.ofVirtual()
.name("order-processor")
.start(() -> {
System.out.println("Chạy trên: " + Thread.currentThread());
});
vThread.join();
Thread.ofVirtual() retorna um builder para configurar a thread virtual. .start() cria e executa imediatamente. Imprimir a thread mostrará algo assim:
Chạy trên: VirtualThread[#21,order-processor]/runnable@ForkJoinPool-1-worker-1
Note a parte @ForkJoinPool-... — esse é exatamente o carrier thread que está “carregando” este virtual thread.
4. Executor para Virtual Thread
A forma mais comum de usar virtual threads em aplicações reais é através de ExecutorService.
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
int id = i;
futures.add(executor.submit(() -> {
Thread.sleep(1000); // simular chamada de I/O (DB, HTTP…)
return "Task " + id + " xong";
}));
}
for (Future<String> f : futures) {
f.get();
}
}
newVirtualThreadPerTaskExecutor() cria um novo virtual thread para cada tarefa, não reutiliza threads como um thread pool tradicional. Como o custo de criar um virtual thread é muito baixo (algumas centenas de bytes, não 1MB), isso é completamente razoável. O bloco try-with-resources garante que o executor seja fechado, esperando que as tarefas sejam concluídas ao sair do bloco.
Em comparação com o exemplo da seção 2 (pool de 200 platform threads), este código completa todas as 10.000 tarefas quase simultaneamente em 1 segundo (porque não há mais limite de pool), em vez de ter que ficar na fila várias vezes.
5. Comparar Performance com Teste Real
class VirtualThreadBenchmarkTest {
@Test
void virtualThreadsShouldHandleManyBlockingTasksFast() throws InterruptedException {
int taskCount = 10_000;
CountDownLatch latch = new CountDownLatch(taskCount);
Instant start = Instant.now();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < taskCount; i++) {
executor.submit(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
latch.countDown();
}
});
}
}
latch.await();
Duration elapsed = Duration.between(start, Instant.now());
assertThat(elapsed).isLessThan(Duration.ofSeconds(5));
}
}
Este teste cria 10.000 tarefas, cada tarefa dorme por 100ms. Se executado sequencialmente levaria 1000 segundos, mas com virtual threads, tudo completa em alguns segundos porque as tarefas rodam realmente em paralelo. CountDownLatch é usado para esperar que todas as tarefas sejam concluídas antes de fazer a asserção.
Nota: Benchmarks reais devem medir também CPU, memória usando JFR (Java Flight Recorder) em vez de apenas medir tempo, pois o tempo também depende da máquina executando o teste.
6. Armadilha: Pinning ao Usar synchronized
Um virtual thread não será desmontado do carrier thread se estiver dentro de um bloco synchronized — esse fenômeno é chamado de pinning. Nesse caso, a I/O bloqueante dentro de synchronized ocupará o carrier thread, perdendo os benefícios do virtual thread.
public class InventoryService {
private final Object lock = new Object();
public void reserveStock(String sku) {
synchronized (lock) {
callExternalInventoryApi(sku); // I/O bloqueante dentro de synchronized -> pinning!
}
}
}
A linha synchronized (lock) mantém o carrier thread durante todo o tempo que chama callExternalInventoryApi, apesar de ser uma operação de I/O. Se houver milhares de virtual threads todos fixados, o pool de carrier threads (padrão é o número de núcleos da CPU) se esgotará rapidamente.
Solução: substitua synchronized por java.util.concurrent.locks.ReentrantLock, que é compatível com virtual threads (não causa pinning).
public class InventoryService {
private final ReentrantLock lock = new ReentrantLock();
public void reserveStock(String sku) {
lock.lock();
try {
callExternalInventoryApi(sku);
} finally {
lock.unlock();
}
}
}
ReentrantLock permite que um virtual thread se desmonte normalmente quando encontra I/O bloqueante, mantendo o espírito “leve” do virtual thread.
7. Integrar Virtual Thread no Spring Boot
A partir do Spring Boot 3.2, você só precisa ativar uma propriedade para que o Spring Web (Tomcat) use virtual threads para cada requisição.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
spring.threads.virtual.enabled=true
Esta propriedade ativa virtualThreadExecutor para Tomcat (e outros executors que o Spring Boot suporta). Cada requisição HTTP chegando será processada em um virtual thread separado, em vez de compartilhar um pool de platform threads fixo (padrão 200 threads do Tomcat).
@RestController
@RequestMapping("/orders")
class OrderController {
private final OrderService orderService;
OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping("/{id}")
public OrderResponse getOrder(@PathVariable Long id) {
return orderService.fetchOrderWithDetails(id); // gọi DB + API khác, blocking bình thường
}
}
O código do controller não precisa mudar em nada — você ainda escreve no estilo blocking como antes. Quando você faz teste de carga com ferramentas como ab ou k6, verá o throughput aumentar significativamente para endpoints com alta latência de I/O (chamadas ao banco de dados, chamadas a outros serviços), porque Tomcat não está mais limitado aos 200 threads padrão.
8. Quando NÃO Usar Virtual Thread
- CPU-bound task: Se o trabalho é principalmente computação (sem I/O esperando), virtual threads não traz benefício — a quantidade de carrier threads ainda é limitada pelo número de núcleos de CPU.
ForkJoinPoolouparallelStream()são mais adequados. - Código que usa
ThreadLocalpesadamente: Virtual threads são compatíveis comThreadLocal, mas se você criar milhões de virtual threads, cada um mantendoThreadLocalgrande, isso consumirá memória significativa — considere usarScopedValue(preview no Java 21) como substituição. - Bibliotecas antigas que usam
synchronizedem muitos lugares: Como mencionado na seção 6, pinning pode silenciosamente reduzir a performance de uma forma que você não percebe imediatamente. - Connection pool limitado: Virtual threads ajudam a criar muitas requisições em paralelo, mas se o DB connection pool tem apenas 20 conexões, você ainda terá gargalo lá — precisa aumentar o tamanho do pool adequadamente ou usar um connection pool que suporte bem alta concorrência.
Nota: Virtual threads resolvem o problema da quantidade de threads, não o problema da velocidade de processar cada tarefa. Não espere que isso faça código CPU-bound rodar mais rápido.
9. Conclusão
Virtual threads ajudam você a escrever código blocking simples mas ainda escalável para milhares de requisições simultâneas, particularmente adequado para aplicações I/O-bound como web services, microsserviços que chamam muitas APIs/bancos de dados. Você só precisa trocar o executor ou ativar uma propriedade no Spring Boot, sem precisar reescrever a lógica no estilo reactive. Porém, atente-se para a armadilha sobre pinning com synchronized e lembre-se que virtual threads não ajudam em tarefas CPU-bound.