Virtual Threads in Production

Turn on virtual threads with one property, and know which limits move from the thread pool to your database pool and downstream services.

Syntaxspring.threads.virtual.enabled=true spring.main.keep-alive=true @EnableResilientMethods on a @Configuration class @ConcurrencyLimit(20) public Result callPartner() { ... }

A traditional servlet application handles each request on a platform thread from a pool, 200 threads in Tomcat by default. While a request waits on the database or a remote API, its thread is blocked and unavailable. Virtual threads are cheap threads managed by the JVM: a blocked virtual thread releases its carrier, so you can have tens of thousands of requests waiting at once while writing ordinary blocking code.

spring.threads.virtual.enabled=true

What the property changes

Spring Boot then serves requests on virtual threads in Tomcat and Jetty, uses a virtual-thread SimpleAsyncTaskExecutor for @Async and the application task executor, and switches scheduling and many messaging listener containers to virtual threads. Properties such as server.tomcat.threads.max and spring.task.execution.pool.* no longer have an effect, because there is no pool.

Virtual threads are daemon threads. An application whose only work is scheduled tasks, with no web server, can exit right after startup. spring.main.keep-alive=true keeps the JVM running.

The limit moves, it does not vanish

The 200-thread pool was also a throttle. Remove it and 5,000 concurrent requests all reach the next bottleneck at once, which is usually the database connection pool. HikariCP defaults to 10 connections, so 4,990 requests wait for a connection, and after connection-timeout (30 seconds by default) they fail. Size the pool to what the database can take, not to the request count, and set a shorter connection timeout so overload fails fast instead of stacking up.

The same applies to remote services. A partner API that coped with your 200 threads may not cope with 5,000 parallel calls. Limit concurrency explicitly at those calls: Spring Framework 7's @ConcurrencyLimit does this on a method, enabled with @EnableResilientMethods.

Pinning and thread locals

A virtual thread that cannot unmount while blocked is pinned to its carrier. On Java 21 that happened inside synchronized blocks, which is why older advice says to replace them with ReentrantLock. Since JDK 24 (JEP 491) synchronized no longer pins, so on Java 25 the remaining cases are mostly native calls. The JFR event jdk.VirtualThreadPinned shows any that remain.

Thread locals still work, but virtual threads are not reused, so caching an expensive object per thread (a formatter, a parser) creates one per request. Use immutable, thread-safe alternatives such as DateTimeFormatter shared as a constant.

When it helps

Virtual threads help I/O-bound services with many concurrent requests that spend most of their time waiting. They do nothing for CPU-bound work such as image processing, where the number of cores is the limit. Measure before and after with the same load test, watching connection pool wait time as closely as latency.

Example

Example · java
import org.springframework.context.annotation.Configuration;
import org.springframework.resilience.annotation.ConcurrencyLimit;
import org.springframework.resilience.annotation.EnableResilientMethods;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;

@Configuration
@EnableResilientMethods
class ResilienceConfig {
}

record Quote(String currency, String rate) {
}

@Service
public class FxRateClient {

    private final RestClient http;

    FxRateClient(RestClient.Builder builder) {
        this.http = builder.baseUrl("https://fx.example.com").build();
    }

    // With virtual threads thousands of requests can arrive here at once.
    // Only 20 calls run against the partner API at a time; the rest wait.
    @ConcurrencyLimit(20)
    public Quote quote(String currency) {
        return http.get()
                .uri("/rates/{currency}", currency)
                .retrieve()
                .body(Quote.class);
    }
}

When to use it

  • Serve an API that spends most of each request waiting on three downstream HTTP services, without tuning thread pools.
  • Protect a rate-limited partner API from a sudden surge of parallel calls after switching to virtual threads.
  • Diagnose rising latency after enabling virtual threads by spotting requests waiting for database connections.

More examples

Production settings with virtual threads

Watch the <code>hikaricp.connections.pending</code> metric after the switch. If it climbs under load, requests are queueing for connections; adding more connections only helps if the database itself has headroom.

Example · properties
spring.threads.virtual.enabled=true

# The database pool is now the real limit: size it for the database, not the traffic.
spring.datasource.hikari.maximum-pool-size=30
# Fail fast under overload instead of queueing requests for 30 seconds.
# Plain milliseconds: Hikari's setter takes a long, so "3s" would fail to bind.
spring.datasource.hikari.connection-timeout=3000

# Only needed when nothing else keeps the JVM alive, such as a scheduler-only worker.
spring.main.keep-alive=true

Checking which thread serves a request

A quick check in a test environment that the property took effect: with virtual threads enabled <code>virtual</code> is <code>true</code>. Remove the endpoint or keep it behind internal routing; it gives nothing useful to the public.

Example · java
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ThreadInfoController {

    @GetMapping("/internal/thread")
    public Map<String, Object> thread() {
        Thread current = Thread.currentThread();
        return Map.of(
                "virtual", current.isVirtual(),
                "name", current.toString());
    }
}

Finding pinned threads with JFR

Each <code>jdk.VirtualThreadPinned</code> event includes a stack trace pointing at the code that held the carrier thread. On Java 25 these are rare; when they appear they usually come from a native library. The JSON thread dump groups virtual threads, which an ordinary thread dump does not show.

Example · bash
# Record pinning events while running a load test.
java -XX:StartFlightRecording=filename=pinning.jfr,settings=profile -jar target/orders.jar &

# ...run the load test, stop the application, then list the events:
jfr print --events jdk.VirtualThreadPinned pinning.jfr

# A thread dump that includes virtual threads, as JSON.
jcmd <pid> Thread.dump_to_file -format=json threads.json

Discussion

  • Be the first to comment on this lesson.