Ativar virtual threads no Spring Boot pode permitir que uma API mantenha mais tarefas esperando por I/O, mas não aumenta o número de conexões disponíveis no banco. Se a aplicação já disputa um pool pequeno, o gargalo continua lá: mais requisições chegam à espera por conexão, a latência cresce e parte delas termina em timeout. O primeiro diagnóstico deve separar tempo de espera no pool e tempo de execução da consulta.
Uma thread virtual não executa uma consulta SQL mais rápido. Seu benefício aparece na organização de muitas tarefas concorrentes que passam tempo bloqueadas. A documentação do Java sobre virtual threads descreve essa diferença entre capacidade de concorrência e velocidade de execução. Para operações que consomem CPU continuamente, trocar o tipo de thread não cria núcleos adicionais.
Virtual threads no Spring Boot: onde a fila se forma
Considere, por exemplo, uma aplicação hipotética com oito conexões e centenas de requisições concorrentes. Em cada instante, somente as operações que conseguiram uma conexão podem usá-la. As demais aguardam, falham ao atingir o prazo de aquisição ou são rejeitadas por algum controle anterior. Aumentar o número de tarefas em memória muda o tamanho da disputa, sem mudar automaticamente a capacidade do recurso compartilhado.
Há pelo menos duas esperas distintas para investigar: adquirir uma conexão e obter o resultado do banco. Uma transação pode ainda ficar segurando a conexão enquanto chama outro serviço. Nesse caso, uma dependência HTTP lenta ocupa capacidade do banco mesmo sem executar SQL durante todo o intervalo. Por isso, olhar apenas a duração da query pode esconder o tempo real de retenção da conexão.
Esse diagnóstico conversa com o guia de Spring Data JPA no Spring Boot, mas resolve uma pergunta mais estreita: onde limitar concorrência ao mudar o modelo de threads. Aumentar o pool sem observar o banco pode apenas transferir a saturação para ele, com mais operações competindo por CPU, disco ou locks.
Como configurar virtual threads no Spring Boot
O Boot permite habilitar o suporte com a propriedade abaixo. A documentação atual exige Java 21 ou posterior e recomenda Java 24 ou posterior para uma experiência melhor. Confira também os componentes e executores utilizados pela aplicação: essa propriedade não transforma todo executor criado manualmente em um executor virtual.
# application.properties
spring.threads.virtual.enabled=true
Consulte a seção de virtual threads na documentação do Boot antes de aplicar a mudança. Threads virtuais são daemon threads; em aplicações que dependem de tarefas agendadas para permanecer vivas, avalie a configuração de keep-alive indicada pelo framework. Uma aplicação web e um processo de jobs não têm necessariamente o mesmo ciclo de vida.
Quando o datasource usa HikariCP, maximumPoolSize limita as conexões do pool, enquanto connectionTimeout limita a espera por uma conexão. Esse timeout não é o timeout de execução de SQL. A documentação do HikariCP deve orientar as unidades e os limites aceitos pela versão utilizada.
# Valores didáticos; dimensione para seu banco e sua carga.
spring.datasource.hikari.maximum-pool-size=8
spring.datasource.hikari.connection-timeout=1000
Os números desse exemplo não são uma recomendação universal. Se dez instâncias puderem abrir oito conexões cada, o conjunto poderá consumir até oitenta conexões, além de outros clientes do banco. O orçamento deve considerar autoscaling, tarefas administrativas e a capacidade de recuperação durante falhas. Dimensionar cada instância isoladamente é uma forma fácil de exceder o limite total.
Exemplo prático: mil tarefas e oito vagas
O programa a seguir usa um semáforo para representar um recurso com oito vagas. Ele não abre conexão JDBC e não mede a vazão de um banco. Sua finalidade é verificar uma propriedade do desenho: mil tarefas podem ser criadas, mas no máximo oito entram simultaneamente na região protegida. O tempo de espera simulado serve apenas para tornar a concorrência observável.
Salve como PoolDemo.java e execute com java PoolDemo.java em um JDK 21 ou posterior. A execução local foi feita no OpenJDK 21.0.8, distribuição JBR, e terminou com mil tarefas concluídas e pico de oito. Isso comprova a restrição do exemplo; não é benchmark nem medição de uma aplicação Spring Boot.
import java.util.ArrayList;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicInteger;
public class PoolDemo {
public static void main(String[] args) throws Exception {
int limite = 8;
int tarefas = 1000;
var vagas = new Semaphore(limite);
var ativas = new AtomicInteger();
var pico = new AtomicInteger();
var concluidas = new AtomicInteger();
var resultados = new ArrayList<Future<?>>();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < tarefas; i++) {
resultados.add(executor.submit(() -> {
vagas.acquire();
try {
int agora = ativas.incrementAndGet();
pico.accumulateAndGet(agora, Math::max);
try {
Thread.sleep(10);
concluidas.incrementAndGet();
} finally {
ativas.decrementAndGet();
}
} finally {
vagas.release();
}
return null;
}));
}
for (var resultado : resultados) resultado.get();
}
if (pico.get() > limite || concluidas.get() != tarefas) {
throw new AssertionError("Limite ou quantidade violados");
}
System.out.printf("tarefas=%d pico=%d limite=%d%n",
concluidas.get(), pico.get(), limite);
}
}
tarefas=1000 pico=8 limite=8A espera em acquire() é deliberadamente ilimitada para que todas as tarefas finitas do exemplo terminem. Em uma API pública, copiar essa estratégia sem limite de admissão pode acumular trabalho além do que o sistema consegue atender. Defina um prazo, limite a quantidade de trabalho aceito e trate a recusa de forma coerente com o contrato da operação.
O pool JDBC já controla a aquisição de conexões. Não acrescente automaticamente outro semáforo ao redor de todo repository: isso pode criar duas filas com prazos diferentes e dificultar o diagnóstico. Um limite adicional faz sentido quando protege um recurso específico ou restringe trabalho antes que ele consuma outros recursos caros. Descreva o que ele protege e como a requisição falha quando não consegue entrar.
Erros comuns ao interpretar o resultado
Uma armadilha é observar CPU baixa e concluir que falta concorrência. Se há espera por locks, a CPU pode continuar baixa enquanto o banco impede o avanço. A causa nesse caso é contenção de dados; o sintoma aparece na duração da transação e nas sessões bloqueadas. A correção passa pela consulta, pela ordem de acesso ou pela duração da transação, conforme a evidência encontrada.
Outro cenário hipotético é uma consulta simples que participa de uma transação muito longa. O time reduz o tempo da query, mas as conexões continuam ocupadas durante uma chamada externa. Revisar a fronteira transacional pode ser mais útil do que ampliar o pool. O texto sobre uso de Transactional readOnly complementa essa investigação sem transformar read-only em solução automática de capacidade.
Também é preciso cuidado com orientações antigas sobre pinning. O comportamento depende da versão do JDK. Não aplique indiscriminadamente a afirmação de que todo bloqueio dentro de synchronized prende uma virtual thread: essa área mudou nas versões recentes. Ao investigar, registre o JDK efetivo e consulte os eventos e as limitações documentadas para aquela versão, inclusive interações com código nativo.
Quando usar e quando evitar a mudança
Virtual threads podem ser uma boa opção para serviços com modelo imperativo e muitas operações bloqueantes, desde que cada dependência tenha capacidade e prazos conhecidos. O trade-off é que fica mais fácil admitir uma grande quantidade de trabalho: o custo de manter a thread pode cair sem que o custo das requisições, dos objetos e dos recursos externos caia junto.
Se o problema medido é processamento intenso em CPU, comece pelo perfil de execução e pelo algoritmo. Se a saturação está no banco, compare espera por conexão, conexões ativas, duração das transações e consultas lentas antes e depois. A análise de métricas com Actuator e Prometheus ajuda a evitar um painel que mostra média aceitável enquanto uma parte dos usuários espera muito mais.
Faça o ensaio com a mesma taxa de chegada e distribuição de operações. Um teste que envia a próxima requisição somente depois da anterior terminar pode esconder a formação de fila. Inclua dependência lenta, indisponibilidade e retorno à normalidade. Além da latência, observe memória e trabalho pendente: um serviço precisa conseguir se recuperar depois do pico, sem carregar uma fila antiga indefinidamente.
FAQ sobre concorrência e pool
Preciso aumentar o pool ao ativar virtual threads?
Não automaticamente. A decisão depende da capacidade do banco e do tempo durante o qual cada operação segura a conexão. Primeiro verifique se a espera está na aquisição, na consulta ou em uma transação longa; cada causa exige uma intervenção diferente.
O connection-timeout cancela uma query demorada?
No HikariCP, essa configuração limita a espera para obter uma conexão. O controle de uma query em execução depende de mecanismos do driver, do banco e da camada de acesso. Configure e teste esses limites separadamente para não presumir uma proteção que não existe.
Mil virtual threads significam mil consultas simultâneas?
Somente se os demais recursos permitirem tal concorrência. Com um pool de oito conexões, as tarefas disputam essas oito vagas. O exemplo executável demonstra essa separação usando um semáforo, sem afirmar que o comportamento de um banco real foi medido.
Conclusão: meça a espera antes de mudar a capacidade
O próximo passo é localizar o limite atual da API e executar um ensaio com falhas controladas. Registre JDK, configuração do pool, quantidade de instâncias e critérios de recusa. Use os links de leitura complementar sobre JPA, transações e métricas para aprofundar a causa encontrada. A adoção fica mais previsível quando a equipe sabe quanto trabalho aceita, onde ele espera e em quanto tempo precisa desistir de uma dependência.
