Java & Spring Boot
16 min de leitura

Virtual Threads no Spring Boot em APIs com alta concorrência: erros que geram falhas, lentidão ou retrabalho

Virtual threads podem ampliar a capacidade de APIs Spring Boot, mas também expor gargalos no banco e nas integrações. Entenda nove erros de adoção e use um checklist para validar concorrência, timeouts, contexto e latência sob carga.

M

Max Alex

Virtual Threads no Spring Boot em APIs com alta concorrência: erros que geram falhas, lentidão ou retrabalho
Neste artigo (10 tópicos)

Como identificar gargalos, evitar falhas e validar a adoção sem transformar mais concorrência em mais lentidão.

Ativar Virtual Threads no Spring Boot pode ampliar a capacidade de uma API que passa muito tempo aguardando I/O. Mas a mudança também permite que mais requisições disputem conexões JDBC, serviços externos e memória ao mesmo tempo. Sem limites, o resultado pode ser aumento de timeouts e piora da latência.

A decisão precisa responder a três perguntas: qual recurso limita a aplicação hoje, quais dependências receberão mais trabalho e como comprovar que a nova configuração atende aos objetivos de serviço? Os erros abaixo ajudam a organizar esse diagnóstico.

Os números dos exemplos são hipotéticos e servem para explicar decisões. Não representam benchmarks nem recomendações universais de dimensionamento.

Erro 1: esperar que virtual threads acelerem qualquer API

Concorrência é manter várias tarefas em andamento. Paralelismo é executar trabalho simultaneamente. Virtual threads tornam mais viável manter muitas tarefas concorrentes, especialmente enquanto aguardam operações de I/O compatíveis com esse modelo.

Elas não aumentam o número de núcleos nem tornam um cálculo individual mais rápido. O benefício esperado costuma ser maior throughput, isto é, mais requisições concluídas por unidade de tempo, quando a quantidade de threads de plataforma era uma restrição.

Imagine um endpoint que consulta uma integração HTTP. Enquanto a resposta não chega, uma virtual thread pode liberar a thread de plataforma que a executa, chamada de carrier thread, para atender outra tarefa. Já um endpoint que calcula grandes combinações permanece consumindo CPU: criar mais tarefas pode apenas aumentar a disputa pelo processador.

Mesmo no primeiro cenário, a integração precisa suportar o volume adicional. Se ela já estiver saturada, ampliar a concorrência da API poderá aumentar filas sem elevar as conclusões bem-sucedidas.

Defina sucesso como capacidade atendida dentro do SLO, o objetivo de nível de serviço acordado, considerando latência, erros e custo por requisição concluída. Um aumento de throughput acompanhado de timeouts não comprova uma melhoria operacional.

Em Spring MVC com JDBC e clientes HTTP bloqueantes, virtual threads podem permitir evolução mantendo o estilo sequencial. Em WebFlux, preserve as regras do modelo reativo e evite operações bloqueantes no event loop. A propriedade de ativação não transforma automaticamente toda a execução reativa em virtual threads nem justifica uma reescrita de arquitetura.

Para aprofundar a configuração e a validação, consulte o guia de implementação de Virtual Threads no Spring Boot em produção.

Erro 2: ativar a propriedade sem verificar versões e executores

O suporte integrado chegou no Spring Boot 3.2 e requer Java 21 ou superior, respeitando a compatibilidade da versão do Boot escolhida. As notas da versão 3.2 documentam processamento de requisições com virtual threads em Tomcat e Jetty. Verifique o servidor e a documentação correspondentes à sua aplicação.

A configuração básica é:

spring.threads.virtual.enabled=true

Isso não substitui todos os executores existentes. Um bean personalizado, um AsyncConfigurer ou a seleção explícita em @Async pode direcionar tarefas para outro executor. Revise também o bean applicationTaskExecutor, usado em integrações específicas do Spring.

Confirme o comportamento dentro do controller e dentro da tarefa assíncrona efetivamente chamada:

log.info("etapa={} virtual={}",
    "controller", Thread.currentThread().isVirtual());

// Execute esta verificação dentro do método assíncrono.
log.info("etapa={} virtual={}",
    "async", Thread.currentThread().isVirtual());

Faça essa verificação em homologação ou com amostragem controlada. Para testar @Async, invoque o método por outro bean através do proxy do Spring; uma chamada interna ao próprio objeto pode não acionar a execução assíncrona.

Também não presuma que CompletableFuture.supplyAsync(...) sem executor explícito usa virtual threads: normalmente ele utiliza o ForkJoinPool.commonPool(). A seleção precisa ser verificada em cada fronteira de execução.

Configurações de tamanho de pool deixam de governar componentes que passaram a operar sem aquele pool. Isso não elimina os limites do HikariCP, dos clientes HTTP ou de executores próprios.

Virtual threads são daemon threads. Examine spring.main.keep-alive=true quando a aplicação precisar permanecer ativa mesmo sem threads não daemon, especialmente em processos sustentados por tarefas agendadas. Essa propriedade trata o ciclo de vida da JVM; não controla a concorrência.

Erro 3: tratar pinning da mesma forma em todas as versões do Java

Pinning ocorre quando uma virtual thread não consegue se desacoplar da carrier thread em uma situação de bloqueio. Se isso acontecer frequentemente e por períodos longos, a capacidade de atender outras tarefas pode cair.

No Java 21 a 23, operações bloqueantes dentro de synchronized podem provocar pinning. Por isso, uma chamada HTTP lenta realizada sob um monitor merece investigação nessas versões.

A partir do Java 24, a JEP 491 remove o pinning associado aos monitores synchronized. A mudança permite liberar a carrier thread durante bloqueios nesses contextos.

Isso não remove a exclusão mútua. Se cem tarefas precisam do mesmo lock para executar uma operação demorada, elas continuam disputando uma seção serializada. Também permanecem situações de pinning relacionadas a código nativo e funções estrangeiras.

Um experimento útil é executar o mesmo trecho com I/O dentro de synchronized em Java 21 e Java 24 ou superior, mantendo as demais condições. Observe separadamente a ocupação das carriers e o tempo de espera pelo lock. A remoção do pinning pode melhorar a disponibilidade das carriers sem aumentar a capacidade daquela seção crítica.

Não substitua todo synchronized por ReentrantLock preventivamente. No Java 21, uma substituição pontual pode ser adequada quando houver pinning comprovado. Em versões posteriores, investigue contenção e duração da seção crítica antes de refatorar.

Relacione eventos do JFR e opções de diagnóstico ao JDK utilizado. Uma receita de investigação para Java 21 não deve ser aplicada automaticamente a uma JVM mais recente.

Erro 4: aumentar a concorrência sem proteger o HikariCP e o banco

A relação entre HikariCP e virtual threads continua limitada pela capacidade do banco. Mais threads não criam mais conexões disponíveis. Quando o pool está ocupado, novas solicitações de conexão aguardam até uma conexão ser liberada ou até atingir connectionTimeout.

Em um cenário hipotético com 500 requisições simultâneas e um pool de 30 conexões, no máximo 30 conexões desse pool podem estar em uso ao mesmo tempo. A quantidade de requisições esperando depende de quantas precisam acessar o banco naquele instante.

Antes de ampliar maximumPoolSize, acompanhe:

  • Conexões ativas, ociosas e solicitações pendentes.
  • Tempo de aquisição e timeouts de conexão.
  • Duração das consultas e das transações.
  • Locks, consumo de CPU e I/O no banco.

Se consultas N+1 fazem cada requisição executar muitos acessos, corrigir a consulta pode reduzir a demanda. Se uma transação mantém a conexão enquanto aguarda uma integração HTTP, reorganizar o fluxo pode liberar o recurso mais cedo.

Essa reorganização precisa preservar as regras de consistência. Tirar uma chamada externa de uma transação pode exigir estados intermediários, compensação ou outro desenho do processo; não é apenas mover uma linha de código.

Compare três intervenções: limitar os acessos simultâneos ao banco, reduzir o tempo de ocupação das conexões e aumentar o pool após comprovar capacidade adicional. A terceira opção pode piorar a situação se o banco já estiver saturado.

Considere todas as réplicas. Quatro instâncias com limite de 30 conexões permitem até 120 conexões desses pools, além de outros consumidores. Escalar a API também altera a pressão agregada sobre o banco.

Erro 5: confundir threads baratas com concorrência ilimitada

A JEP 444 orienta criar virtual threads por tarefa, evitando pools de virtual threads como mecanismo de controle. Proteja os recursos finitos com limites próprios, como semáforos e bulkheads.

Rate limiting controla a taxa de entrada ao longo do tempo. Um bulkhead limita operações simultâneas em uma dependência. Uma integração lenta pode acumular muitas chamadas em andamento mesmo quando a taxa de chegada parece moderada.

Suponha que uma integração tenha capacidade validada de 40 chamadas simultâneas por instância. Um trecho ilustrativo de proteção seria:

private final Semaphore permits = new Semaphore(40);

public Resposta consultar() throws InterruptedException {
    if (!permits.tryAcquire(50, TimeUnit.MILLISECONDS)) {
        throw new DependenciaSaturadaException();
    }
    try {
        return cliente.consultar();
    } finally {
        permits.release();
    }
}

Os valores são hipotéticos. Resposta, o cliente e a exceção representam elementos da aplicação. O cliente também precisa de timeouts: o semáforo não limita a duração da chamada.

Esse trecho limita operações em execução e o tempo de espera de cada tentativa. Ele não limita sozinho a quantidade de tarefas aguardando permissão. Para proteger memória e filas, adicione controle de admissão na entrada ou uma política explícita de quantidade máxima de aguardantes.

Ao exceder o limite, devolva uma resposta previsível conforme o contrato: por exemplo, 429 para uma política de taxa excedida ou 503 para indisponibilidade temporária por saturação. Evite converter sobrecarga em erro interno genérico.

Preserve a interrupção ao tratar InterruptedException e libere permissões somente quando tiverem sido adquiridas. O finally deve cobrir todo o uso do recurso.

O limite do exemplo é local. Com várias réplicas, negocie a capacidade total e distribua o orçamento entre elas ou use coordenação apropriada. Controle também o fan-out: uma requisição que dispara dez chamadas consome capacidade muito mais rapidamente que uma chamada única.

Erro 6: deixar timeouts e retries multiplicarem a sobrecarga

Mais tarefas aguardando não tornam a espera gratuita. Cada requisição pode manter objetos, contexto e recursos ocupados. Por isso, estabeleça um prazo total e distribua o orçamento entre espera, conexão, leitura, banco e eventuais novas tentativas.

Em um exemplo hipotético de orçamento de dois segundos, a equipe poderia reservar 100 ms para admissão, 200 ms para trabalho no banco, até 1.400 ms para a integração e 300 ms para processamento e margem. Esse desenho precisa ser validado sob carga; não basta somar timeouts configurados isoladamente.

Uma nova tentativa só deve começar se houver tempo restante suficiente. Dois segundos para cada tentativa já deixam de respeitar um prazo total de dois segundos.

Revise os retries no cliente HTTP, no código da aplicação e na infraestrutura. Se três camadas permitirem três tentativas cada, uma operação poderá gerar até 27 tentativas ao serviço final, dependendo do fluxo de falhas. Essa multiplicação amplia a carga justamente durante a degradação.

Use tentativas limitadas, backoff e jitter para falhas elegíveis. Não repita automaticamente erros de validação nem faça retry indiscriminado de rejeições por sobrecarga.

Em pagamentos ou outras operações com efeitos externos, um timeout não comprova que a operação falhou. O serviço pode ter concluído o trabalho e perdido a resposta. Uma chave de idempotência ou mecanismo equivalente precisa impedir a duplicação antes de autorizar a repetição.

Também não presuma que um timeout cancela o trabalho subjacente. Valide cancelamento, propagação de interrupção e comportamento do driver ou cliente. Se a chamada continuar após o timeout visível, liberar o limite cedo demais poderá permitir mais operações reais que o previsto.

Todas as tentativas devem passar pelos mesmos controles da dependência. Considere tanto a ocupação física do recurso quanto o prazo da requisição original.

Erro 7: ignorar memória, contexto e fronteiras transacionais

Virtual threads suportam ThreadLocal, mas cada tarefa pode carregar valores próprios. Pequenos custos por thread tornam-se relevantes quando há muitas requisições em andamento.

Evite usar ThreadLocal como cache de recursos caros por virtual thread. Revise buffers, objetos de sessão, payloads retidos e resultados de tarefas paralelas. Threads mais leves não eliminam o custo dos dados associados.

Ao criar novas tarefas, verifique a propagação de MDC, tracing e contexto de segurança. Um identificador presente no controller pode desaparecer no log de uma consulta paralela se não houver integração de contexto adequada.

Faça um teste funcional simples: envie uma requisição com identificador de correlação, abra as tarefas previstas e confira os logs e spans de todas as etapas. Valide também qual identidade chega aos serviços internos. Escolha os mecanismos de propagação compatíveis com o executor e a stack utilizada.

Em transações imperativas gerenciadas pelo Spring, a transação da thread chamadora não se propaga automaticamente para uma nova thread. A migração de uma mesma virtual thread entre carriers não é essa fronteira; abrir outra tarefa é.

Uma tarefa chamada via @Async pode iniciar sua própria transação quando houver configuração e interceptação apropriadas. Isso não significa participar da transação original. Planeje confirmação, rollback e tratamento de falhas considerando essa separação.

Não compartilhe um EntityManager físico entre tarefas concorrentes. Prefira limites transacionais claros e dados independentes, como DTOs imutáveis, ao transportar resultados entre tarefas. Limpe valores de contexto quando a aplicação for responsável por configurá-los.

Erro 8: investigar lentidão olhando apenas CPU e contagem de threads

Para analisar a latência p99 no Spring Boot, acompanhe throughput, requisições em andamento, taxa de erros e percentis p50, p95 e p99. O p99 ajuda a enxergar a cauda da distribuição, onde filas e dependências lentas frequentemente aparecem.

CPU baixa com p99 alto sugere investigar espera, mas não identifica a causa. Pode haver disputa por conexão, contenção de lock, rede lenta ou outro bloqueio. Pinning é uma hipótese que precisa de evidência e contexto de versão.

Sintomas e evidências para localizar o gargalo
SituaçãoEvidências a correlacionarPróxima investigação
CPU saturadaUso e throttling de CPU, perfis de execução, alocação e GCCálculos intensivos, serialização, excesso de tarefas executáveis e limites do contêiner
Pool JDBC saturadoConexões ativas no limite, pendentes, tempo de aquisição e duração das transaçõesConsultas lentas, locks, N+1 e conexão retida por trabalho externo
Integração HTTP lentaDuração das chamadas, operações em andamento, espera no cliente e timeoutsCapacidade da dependência, pool de conexões HTTP, rede e retries

Use JFR durante uma janela representativa de degradação e consulte os eventos disponíveis no JDK. Para dumps que incluam virtual threads, utilize o mecanismo apropriado, como:

jcmd <PID> Thread.dump_to_file -format=json /tmp/threads.json

A cobertura e os detalhes do dump dependem da versão e das opções da JVM. Combine essa evidência com métricas e traces; uma fotografia isolada pode não capturar um bloqueio intermitente.

Métricas tradicionais de threads de plataforma não representam toda a concorrência virtual. Meça diretamente requisições e operações em andamento. Evite IDs de requisição, usuário ou thread como tags de métricas: a cardinalidade elevada aumenta custo e dificulta a análise. Reserve identificadores individuais para logs e traces.

Erro 9: confiar em benchmarks que escondem filas e dependências

Um teste de carga Spring Boot precisa reproduzir as restrições do sistema. Um endpoint que responde sem banco ou integração não valida o comportamento de uma rota que disputa esses recursos.

Compare a configuração atual com virtual threads mantendo iguais código, JDK, versão do Boot, infraestrutura, limites de CPU e memória, dados e pools. Se a avaliação também envolver atualização de JDK, faça uma comparação separada para distinguir os efeitos.

Inclua aquecimento, carga sustentada, picos e um teste prolongado. Use consultas e respostas externas representativas, além de falhas controladas em ambiente de teste.

Quando o objetivo for avaliar uma taxa de chegada independente da duração das respostas, use um modelo aberto. No k6, executores como ramping-arrival-rate permitem aumentar essa taxa. Em um modelo fechado, usuários aguardam a resposta antes de continuar; quando a API fica lenta, a entrada pode cair e esconder parte da sobrecarga. Essa distorção é associada a coordinated omission.

Um roteiro reproduzível pode seguir estas etapas:

  1. Registrar o baseline com carga estável e dependências saudáveis.
  2. Aumentar progressivamente a taxa de chegada até observar o limite do SLO.
  3. Introduzir atraso controlado na integração e repetir a mesma sequência.
  4. Simular lentidão no banco e verificar aquisição de conexões, rejeições e timeouts.
  5. Retirar o excesso de carga e medir a recuperação das filas e da latência.
  6. Repetir com virtual threads, preservando as condições da comparação.

Observe o gerador: CPU, rede, quantidade de usuários virtuais disponíveis e, no k6, iterações descartadas. Um gerador incapaz de produzir a carga planejada invalida conclusões sobre a capacidade da API.

Registre p99, erros, memória, pausas de GC, conexões pendentes e chamadas externas em andamento. Separe requisições concluídas com sucesso das rejeitadas rapidamente; uma latência aparentemente melhor pode resultar de mais rejeições.

Publique ganhos apenas com medições reproduzíveis. Para a decisão interna, considere também o custo por requisição bem-sucedida e a velocidade de recuperação após o pico.

Checklist para adotar virtual threads sem retrabalho

A adoção está pronta para avançar quando a equipe consegue demonstrar onde as virtual threads executam, quais recursos estão protegidos e como o sistema reage à sobrecarga. A propriedade habilitada é apenas uma parte dessa evidência.

Critérios de implantação e reversão
RequisitoEvidênciaResponsável sugeridoCondição de rollback
Versões e caminhos de execução confirmadosInventário de JDK, Boot, servidor e executores; verificações de isVirtual()DesenvolvimentoExecução diferente do desenho validado com impacto no serviço
Banco e integrações protegidosLimites por dependência e orçamento agregado entre réplicasPlataforma e responsáveis pelas dependênciasSaturação acima dos limites acordados na janela definida
Timeouts, retries e contexto revisadosTestes de prazo, cancelamento, idempotência, correlação e transaçõesDesenvolvimento e qualidadeOperações duplicadas, falhas funcionais ou perda de contexto necessário
Carga aprovada dentro do SLOComparação de capacidade, p99, erros, memória e recuperaçãoQualidade e operaçãoRegressão além da margem aceita pela equipe
Reversão preparadaConfiguração anterior preservada e procedimento exercitadoOperaçãoGatilhos de latência, erros ou saturação atingidos

Adote gradualmente quando o workload tiver espera de I/O relevante e os testes confirmarem benefício dentro do SLO. Adie quando faltar observabilidade ou quando a capacidade das dependências ainda for desconhecida. Corrija primeiro quando a regressão estiver ligada a executor inesperado, filas sem limite ou fronteiras transacionais inadequadas.

Na implantação gradual, observe o efeito agregado sobre banco e integrações. Uma pequena parcela de instâncias pode produzir pressão significativa se passar a admitir muito mais trabalho.

Prepare a reversão da propriedade e dos ajustes associados. Alterações em executores, admissão e timeouts podem exigir configuração adicional ou retorno à versão anterior da aplicação. Defina previamente a janela de avaliação e os gatilhos; não use limites universais de p99 ou erros.

Antes de ativar virtual threads em produção, aplique o checklist em um endpoint crítico e compare os resultados sob carga. Continue pelos conteúdos relacionados de Java & Spring Boot no blog Max Alex para aprofundar o gargalo identificado.

M
Publicado por

Max Alex

Desenvolvedor full stack especializado em SaaS, Laravel, IA aplicada, WhatsApp API, automações e integrações. Criação de sistemas web escaláveis para empresas.

Conhecer Max Alex
Próximas leituras

Continue lendo

Ver todos os artigos →