All articles

// Knowledge.log — 技術記事

Spring Boot 3 and virtual threads: what really changes in APIs with blocking I/O

See when virtual threads help Spring MVC APIs with blocking I/O, with a benchmark, pinning diagnostics, and the path from Java 21 to 25.

Virtual threads let you handle many simultaneous blocking operations without keeping a dedicated platform thread for each one. That fits well with Spring MVC applications that use synchronous APIs such as JDBC, JdbcTemplate, JPA, or blocking HTTP clients.

But enabling the property does not make every application faster. The result depends on the type of work, external limits, and, on Java 21, the presence of operations that cause pinning.

The demonstration uses:

  • Spring Boot 3.2, 3.3, or 3.4;
  • Java 21;
  • Spring MVC with Tomcat;
  • endpoints whose time is dominated by waiting for I/O.

Spring Boot has supported virtual threads since version 3.2, but the feature remains opt-in in versions 3.2 through 3.4. The property remains disabled by default: there was no change to the default over that range.

The problem with blocking requests

In a traditional Spring MVC application, each request is processed by a server thread. When the code waits for a JDBC query or an HTTP call, that thread remains occupied during the wait.

With enough concurrency, the server thread pool can become the first limit:

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

Virtual threads preserve the synchronous programming model, but they are much cheaper than platform threads. When a virtual thread encounters a compatible blocking operation, the JVM can temporarily unmount it from the platform thread running it, called the carrier thread. The carrier is then free to run another virtual thread while the operation waits. The expected gain is support for more simultaneous waits, not making each I/O operation shorter.

How to enable them in Spring Boot 3.2 through 3.4

The configuration is explicit:

spring.threads.virtual.enabled=true

The default value remains false. Therefore, upgrading from Spring Boot 3.2 to 3.4 does not enable virtual threads automatically.

When the property is active and the application runs on Java 21, Spring Boot adapts compatible components, including the embedded Tomcat server and task execution infrastructure.

Executable example

The example below uses Spring Boot 3.4.0 and Java 21. Thread.sleep represents a blocking wait, such as the latency of a query or an HTTP integration. It is useful for checking the configuration, but it does not replace a test with the application's real dependencies.

Structure:

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

The 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>

The main class:

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);
    }
}

The 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()
        );
    }
}

The configuration in application.properties:

spring.threads.virtual.enabled=true
server.tomcat.threads.max=200

Run the application:

mvn spring-boot:run

In another terminal, call the endpoint:

curl http://localhost:8080/blocking

The virtual field should be true. This check confirms that the request is being served by a virtual thread; it does not yet demonstrate a performance gain.

To compare the two modes, change only the property:

spring.threads.virtual.enabled=false

Restart the application and make the same call. The virtual field should be false.

Spring MVC with virtual threads is not the same as WebFlux

Virtual threads and reactive programming solve similar problems with different models.

With Spring MVC and virtual threads, the code remains sequential:

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

Each request can block without requiring an exclusive platform thread for the entire wait, as long as the JVM can unmount the virtual thread.

In WebFlux, the application typically works with an event loop and chains non-blocking operations:

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

Virtual threads can be a good option when:

  • the application already uses Spring MVC;
  • the dependencies provide blocking APIs;
  • most of the time is spent waiting for JDBC or HTTP;
  • the team prefers to keep a synchronous flow.

WebFlux remains appropriate when the entire chain is already non-blocking. Running a reactive WebClient on a virtual thread does not automatically make that flow more efficient: the client already uses non-blocking I/O and event loops.

The decision depends on the code the team already maintains. Compare the measured result with the operational complexity of each model.

Virtual threads do not remove the database limit

An application can accept more concurrent requests and still remain limited by the database.

Virtual threads do not create connections or make queries faster. If many requests are waiting for HikariCP, they occupy fewer platform threads, but the queue still exists. Raising HTTP concurrency without reviewing the data layer merely moves that queue.

Measure the entire path: wait time and active pool connections, database limits, transaction duration, query latency, timeouts, errors, and the concurrency accepted by the HTTP server. Do not increase the pool automatically; additional connections can increase contention in a database that has already reached its useful capacity.

Pinning on Java 21

This article assumes Java 21. In that version, a virtual thread can remain pinned to the carrier thread during a blocking operation executed inside a synchronized region.

One problematic pattern is:

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

On Java 21, the sleep happens while the monitor is held. The virtual thread cannot be unmounted normally during that wait, keeping the carrier occupied.

The problem may also be hidden inside:

  • older JDBC drivers;
  • connection wrappers;
  • libraries that synchronize network operations;
  • dependencies that call native code;
  • JNI-based integrations.

Do not treat every JDBC call as a source of pinning. The result depends on the driver, version, and executed path; test under load with the real dependencies.

When you control the code and the critical section must remain serialized, ReentrantLock avoids monitor pinning on Java 21. A direct replacement looks like this:

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();
    }
}

This code still allows only one execution of load() at a time; it fixes carrier usage, not the critical-section bottleneck. If the fix allows it, moving the I/O outside the protected region is usually better. Do not remove synchronization without preserving the code's invariants.

What changed in JDK 24 and the practical path on Java 25

JEP 491, delivered in JDK 24, changed the implementation so that virtual threads can block in synchronized regions without the monitor-caused pinning that existed on Java 21.

Java 25, released in September 2025 and treated as an LTS release by most vendors, includes this change. The migration must update Spring Boot as well: the 3.4 line documentation declares compatibility through Java 24, while the current 3.5 line declares compatibility with Java 25. If pinning caused by synchronized blocks adoption in 2026, upgrade to a maintained version of Spring Boot 3.5 or later together with Java 25, run regression tests, and only then reassess the problem. This route is safer than running the example on Spring Boot 3.4.0 with Java 25 or replacing monitors wholesale just because of virtual threads. The example remains on Java 21 to show the behavior that requires diagnosis.

Native calls and JNI integrations still deserve their own evaluation. A virtual thread can remain associated with the carrier while running native code, reducing available parallelism if the call is long-running or blocking.

How to check for pinning

For continuous observation, prefer Java Flight Recorder. On Java 21, the jdk.VirtualThreadPinned event records blocks pinned to the carrier that exceed the configured threshold; in the default configuration, the event is enabled with a 20 ms threshold. A bounded recording avoids a flood of stack traces in the application log:

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

For local investigation on Java 21, start the application with text diagnostics:

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

Then generate load against the endpoints that execute JDBC, synchronous HTTP calls, or suspicious libraries.

The JVM prints stack traces when it detects a virtual thread blocked while pinned to a carrier. The shorter option is also available:

-Djdk.tracePinnedThreads=short

Use full to locate the monitor and dependency involved. This diagnostic can produce plenty of output; keep JFR as the first choice in production.

To observe the threads structured by the JVM, find the PID:

jcmd

Then generate a JSON dump:

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

Search the file for virtual threads and the stacks associated with the endpoint. Traditional dumps may not represent every virtual thread as expected, especially when there are many of them; the structured format is better suited to this inspection.

The Thread.currentThread().isVirtual() method used in the example is also a simple, direct check for a specific path.

How to measure without inventing gains

A test with 100 VUs does not cross the configured Tomcat limit of 200 threads: because each request waits for 100 ms, the two configurations should be close. To make the ceiling visible, compare this control with 1,000 VUs, keeping server.tomcat.threads.max=200 in both modes and changing only spring.threads.virtual.enabled.

The script accepts concurrency through the VUS variable:

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 });
}

After a 10-second warm-up, run both loads in each mode:

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

What we measured

We ran one 30-second round per combination on a shared VPS with 4 logical CPUs and 8 GB of RAM. The application used OpenJDK 21.0.11, Spring Boot 3.4.0, Tomcat 10.1.33, and server.tomcat.threads.max=200; k6 2.2.0 ran on the same host. Before each round, the endpoint confirmed the expected value of Thread.currentThread().isVirtual().

ThreadsVUsreq/sp95p99HTTP errors
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%

The 100-VU control produced the expected result: a negligible difference. With 1,000 VUs, platform threads came close to the theoretical ceiling of 2,000 req/s imposed by 200 workers serving 100 ms waits; the queue pushed p95 and p99 above 500 ms. Virtual threads kept more waits in progress and came close to 9,300 req/s on this host.

This table demonstrates the mechanism, not the capacity of a real application. It is a single round, and the application and load generator competed for the same machine. Thread.sleep does not represent a JDBC driver, connection pool, database, network, or remote service. Repeat the procedure with a representative endpoint, keep versions, data, timeouts, and pools constant, and observe CPU, memory, platform threads, pinning, external saturation, and errors together.

When virtual threads help little or not at all

Virtual threads do not add cores to the processor. Heavy parsing, compression, cryptography, and intensive calculations remain CPU-bound.

Allowing uncontrolled concurrency on these paths can increase context switching and latency. A WebFlux chain that already uses non-blocking I/O also gains no new advantage merely by changing its executor. The Java 21 pinning and JNI cases were covered in the previous sections because they require their own diagnosis.

Checklist for safe adoption

Before enabling them in production:

  1. Confirm that the path is Spring MVC dominated by waits on blocking I/O, and record the baseline.
  2. For a new adoption, use a compatible Spring Boot line together with Java 25; if you remain on Java 21, include pinning in the test plan.
  3. Enable spring.threads.virtual.enabled=true in staging and confirm Thread.currentThread().isVirtual().
  4. Generate load below and above the current limit with a representative endpoint, not only with Thread.sleep.
  5. Compare req/s, p95, p99, errors, and CPU alongside pools and saturation of external services.
  6. Use JFR to look for jdk.VirtualThreadPinned, and keep timeouts and concurrency limits in place.
  7. Adopt only if the result is repeatable and the operational complexity remains acceptable.

Official sources

The behavior described here is based on official documentation and proposals:

Next step

DevDojo's recommendation in 2026 is straightforward: for a Spring MVC application dominated by blocking I/O, test virtual threads on a maintained Spring Boot version compatible with Java 25 before considering a reactive migration. Keep the flag only when repeated tests with real dependencies improve throughput or tail latency. For a CPU-bound, already reactive, or low-concurrency workload, do not enable it merely because the option exists.

javaspring-bootvirtual-threadsperformance

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

Knowledge only counts when it becomes practice.

Go back to the article, run the examples, and share what you learned.

Explore more articles