Java 25 · Spring Boot
Java virtual threads in Spring Boot: pinning, pools and production pitfalls
Run Java virtual threads in Spring Boot production: pinning and the Java 24 fix, JFR detection, connection pool limits, scoped values and structured concurrency.
Virtual threads make blocking code cheap: a Spring Boot service can handle thousands of concurrent requests with plain, readable, blocking JDBC and HTTP calls. But switching them on doesn’t remove limits — it moves them. This guide covers what actually changes, the pinning problem and how Java 24 fixed most of it, and the production pitfalls teams hit after the flag is flipped.
What virtual threads change
A platform thread maps one-to-one to an OS thread and costs around a megabyte of stack, so servers cap them in a pool (Tomcat’s default is 200). A virtual thread is a JVM object: when it blocks on I/O, the JVM unmounts it from its carrier thread and parks its stack on the heap. A small pool of carriers — by default one per CPU core — runs millions of virtual threads.
The result: thread-per-request code scales like reactive code for I/O-bound workloads, without Mono, Flux or callback chains. Virtual threads do not make CPU-bound work faster — there are still only as many carriers as cores.
Turning them on in Spring Boot
On Spring Boot 3.2 or later with Java 21+, one property does it:
spring.threads.virtual.enabled=true
# Needed if the app has no web server and only runs @Scheduled work:
# with virtual threads, nothing else keeps the JVM alive.
spring.main.keep-alive=trueTomcat and Jetty then serve each request on a new virtual thread, and the auto-configured applicationTaskExecutor (used by @Async) and task scheduler switch to virtual threads too. For your own executors, never pool virtual threads — create one per task:
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Quote>> quotes = suppliers.stream()
.map(s -> executor.submit(() -> s.fetchQuote(order)))
.toList();
// each fetchQuote blocks on HTTP; carriers stay free while it waits
}Pinning: what changed in Java 24
A virtual thread is pinned when it blocks but can’t unmount, so it holds its carrier hostage. With only a handful of carriers, a few pinned threads can stall the whole application — the classic symptom is a service that freezes under load with low CPU.
On Java 21–23, blocking inside a synchronized block or method pins the thread. That bites hard because many libraries (older JDBC drivers, connection pools, HTTP clients, logging appenders) use synchronized around I/O. The standard workaround was replacing it with ReentrantLock:
private final ReentrantLock lock = new ReentrantLock();
void refreshToken() {
lock.lock(); // virtual-thread friendly on every Java version
try {
token = authClient.fetchToken(); // blocking I/O while holding the lock
} finally {
lock.unlock();
}
}Java 24 (JEP 491) reimplemented monitors so virtual threads can unmount while holding or waiting for a synchronized lock. On Java 24 and later — including the Java 25 LTS — that whole class of pinning is gone. Two cases still pin: blocking inside native code (JNI or foreign function calls) and blocking inside a class initializer. If you are on Java 21 LTS, plan the move to 25; it is the single biggest virtual thread improvement since launch.
Detecting pinning and stalls
JDK Flight Recorder emits a jdk.VirtualThreadPinned event when a virtual thread blocks while pinned for longer than the threshold (20 ms by default). Record in production with low overhead:
# Start a recording on a running JVM
jcmd <pid> JFR.start name=vt duration=5m filename=/tmp/vt.jfr
# Print pinning events with their stack traces
jfr print --events jdk.VirtualThreadPinned /tmp/vt.jfrClassic jstack output doesn’t show virtual threads. Use the JSON thread dump instead, which groups virtual threads by their executor:
jcmd <pid> Thread.dump_to_file -format=json /tmp/threads.jsonThe old -Djdk.tracePinnedThreads flag was removed in Java 24; use the JFR event on all versions.
The bottleneck moves downstream
With a 200-thread pool, Tomcat itself limited how hard you could hit the database. With virtual threads there is no such limit, so the next constraint takes the load:
- Connection pools. With HikariCP at
maximumPoolSize=10, 5,000 concurrent requests means 4,990 virtual threads waiting ingetConnection(). AfterconnectionTimeout(30 s by default) they fail withSQLTransientConnectionException. Size the pool for what the database can take, and keep the timeout short so callers fail fast. - Downstream APIs. A partner API rated for 50 concurrent calls will now receive thousands. Limit concurrency explicitly — a semaphore is the virtual-thread-native tool, because blocking on it is cheap:
@Component
class PartnerClient {
private final Semaphore permits = new Semaphore(50); // the partner's limit
private final RestClient rest;
PartnerClient(RestClient.Builder builder) {
this.rest = builder.baseUrl("https://partner.example.com").build();
}
Quote fetch(String sku) throws InterruptedException {
permits.acquire();
try {
return rest.get().uri("/quotes/{sku}", sku).retrieve().body(Quote.class);
} finally {
permits.release();
}
}
}- Memory. Each in-flight request now holds its objects until it completes. Higher concurrency means more live heap — load-test with realistic payloads before raising traffic.
ThreadLocals and scoped values
ThreadLocal still works on virtual threads, but the pattern of caching expensive objects per thread (formatters, buffers, parsers) breaks: with a new thread per task, the cache is never reused, and with a million threads it multiplies memory. Make such objects shared and thread-safe, or create them per use.
For passing request context (tenant, user, trace data), Java 25 finalised scoped values (JEP 506) — immutable, bounded to a call, and cheap to inherit:
static final ScopedValue<String> TENANT = ScopedValue.newInstance();
void handle(Request req) {
ScopedValue.where(TENANT, req.tenantId()).run(() -> service.process(req));
}
// anywhere below in the call stack:
String tenant = TENANT.get();Spring’s own request context and MDC-based logging still use ThreadLocal and work correctly with virtual threads; you don’t have to rewrite them.
Structured concurrency
Fanning out to several services per request is where virtual threads shine. StructuredTaskScope makes the fan-out safe: if one subtask fails, the others are cancelled, and no thread outlives the scope. It is still a preview API in Java 25 (JEP 505), so it needs --enable-preview:
CustomerView load(String id) throws InterruptedException {
try (var scope = StructuredTaskScope.open()) {
var customer = scope.fork(() -> customers.find(id));
var orders = scope.fork(() -> orders.recent(id));
var credit = scope.fork(() -> creditApi.score(id));
scope.join(); // waits for all; throws if any failed, cancelling the rest
return new CustomerView(customer.get(), orders.get(), credit.get());
}
}If you can’t use preview features in production, the newVirtualThreadPerTaskExecutor() pattern above gives you the concurrency; you just handle cancellation yourself.
Rollout checklist
- Run on Java 24+ (ideally the Java 25 LTS) so
synchronizedno longer pins. - Enable
spring.threads.virtual.enabledin one service first. - Load-test at several times normal concurrency and record JFR during the test.
- Check for
jdk.VirtualThreadPinnedevents, connection pool wait times and heap growth. - Add explicit concurrency limits for every downstream with a capacity limit.
- Remove per-thread caches; use scoped values for new context-passing code.
- Keep CPU-heavy work on a bounded platform-thread pool.