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, com benchmark, diagnóstico de pinning e a rota do Java 21 ao 25.

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.

A demonstração usa:

  • 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. O ganho esperado é suportar mais esperas simultâneas, não encurtar cada operação de I/O.

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
server.tomcat.threads.max=200

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.

A decisão depende do código que a equipe já mantém. Compare o resultado medido com a complexidade operacional de cada modelo.

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 criam conexões nem tornam consultas mais rápidas. Se muitas requisições esperam o HikariCP, elas passam a ocupar menos platform threads, mas a fila continua existindo. Elevar a concorrência HTTP sem revisar a camada de dados apenas desloca essa fila.

Meça o caminho completo: tempo de espera e conexões ativas do pool, limites do banco, duração das transações, latência das consultas, timeouts, erros e concorrência aceita pelo servidor HTTP. Não aumente o pool automaticamente; conexões adicionais podem elevar contenção em um banco que já chegou à capacidade útil.

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 trate toda chamada JDBC como fonte de pinning. O resultado depende do driver, da versão e do caminho executado; teste sob carga com as dependências reais.

Quando o controle do código é seu e a seção crítica precisa continuar serializada, ReentrantLock evita o pinning de monitor no Java 21. Uma substituição direta fica assim:

import java.util.concurrent.locks.ReentrantLock;

private final ReentrantLock lock = new ReentrantLock();

public String load() throws InterruptedException {
    lock.lock();
    try {
        Thread.sleep(Duration.ofMillis(100));
        return "ok";
    } finally {
        lock.unlock();
    }
}

Esse código ainda permite apenas uma execução de load() por vez; ele corrige o uso da carrier, não o gargalo da seção crítica. Se a correção permitir, mover o I/O para fora da região protegida costuma ser melhor. Não remova sincronização sem preservar as invariantes do código.

O que mudou no JDK 24 e a rota prática no Java 25

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.

O Java 25, lançado em setembro de 2025 e tratado como LTS pela maioria dos fornecedores, incorpora essa mudança. A migração também precisa atualizar o Spring Boot: a documentação da linha 3.4 declara compatibilidade até Java 24, enquanto a linha 3.5 atual declara compatibilidade com Java 25. Se o pinning causado por synchronized bloqueia a adoção em 2026, atualize para uma versão mantida do Spring Boot 3.5 ou posterior junto com Java 25, execute os testes de regressão e só então reavalie o problema. Essa rota é mais segura que rodar o exemplo em Spring Boot 3.4.0 com Java 25 ou substituir monitores em massa apenas por causa de virtual threads. O exemplo permanece em Java 21 para mostrar o comportamento que exige diagnóstico.

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

Para observação contínua, prefira Java Flight Recorder. No Java 21, o evento jdk.VirtualThreadPinned registra bloqueios presos à carrier que ultrapassam o limiar configurado; na configuração padrão, o evento fica habilitado com limiar de 20 ms. Uma gravação limitada evita o volume de stack traces no log da aplicação:

java -XX:StartFlightRecording=filename=recording.jfr,settings=profile,duration=5m \
  -jar target/virtual-threads-demo-0.0.1-SNAPSHOT.jar
jfr print --events jdk.VirtualThreadPinned recording.jfr

Para investigação local no Java 21, inicie a aplicação com o diagnóstico textual:

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 para localizar o monitor e a dependência envolvidos. Esse diagnóstico pode gerar bastante saída; deixe JFR como primeira opção 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

Um teste com 100 VUs não atravessa o limite de 200 threads configurado no Tomcat: como cada requisição espera 100 ms, as duas configurações devem ficar próximas. Para tornar o teto visível, compare esse controle com 1.000 VUs, mantendo server.tomcat.threads.max=200 nos dois modos e alterando apenas spring.threads.virtual.enabled.

O script usado aceita a concorrência pela variável VUS:

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

export const options = {
  vus: Number(__ENV.VUS),
  duration: __ENV.DURATION || "30s",
  discardResponseBodies: true,
  summaryTrendStats: ["avg", "med", "p(90)", "p(95)", "p(99)", "max"],
};

export default function () {
  const response = http.get("http://127.0.0.1:8080/blocking");
  check(response, { "status é 200": (result) => result.status === 200 });
}

Depois de um aquecimento de 10 segundos, execute as duas cargas em cada modo:

VUS=100 DURATION=10s k6 run load.js
VUS=100 DURATION=30s k6 run load.js
VUS=1000 DURATION=30s k6 run load.js

O que medimos

Executamos uma rodada de 30 segundos por combinação em uma VPS compartilhada com 4 CPUs lógicas e 8 GB de RAM. A aplicação usou OpenJDK 21.0.11, Spring Boot 3.4.0, Tomcat 10.1.33 e server.tomcat.threads.max=200; o k6 2.2.0 rodou no mesmo host. Antes de cada rodada, o endpoint confirmou o valor esperado de Thread.currentThread().isVirtual().

ThreadsVUsreq/sp95p99erros HTTP
Platform100987,75102,75 ms105,03 ms0%
Virtual100987,87102,88 ms104,63 ms0%
Platform1.0001.981,17516,84 ms532,78 ms0%
Virtual1.0009.277,02130,62 ms164,13 ms0%

O controle de 100 VUs produziu o resultado esperado: diferença desprezível. Com 1.000 VUs, as platform threads chegaram perto do teto teórico de 2.000 req/s imposto por 200 workers atendendo esperas de 100 ms; a fila elevou p95 e p99 para mais de 500 ms. As virtual threads mantiveram mais esperas em andamento e ficaram próximas de 9.300 req/s nesse host.

Essa tabela demonstra o mecanismo, não a capacidade de uma aplicação real. É uma única rodada, e aplicação e gerador de carga disputaram a mesma máquina. Thread.sleep não representa driver JDBC, pool de conexões, banco, rede ou serviço remoto. Repita o roteiro com um endpoint representativo, mantenha constantes versões, dados, timeouts e pools, e observe em conjunto CPU, memória, platform threads, pinning, saturação externa e erros.

Quando virtual threads ajudam pouco ou não ajudam

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. Uma cadeia WebFlux que já usa I/O não bloqueante também não ganha uma nova vantagem apenas por trocar seu executor. Os casos de pinning e JNI no Java 21 foram tratados nas seções anteriores porque exigem diagnóstico próprio.

Checklist para uma adoção segura

Antes de habilitar em produção:

  1. Confirme que o caminho é Spring MVC com espera dominante em I/O bloqueante e registre a linha de base.
  2. Em uma adoção nova, use uma linha do Spring Boot compatível junto com Java 25; se permanecer no Java 21, inclua pinning no plano de teste.
  3. Habilite spring.threads.virtual.enabled=true em homologação e confirme Thread.currentThread().isVirtual().
  4. Gere carga abaixo e acima do limite atual com um endpoint representativo, não apenas com Thread.sleep.
  5. Compare req/s, p95, p99, erros e CPU junto com pools e saturação dos serviços externos.
  6. Use JFR para procurar jdk.VirtualThreadPinned e mantenha timeouts e limites de concorrência.
  7. Adote somente se o resultado se repetir e a complexidade operacional continuar aceitável.

Fontes oficiais

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

Próximo passo

A recomendação da DevDojo em 2026 é direta: para uma aplicação Spring MVC dominada por I/O bloqueante, teste virtual threads em uma versão mantida do Spring Boot compatível com Java 25 antes de considerar uma migração reativa. Mantenha a flag somente quando testes repetidos com dependências reais melhorarem throughput ou latência de cauda. Em workload CPU-bound, já reativo ou com baixa concorrência, não habilite apenas porque a opção existe.

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