Virtual Threads no Java: mais concorrência sem transformar tudo em callbacks
#Backend

Virtual Threads no Java: mais concorrência sem transformar tudo em callbacks

Backend Reporter
9 min read

O Java 21 tornou virtual threads estáveis e trouxe de volta o modelo thread-per-request para serviços com muito I/O, mas escala maior também expõe limites de banco, API e consistência.

Problema

Java sempre teve uma história honesta com threads: o modelo era simples de entender, mas caro de operar em escala. Uma requisição chegava, uma thread cuidava dela, o código seguia uma linha legível de execução, exceções preservavam stack trace útil e depuração continuava próxima do raciocínio humano. O problema aparecia quando o serviço deixava de atender centenas de requisições concorrentes e passava a precisar sustentar dezenas ou centenas de milhares de operações bloqueadas em rede, banco, fila ou disco.

Featured image

A causa é estrutural. A thread tradicional do Java, hoje chamada de platform thread, é ligada a uma thread do sistema operacional. Ela consome memória de stack, participa do escalonamento do kernel e carrega custos que fazem sentido quando existem algumas centenas ou poucos milhares delas. Em sistemas de API, porém, a maior parte do tempo de uma requisição não é CPU. É espera: conexão HTTP, query SQL, leitura em cache remoto, chamada para serviço de pagamento, timeout em dependência lenta.

Esse padrão muda a conta de capacidade. Pela Lei de Little, throughput, latência e concorrência caminham juntos. Se uma chamada leva 100 ms em média e você quer 10.000 chamadas por segundo, precisa sustentar algo na ordem de 1.000 operações simultâneas. Se a latência sobe para 500 ms durante uma degradação de banco, a concorrência necessária cresce junto. O sistema não falha só porque o processador ficou cheio. Ele falha porque threads, conexões, buffers, filas e timeouts começam a acumular estado enquanto esperam.

A resposta clássica foi usar pools, CompletableFuture, reactive streams ou APIs assíncronas. Isso funciona, mas cobra um preço de desenho. O fluxo de negócio vira composição de callbacks, a pilha deixa de representar a unidade lógica da requisição e o ponto onde uma falha aparece no log nem sempre é o ponto que explica a causa. Quem já tentou depurar uma cascata de thenCompose, retries e timeouts em produção sabe que o problema raramente é a sintaxe. O problema é perder a relação simples entre uma requisição, uma sequência de operações e um erro observável.

Abordagem de solução

Virtual threads, finalizadas no Java 21 pela JEP 444, atacam exatamente esse ponto. Elas mantêm a API mental de java.lang.Thread, mas removem a relação um-para-um com threads do sistema operacional. Em vez de cada tarefa prender uma OS thread enquanto espera I/O, a JVM monta uma virtual thread sobre uma carrier thread apenas enquanto há trabalho a executar. Quando a operação bloqueante suportada pela plataforma entra em espera, a virtual thread pode ser desmontada, e a carrier thread fica livre para executar outra virtual thread.

O efeito prático é importante para servidores: dá para voltar ao estilo uma tarefa por requisição sem reservar uma thread cara para cada espera. O código pode continuar bloqueante, com try/catch, loops, chamadas JDBC e HTTP síncronas, mas o runtime passa a multiplexar muitas unidades lógicas sobre menos threads reais. A documentação oficial de virtual threads no Java 21 cobre esse modelo em detalhe.

O padrão de API mais comum é criar uma virtual thread por tarefa, não criar um pool de virtual threads. A própria API deixa isso explícito com Executors.newVirtualThreadPerTaskExecutor(). A regra operacional é simples: pool existe para recurso caro. Virtual thread é barata o suficiente para ser descartável. O que precisa ser limitado não é a virtual thread em si, mas o recurso externo que ela toca.

Um serviço que agrega três APIs externas, por exemplo, pode expressar concorrência de forma direta. Uma requisição entra, o handler cria subtarefas, cada subtarefa chama um serviço, e o código espera os resultados. Com platform threads, esse desenho podia esgotar o pool quando as dependências ficavam lentas. Com virtual threads, a espera custa bem menos em termos de threads reais. A legibilidade volta sem abrir mão de throughput.

O ponto que separa um desenho bom de um incidente caro está nos limites. Virtual threads aumentam a quantidade de trabalho concorrente que o processo Java consegue manter vivo. Elas não aumentam automaticamente o número de conexões do banco, a capacidade do PostgreSQL de executar queries, o limite de rate da API parceira ou a vazão de uma partição Kafka. Se antes um pool de 200 threads escondia um problema ao limitar a concorrência por acidente, trocar para virtual threads pode remover esse freio e jogar 20.000 operações simultâneas contra uma dependência que aguentava 300.

Por isso, o padrão correto é combinar virtual threads com controle explícito de concorrência. Para banco, o pool de conexões continua sendo uma fronteira real. Para APIs externas, Semaphore, rate limiter e circuit breaker continuam necessários. Para filas, a quantidade de consumidores precisa respeitar partições, ordenação e idempotência. A virtual thread resolve o custo de espera dentro da JVM. Ela não resolve contrato de capacidade fora dela.

Esse detalhe também conecta virtual threads a modelos de consistência. Mais concorrência significa mais interleavings. Um serviço que antes processava poucos comandos simultâneos pode passar a observar mais conflitos de escrita, leituras obsoletas e retries. Se a API promete read-your-writes, o roteamento de leitura para réplica eventual pode ficar mais visivelmente incorreto quando milhares de virtual threads disparam leituras logo após gravações. Se o sistema usa consistência eventual, a documentação da API precisa dizer quais operações são assíncronas, quais retornam estado confirmado e quais retornam uma intenção aceita para processamento posterior.

Em APIs HTTP, isso aparece em decisões bem concretas. Um POST /payments que retorna 201 deve significar que o pagamento foi criado de forma durável, ou apenas que entrou em uma fila. Um PUT /orders/{id} precisa ser idempotente quando o cliente repetir a chamada após timeout. Um endpoint de consulta deve deixar claro se lê do primário, de uma réplica ou de uma projeção atrasada. Virtual threads permitem sustentar mais chamadas concorrentes a esses endpoints, mas a semântica continua sendo a parte que evita duplicidade, perda de atualização e surpresa operacional.

Trade-offs

O primeiro trade-off é throughput contra latência. Virtual threads ajudam muito quando há muitas tarefas bloqueadas em I/O. Elas não fazem código CPU-bound ficar mais rápido. Se o gargalo é compressão, criptografia, ordenação grande, renderização ou cálculo pesado, criar mais threads do que núcleos tende a piorar cache, escalonamento e previsibilidade. Para CPU, ainda faz sentido usar paralelismo limitado ao hardware, ForkJoinPool, streams paralelos com critério ou workers dimensionados por núcleo.

O segundo trade-off é simplicidade de código contra explosão de concorrência. Código bloqueante é mais fácil de ler, testar e debugar, mas também é fácil criar concorrência demais porque cada tarefa parece barata. Em produção, a pergunta correta deixa de ser quantas threads cabem na JVM e passa a ser quantas operações simultâneas o sistema inteiro suporta sem violar SLO, orçamento de erro e contrato de consistência.

O terceiro trade-off é compatibilidade contra memória por contexto. Virtual threads suportam ThreadLocal, interrupção e grande parte do código Java existente. Isso facilita adoção incremental. O custo aparece quando bibliotecas guardam objetos grandes em ThreadLocal, como buffers, formatadores pesados, contexto de segurança ou conexões. Em um pool pequeno de platform threads, esse custo ficava limitado ao tamanho do pool. Com uma virtual thread por tarefa, o mesmo padrão pode multiplicar memória e retenção de objetos.

O quarto trade-off envolve bloqueio e pinning. No Java 21, havia cenários em que virtual threads podiam ficar presas à carrier thread, especialmente ao bloquear dentro de certos trechos synchronized ou código nativo. A JEP 491, entregue no Java 24, reduziu quase todos os casos de pinning associados a synchronized. Mesmo assim, o desenho prudente continua o mesmo: evitar I/O enquanto segura locks, manter seções críticas pequenas e usar ferramentas como JDK Flight Recorder quando a aplicação real não escala como o modelo prometia.

O quinto trade-off é observabilidade. Ter milhões de unidades concorrentes muda a forma de investigar produção. Um dump tradicional de threads não foi pensado para esse volume. A JEP 444 introduziu formatos melhores para observar virtual threads, inclusive dumps via jcmd em JSON. Ainda assim, times precisam ajustar dashboards, cardinalidade de métricas, correlação de traces e logs. Um sistema com mais concorrência precisa de mais clareza causal, não só mais capacidade de aceitar conexões.

A migração mais segura costuma começar nas bordas de I/O. Handlers HTTP, jobs que fazem chamadas remotas e agregadores de serviços são bons candidatos. Trocar um executor fixo por newVirtualThreadPerTaskExecutor() pode ser uma mudança pequena no código, mas deve vir acompanhada de testes de carga que medem fila, timeout, pool de conexões, saturação do banco e taxa de erro das dependências. O resultado esperado não é latência menor em uma chamada isolada. O resultado esperado é manter throughput alto quando há muitas chamadas esperando I/O.

Um exemplo simples de limite explícito ajuda a fixar o desenho. Imagine um serviço que pode criar uma virtual thread por requisição, mas só deve fazer 50 chamadas simultâneas para uma API de cadastro. O executor não deve virar o mecanismo de limitação. A aplicação deve usar um semáforo, um rate limiter ou uma fila com política clara. Assim, a virtual thread melhora o custo de espera, enquanto o limite protege a dependência e torna o comportamento previsível sob carga.

Essa separação é a lição principal para sistemas distribuídos. Virtual threads melhoram o modelo local de concorrência na JVM. Elas não substituem backpressure, idempotência, timeouts, retries com jitter, bulkheads, versionamento de API ou escolhas explícitas de consistência. Um serviço mal especificado apenas falha em maior escala quando recebe uma ferramenta que permite mais simultaneidade.

A adoção pragmática é tratar virtual threads como uma forma de recuperar código sequencial em sistemas com muito I/O, sem fingir que a rede ficou confiável ou que o banco ficou infinito. Para APIs, o ganho está em expressar fluxos de negócio com menos cerimônia assíncrona. Para bancos, o cuidado está em limitar conexões e transações. Para consistência, o trabalho continua sendo definir o que cada resposta significa quando milhares de operações estão em voo ao mesmo tempo.

Virtual threads não são mágica e não aposentam engenharia de capacidade. Elas corrigem uma limitação antiga do modelo thread-per-request: a escassez artificial de threads do sistema operacional. Usadas com limites explícitos e contratos de API bem desenhados, elas deixam Java mais confortável para construir serviços concorrentes sem transformar cada operação de I/O em uma cadeia de callbacks.

Comments

Loading comments...