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.
spring.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=trueWhat 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
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;
class ResilienceConfig {
}
record Quote(String currency, String rate) {
}
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.
(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.
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.
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.
# 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