Todos os artigos

// Knowledge.log — 技術記事

Spring Boot 3 e virtual threads: o que realmente muda em APIs com I/O bloqueante

Veja quando virtual threads ajudam APIs Spring MVC com I/O bloqueante, como medir, detectar pinning e reconhecer seus limites com Java 21.

Virtual threads permitem atender muitas operações bloqueantes simultâneas sem manter uma platform thread dedicada para cada uma delas. Isso combina bem com aplicações Spring MVC que usam APIs síncronas, como JDBC, JdbcTemplate, JPA ou clientes HTTP bloqueantes.

Mas habilitar a propriedade não torna qualquer aplicação mais rápida. O resultado depende do tipo de trabalho, dos limites externos e, no Java 21, da presença de operações que causam *pinning*.

Este artigo considera explicitamente:

  • Spring Boot 3.2, 3.3 ou 3.4;
  • Java 21;
  • Spring MVC com Tomcat;
  • endpoints cujo tempo é dominado por espera de I/O.

O Spring Boot oferece suporte a virtual threads desde a versão 3.2, mas a funcionalidade continua opt-in nas versões 3.2 a 3.4. A propriedade permanece desabilitada por padrão: não houve mudança de *default* nesse intervalo.

O problema das requisições bloqueantes

Em uma aplicação Spring MVC tradicional, cada requisição é processada por uma thread do servidor. Quando o código espera uma consulta JDBC ou uma chamada HTTP, essa thread permanece ocupada durante a espera.

Com concorrência suficiente, o pool de threads do servidor pode se tornar o primeiro limite:

requisição
    ↓
thread do Tomcat
    ↓
espera por JDBC ou HTTP
    ↓
thread indisponível para outra requisição

Virtual threads preservam o modelo de programação síncrono, mas são muito mais baratas que platform threads. Quando uma virtual thread encontra uma operação bloqueante compatível, a JVM pode desmontá-la temporariamente da platform thread que a executa, chamada de *carrier thread*. A carrier fica livre para executar outra virtual thread enquanto a operação aguarda.

Isso melhora a capacidade de manter muitas operações de I/O em andamento. Não reduz, por si só, o tempo que o banco de dados ou o serviço remoto leva para responder.

Como habilitar no Spring Boot 3.2 a 3.4

A configuração é explícita:

spring.threads.virtual.enabled=true

O valor padrão continua sendo false. Portanto, atualizar do Spring Boot 3.2 para o 3.4 não habilita virtual threads automaticamente.

Quando a propriedade está ativa e a aplicação roda em Java 21, o Spring Boot adapta componentes compatíveis, incluindo o servidor Tomcat embutido e a infraestrutura de execução de tarefas.

Exemplo executável

O exemplo abaixo usa Spring Boot 3.4.0 e Java 21. O Thread.sleep representa uma espera bloqueante, como a latência de uma consulta ou de uma integração HTTP. Ele é útil para verificar a configuração, mas não substitui um teste com as dependências reais da aplicação.

Estrutura:

virtual-threads-demo/
├── pom.xml
└── src/
    └── main/
        ├── java/com/devdojo/demo/
        │   ├── DemoApplication.java
        │   └── BlockingController.java
        └── resources/
            └── application.properties

O pom.xml:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
                             https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.4.0</version>
        <relativePath/>
    </parent>

    <groupId>com.devdojo</groupId>
    <artifactId>virtual-threads-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <properties>
        <java.version>21</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

A classe principal:

package com.devdojo.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

O controller:

package com.devdojo.demo;

import java.time.Duration;
import java.util.Map;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class BlockingController {

    @GetMapping("/blocking")
    public Map<String, Object> blocking() throws InterruptedException {
        Thread.sleep(Duration.ofMillis(100));

        Thread current = Thread.currentThread();

        return Map.of(
                "thread", current.toString(),
                "virtual", current.isVirtual()
        );
    }
}

A configuração em application.properties:

spring.threads.virtual.enabled=true

Execute a aplicação:

mvn spring-boot:run

Em outro terminal, consulte o endpoint:

curl http://localhost:8080/blocking

O campo virtual deve ser true. Essa verificação confirma que a requisição está sendo atendida por uma virtual thread; ela ainda não demonstra ganho de desempenho.

Para comparar os dois modos, altere somente a propriedade:

spring.threads.virtual.enabled=false

Reinicie a aplicação e faça a mesma chamada. O campo virtual deverá ser false.

Spring MVC com virtual threads não é o mesmo que WebFlux

Virtual threads e programação reativa resolvem problemas semelhantes por modelos diferentes.

Com Spring MVC e virtual threads, o código continua sequencial:

var customer = customerRepository.findById(id);
var invoice = billingClient.findInvoice(customer.id());
return new CustomerResponse(customer, invoice);

Cada requisição pode bloquear sem exigir uma platform thread exclusiva durante toda a espera, desde que a JVM consiga desmontar a virtual thread.

No WebFlux, a aplicação normalmente trabalha com um event loop e encadeia operações não bloqueantes:

return customerRepository.findById(id)
        .flatMap(customer ->
                billingClient.findInvoice(customer.id())
                        .map(invoice -> new CustomerResponse(customer, invoice))
        );

Virtual threads podem ser uma boa opção quando:

  • a aplicação já usa Spring MVC;
  • as dependências oferecem APIs bloqueantes;
  • a maior parte do tempo é gasta esperando JDBC ou HTTP;
  • a equipe prefere manter um fluxo síncrono.

WebFlux continua apropriado quando toda a cadeia já é não bloqueante. Executar um WebClient reativo em uma virtual thread não transforma automaticamente esse fluxo em algo mais eficiente: o cliente já usa I/O não bloqueante e event loops.

Não existe uma escolha universal. Meça a aplicação e considere também a complexidade operacional e o modelo de programação adotado pela equipe.

Virtual threads não removem o limite do banco

Uma aplicação pode aceitar mais requisições concorrentes e, ainda assim, continuar limitada pelo banco de dados.

Virtual threads não substituem:

  • o pool de conexões;
  • limites de conexões do banco;
  • timeouts;
  • controle de concorrência;
  • transações bem delimitadas;
  • dimensionamento do Hibernate e do HikariCP.

Considere uma aplicação com muitas requisições esperando uma conexão do HikariCP. Habilitar virtual threads permite que essas esperas consumam menos platform threads, mas não cria novas conexões nem aumenta a capacidade do banco.

A consequência prática é importante: elevar a concorrência HTTP sem revisar o pool e o banco pode apenas deslocar a fila para outra camada.

Observe em conjunto:

  • tempo esperando uma conexão;
  • quantidade de conexões ativas e ociosas;
  • duração das transações;
  • latência das consultas;
  • timeouts e erros do banco;
  • concorrência aceita pelo servidor HTTP.

Ajustar o pool para um valor muito alto também não é uma solução automática. O banco possui capacidade finita, e conexões adicionais podem aumentar contenção.

Pinning no Java 21

Este artigo usa Java 21 como premissa. Nessa versão, uma virtual thread pode ficar presa à carrier thread durante uma operação bloqueante executada dentro de uma região synchronized.

Um padrão problemático é:

public synchronized String load() throws InterruptedException {
    Thread.sleep(Duration.ofMillis(100));
    return "ok";
}

No Java 21, o sleep ocorre enquanto o monitor está adquirido. A virtual thread não pode ser desmontada normalmente durante essa espera, mantendo a carrier ocupada.

O problema também pode estar escondido dentro de:

  • drivers JDBC antigos;
  • wrappers de conexão;
  • bibliotecas que sincronizam operações de rede;
  • dependências que chamam código nativo;
  • integrações baseadas em JNI.

Não conclua que toda chamada JDBC causa pinning. Isso depende do driver, da versão e do caminho executado. A forma correta de descobrir é testar a aplicação com suas dependências reais.

Quando o controle do código é seu, considere substituir um synchronized que envolve I/O por uma estratégia que não mantenha o monitor durante a espera. A mudança precisa preservar a correção da sincronização; remover o monitor apenas para eliminar um aviso pode introduzir condições de corrida.

O que mudou no JDK 24

O JEP 491, entregue no JDK 24, alterou a implementação para que virtual threads possam bloquear em regiões synchronized sem o pinning causado por monitores que existia no Java 21.

Essa melhoria deve ser descrita com a versão correta: ela não vale para a premissa Java 21 deste artigo e não deve ser generalizada como se todos os JDKs recentes tivessem o mesmo comportamento.

Chamadas nativas e integrações JNI ainda merecem avaliação própria. Uma virtual thread pode permanecer associada à carrier enquanto executa código nativo, reduzindo o paralelismo disponível se a chamada for longa ou bloqueante.

Como verificar pinning

No Java 21, inicie a aplicação com o diagnóstico de pinning habilitado:

JAVA_TOOL_OPTIONS="-Djdk.tracePinnedThreads=full" mvn spring-boot:run

Depois, gere carga nos endpoints que executam JDBC, chamadas HTTP síncronas ou bibliotecas suspeitas.

A JVM imprime stack traces quando detecta uma virtual thread bloqueada enquanto está presa a uma carrier. A opção reduzida também está disponível:

-Djdk.tracePinnedThreads=short

Use full durante a investigação para localizar o monitor e a dependência envolvidos. Esse diagnóstico pode gerar bastante saída e não deve ser habilitado indiscriminadamente em produção.

Para observar as threads estruturadas pela JVM, descubra o PID:

jcmd

Em seguida, gere um dump em JSON:

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

Pesquise no arquivo por virtual threads e pelos stacks associados ao endpoint. Dumps tradicionais podem não representar todas as virtual threads da forma esperada, especialmente quando há grande quantidade delas; o formato estruturado é mais adequado para essa inspeção.

O método Thread.currentThread().isVirtual() usado no exemplo também é uma verificação direta e simples para um caminho específico.

Como medir sem inventar ganhos

Não compare apenas uma chamada feita com curl. Virtual threads são principalmente uma ferramenta de escalabilidade para concorrência bloqueante; a diferença costuma aparecer quando várias operações ficam esperando ao mesmo tempo.

Uma comparação honesta mantém constantes:

  • máquina e versão do Java;
  • versão e configuração da aplicação;
  • duração do teste;
  • aquecimento da JVM;
  • concorrência;
  • endpoint;
  • banco e dados consultados;
  • pool de conexões;
  • timeouts;
  • serviço remoto;
  • ferramenta geradora de carga.

Altere apenas:

spring.threads.virtual.enabled=false

e depois:

spring.threads.virtual.enabled=true

Carga simples com k6

Salve este script como load.js:

import http from "k6/http";
import { check } from "k6";

export const options = {
  vus: 100,
  duration: "30s",
};

export default function () {
  const response = http.get("http://localhost:8080/blocking");

  check(response, {
    "status é 200": (result) => result.status === 200,
  });
}

Execute:

k6 run load.js

Os valores de vus e duration são apenas uma configuração inicial reproduzível. Ajuste-os à capacidade do ambiente, sem tratar o endpoint com sleep como representação fiel da produção.

Compare pelo menos:

  • requisições concluídas por segundo;
  • latências mediana, p95 e p99;
  • taxa de erros;
  • uso de CPU;
  • uso de memória;
  • quantidade de platform threads;
  • espera por conexões do pool;
  • latência e saturação do banco;
  • ocorrências de pinning.

Não há um percentual de ganho que possa ser prometido antecipadamente. Se o pool de conexões, o banco ou o serviço remoto já estiver saturado, o throughput pode permanecer igual e a latência pode crescer porque mais operações passaram a disputar o mesmo recurso.

Depois do teste sintético, repita a comparação em um endpoint representativo com JDBC ou integração HTTP real.

Quando virtual threads ajudam pouco ou não ajudam

Trabalho limitado por CPU

Virtual threads não adicionam núcleos ao processador. Parsing pesado, compressão, criptografia e cálculos intensivos continuam limitados pela CPU.

Permitir concorrência sem controle nesses caminhos pode aumentar troca de contexto e latência. Para trabalho CPU-bound, use concorrência limitada de acordo com os recursos disponíveis.

Aplicações totalmente reativas

Uma cadeia WebFlux baseada em APIs não bloqueantes já evita dedicar uma thread a cada espera. Migrar seu event loop para virtual threads não oferece automaticamente um benefício adicional.

Bibliotecas com bloqueio incompatível

No Java 21, dependências que mantêm synchronized durante I/O podem causar pinning. Drivers, agentes, wrappers e bibliotecas nativas precisam ser avaliados sob carga.

Chamadas JNI longas ou bloqueantes

Uma chamada nativa pode manter a virtual thread associada à carrier durante sua execução. Se muitas chamadas desse tipo ocorrerem ao mesmo tempo, o conjunto de carriers pode sofrer contenção.

Recursos externos já saturados

Virtual threads aumentam a capacidade de esperar de forma barata; elas não aumentam a capacidade do banco, da API externa ou do sistema de arquivos. Sem limites de concorrência, a aplicação pode enviar mais trabalho a um recurso que já está saturado.

Checklist para uma adoção segura

Antes de habilitar em produção:

1. Confirme Spring Boot 3.2 a 3.4 e Java 21. 2. Habilite spring.threads.virtual.enabled=true em um ambiente de teste. 3. Verifique Thread.currentThread().isVirtual() em um endpoint. 4. Gere carga com uma operação de I/O representativa. 5. Compare throughput, percentis de latência, erros e CPU. 6. Observe o pool de conexões e a capacidade do banco. 7. Execute o teste com -Djdk.tracePinnedThreads=full. 8. Investigue stacks de pinning em código próprio e dependências. 9. Mantenha limites e timeouts para recursos externos. 10. Só adote se a medição mostrar um resultado útil para o workload real.

Fontes oficiais

O comportamento descrito parte da documentação e das propostas oficiais:

Próximo passo

Habilite virtual threads em homologação, execute o mesmo teste com a propriedade ligada e desligada e investigue qualquer pinning antes de decidir pela adoção.

javaspring-bootvirtual-threadsperformance

// Continue.training — 次のステップ

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos