The Code Codex
Tema
← Guias

Java Threads

Platform threads, virtual threads e o modelo de concorrência da JVM: como funcionam, quando usar e as armadilhas.

24 abr 2026· 9 min· 10 seções

Neste guia · 10 seções

Threads ou virtual threads?

Uma thread é uma linha de execução: um trecho de código rodando com sua própria pilha de chamadas. Ter várias threads é a forma clássica de o Java lidar com várias coisas ao mesmo tempo, o que é concorrência, não necessariamente paralelismo.

Desde o Java 21 existem dois tipos: a platform thread, a thread de sempre, e a virtual thread. Os dois usam a mesma classe Thread e a mesma API; a diferença está em quem as gerencia e quanto custam.

O modelo tradicional: platform threads

  • Mapeamento 1:1: cada Thread do Java corresponde a uma thread do sistema operacional, e é o SO quem decide quando ela roda.
  • Custo elevado: cada uma reserva uma pilha fixa (1 MB por padrão no Linux x64, ajustável com -Xss), e criar ou trocar de thread (context switch) passa pelo kernel.
  • Limite de escala: na prática, alguns milhares por processo. Por isso sempre usamos pools com um número fixo de threads.
ExecutorService pool = Executors.newFixedThreadPool(200);
pool.submit(() -> handle(request)); // no máximo 200 tarefas ao mesmo tempo
O problema do I/O bloqueante

No Java clássico, quando uma thread faz uma chamada de rede ou de banco, ela fica parada esperando a resposta, mas continua ocupando a thread do SO e a memória dela.

A Lei de Little mostra por que isso limita o servidor: requisições simultâneas = taxa de chegada × tempo de resposta. Se chegam 10.000 requisições por segundo e cada uma leva 0,5 s esperando o banco, são 5.000 requisições em andamento, ou seja, 5.000 threads ocupadas, quase todas só esperando. Com um pool de 200, o resto entra na fila; com 5.000 platform threads, a memória e o SO viram o gargalo.

A saída antiga era o código assíncrono (CompletableFuture, frameworks reativos): escala bem, mas é difícil de ler, de depurar e de seguir no stack trace.

Virtual threads (Java 21+)

Virtual threads são threads gerenciadas pela JVM, não pelo SO.

  • Mapeamento M:N: milhares (ou milhões) de virtual threads rodam sobre poucas platform threads, chamadas carrier threads. Por padrão, uma por núcleo de CPU, num ForkJoinPool próprio da JVM.
  • Leveza: a pilha de uma virtual thread é um objeto no heap que cresce e encolhe conforme o uso. Criar 1 milhão delas é viável.
  • Desbloqueio: quando uma virtual thread bloqueia em I/O, a JVM a desmonta da carrier, que fica livre para outra virtual thread. Quando a resposta chega, ela é montada de novo, em qualquer carrier livre, e continua de onde parou.

Platform threads (1:1)

Thread 1

Thread do SO

Thread 2

Thread do SO

Thread 3

Thread do SO

Virtual threads (M:N)

VT 1

Scheduler da JVM

ForkJoinPool

VT 2

VT 3

VT ...

Carrier 1 → thread do SO

Carrier 2 → thread do SO

Platform threads (1:1)

Thread 1

Thread do SO

Thread 2

Thread do SO

Thread 3

Thread do SO

Virtual threads (M:N)

VT 1

Scheduler da JVM

ForkJoinPool

VT 2

VT 3

VT ...

Carrier 1 → thread do SO

Carrier 2 → thread do SO

O que acontece quando uma virtual thread espera o banco:

BancoVirtual thread BCarrier threadVirtual thread ABancoVirtual thread BCarrier threadVirtual thread AA é desmontada: pilha vai para o heapA volta à fila e é montadana próxima carrier livremontada, executaSELECT ... (bloqueia)montada, executa enquanto A esperaresposta chega
BancoVirtual thread BCarrier threadVirtual thread ABancoVirtual thread BCarrier threadVirtual thread AA é desmontada: pilha vai para o heapA volta à fila e é montadana próxima carrier livremontada, executaSELECT ... (bloqueia)montada, executa enquanto A esperaresposta chega
Criando virtual threads
// uma thread avulsa
Thread.startVirtualThread(() -> handle(request));
Thread.ofVirtual().name("worker").start(() -> handle(request));
// uma virtual thread por tarefa (adaptado da JEP 444)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i -> executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
}));
} // o close() espera todas terminarem

As 10.000 tarefas terminam em pouco mais de 1 segundo. Com um pool fixo de 200 platform threads, levariam cerca de 50.

O que mudou na prática

Simplicidade: você volta a escrever código síncrono, fácil de ler e de depurar, com a escalabilidade que antes exigia código assíncrono.

Abandono do pool: antes limitávamos o número de threads para não derrubar o servidor. Virtual threads são baratas, então a recomendação é uma thread por tarefa, descartada no final. Não faça pool de virtual threads.

Limite o recurso, não a thread: se o banco aguenta 50 conexões, quem limita é um Semaphore (ou o pool de conexões), não o número de threads.

private static final Semaphore DB_PERMITS = new Semaphore(50);
String query(String sql) throws InterruptedException {
DB_PERMITS.acquire();
try {
return database.execute(sql);
} finally {
DB_PERMITS.release();
}
}

Nos frameworks

Quase ninguém cria threads na mão em uma aplicação web: quem decide em que thread cada requisição roda é o framework. Por isso, adotar virtual threads costuma ser uma configuração, e não uma reescrita.

modelo clássico

com virtual threads

Requisição HTTP

Servidor

Tomcat, Jetty, Vert.x

Pool fixo de workers

(ex.: 200 no Tomcat)

Uma virtual thread

nova por requisição

Seu controller,

o mesmo código

modelo clássico

com virtual threads

Requisição HTTP

Servidor

Tomcat, Jetty, Vert.x

Pool fixo de workers

(ex.: 200 no Tomcat)

Uma virtual thread

nova por requisição

Seu controller,

o mesmo código

Spring BootQuarkusHelidon 4
Como ativarUma propriedade, vale para a aplicação todaAnotação por método (@RunOnVirtualThread)Já é o padrão
Modelo padrão sem issoPool de threads do servidorEvent loop (reativo) ou pool de workersVirtual threads
VersãoBoot 3.2+ e Java 21+ (Java 24+ recomendado)Quarkus 3 e Java 21+Helidon 4 e Java 21+
Spring Boot

Uma propriedade troca para virtual threads o processamento de requisições do Tomcat e do Jetty, o executor de @Async, o agendador de @Scheduled e os listeners de mensageria (Kafka, RabbitMQ):

spring.threads.virtual.enabled=true
# virtual threads são daemon: sem isto, uma app só com @Scheduled pode encerrar sozinha
spring.main.keep-alive=true

O controller continua igual. Dois cuidados: com virtual threads, as propriedades de tamanho de pool (spring.task.execution.pool.*, server.tomcat.threads.max) deixam de ter efeito, então os limites passam a ser do banco e das conexões; e a própria documentação recomenda Java 24+, por causa do pinning (veja abaixo).

Quarkus

O Quarkus nasceu reativo: por padrão, o código roda no event loop do Vert.x ou num pool de workers. Virtual threads são opt-in por endpoint, e os três modelos convivem na mesma aplicação:

@Path("/orders")
public class OrderResource {
@GET
@RunOnVirtualThread // este método roda numa virtual thread nova a cada chamada
public List<Order> list() {
return repository.listAll(); // bloqueante, e tudo bem
}
}

A anotação também funciona em consumidores de mensageria, em tarefas do @Scheduled e em serviços gRPC. A documentação do Quarkus alerta para bibliotecas que guardam objetos em ThreadLocal como cache (cita Jackson e Netty): com uma thread nova por chamada, o cache é recriado toda vez e o consumo de memória sobe.

Helidon 4

O servidor web do Helidon 4 foi reescrito sobre virtual threads: cada requisição já roda na sua, sem configurar nada. É o exemplo de framework pensado desde o início para esse modelo.

Armadilhas

  • Não são threads mais rápidas. Elas aumentam a vazão (mais tarefas ao mesmo tempo), não a velocidade de cada uma. Em trabalho de CPU (cálculo, compressão) não há ganho: use platform threads ou um pool do tamanho do número de núcleos.
  • Pinning: em alguns casos a virtual thread não consegue se desmontar e “prende” a carrier enquanto espera.
    • No Java 21 a 23, isso acontecia ao bloquear dentro de um bloco synchronized. A recomendação era trocar por ReentrantLock.
    • No Java 24+ (JEP 491), synchronized não prende mais. Continua prendendo quando o bloqueio acontece dentro de código nativo (JNI ou Foreign Function API) que chamou de volta o Java.
    • Para investigar: o evento jdk.VirtualThreadPinned do Java Flight Recorder.
  • ThreadLocal pesado: com milhões de threads, um cache grande em ThreadLocal vira milhões de cópias. Prefira ScopedValue (abaixo) ou passe o valor explicitamente.

Além das virtual threads

Scoped Values (final no Java 25)

Um substituto mais seguro para ThreadLocal: o valor é imutável e só existe dentro do escopo em que foi definido.

static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
ScopedValue.where(CURRENT_USER, user).run(() -> handle(request));
// em qualquer ponto da cadeia de chamadas dentro de handle():
User user = CURRENT_USER.get();
Structured Concurrency (preview)

Trata um grupo de subtarefas como uma unidade: elas começam e terminam dentro de um bloco, e se uma falha, as outras são canceladas.

fork

fork

as duas OK

uma falhou

Requisição

StructuredTaskScope

findUser()

fetchOrder()

join()

Resposta

cancela a outra

e lança ExecutionException

fork

fork

as duas OK

uma falhou

Requisição

StructuredTaskScope

findUser()

fetchOrder()

join()

Resposta

cancela a outra

e lança ExecutionException

// JDK 27: java --enable-preview --source 27 ...
Response handle() throws InterruptedException, ExecutionException {
try (var scope = StructuredTaskScope.open()) {
Subtask<User> user = scope.fork(() -> findUser());
Subtask<Order> order = scope.fork(() -> fetchOrder());
scope.join(); // espera as duas; se uma falhar, cancela a outra
return new Response(user.get(), order.get());
}
}

A API ainda muda a cada versão: no Java 27 está na sétima preview (JEP 533), e tutoriais de versões anteriores podem não compilar.

Quando usar cada uma

SituaçãoUse
Servidor com muitas requisições esperando I/O (HTTP, banco, filas)Virtual threads, uma por tarefa
Trabalho pesado de CPUPlatform threads, pool do tamanho dos núcleos
Limitar acesso a um recurso escassoSemaphore ou o pool do próprio recurso
Várias chamadas em paralelo dentro de uma requisiçãoVirtual threads + Structured Concurrency (quando sair do preview)
Passar contexto (usuário, trace id) pela chamadaScopedValue

Vídeos

Referências

1/10 · Threads ou virtual threads?