If you enabled virtual threads on Spring Boot 3.4 sometime in the last year, you probably also copied in the standard advice that came with every article about them: avoid synchronized, because a virtual thread that blocks while holding a monitor pins its carrier thread, and watch for it with -Djdk.tracePinnedThreads=full. That advice was correct when it was written. On Spring Boot 4.1 and JDK 25, most of it is no longer true, and the flag that used to prove it does nothing at all. This is the rewrite: the same I/O-bound vs. CPU-bound benchmark, re-run for real on Boot 4.1.1 and JDK 25, plus the pinning story corrected against the JDK that actually ships today.
Versions this was verified against. Spring Boot 4.1.1 (GA, 20 August 2026), Spring Framework 7.0.9, Eclipse Temurin JDK 25.0.4.1 LTS — which includes JEP 491 (GA in JDK 24) and JEP 506 (GA in JDK 25). Companion code: virtual-threads-benchmark — every number and transcript below is underdocs/output/, regenerated byscripts/run-all.sh.
The problem: bounded thread pools cap your concurrency, not your hardware
A classic Spring MVC application handles each request on a platform thread borrowed from a bounded pool — Tomcat’s default is 200. Each platform thread maps to a real OS thread, costing roughly 1–2MB of stack and a kernel-mode context switch to schedule. That’s fine until your endpoint spends most of its time waiting on something else — a database, a downstream HTTP call — because the pool doesn’t care that the thread is idle while it waits. It’s still occupied. Past 200 concurrent in-flight requests, the 201st queues, and tail latency stops looking like your service’s actual response time and starts looking like a queueing simulation.
Virtual threads, finalized in Java 21 via JEP 444, are JVM-managed threads that get created by the millions and unmount from their underlying OS “carrier” thread whenever they block on I/O — freeing the carrier to run other virtual threads in the meantime.
The code you write doesn’t change — the same blocking, synchronous style you already use. What changes is what a “thread” costs while it’s waiting.
Enabling it: still one property
Unchanged since Boot 3.2, still the whole configuration surface for most applications on Boot 4.1:
spring:
threads:
virtual:
enabled: true
(application.yml) Confirmed against a real running embedded server, not read off the reference docs:
enabled=false -> Thread: Thread[#9742,http-nio-auto-5-exec-1,5,main] | Virtual: false
enabled=true -> Thread: VirtualThread[#9769,tomcat-handler-0]/runnable@ForkJoinPool-1-worker-1 | Virtual: true
(00-thread-type-confirmation.txt, from ThreadTypeTest) Setting spring.threads.virtual.enabled=true makes Boot spawn a new virtual thread per HTTP request instead of borrowing from Tomcat’s bounded pool, and switches @Async methods and scheduled tasks over to a virtual-thread executor as well. Requires JDK 21+; this repo runs it on JDK 25.
If you’re setting this property programmatically in a test — not `application.yml` — watch where you set it.SpringApplicationBuilder.properties(...)binds to Spring’sdefaultPropertiessource, which has the lowest precedence of any property source Spring Boot recognizes, lower than an explicit default already inapplication.yml. This repo’s own benchmark below silently ran both its “platform” and “virtual” scenarios on platform threads the first time, for exactly this reason, until the property was passed as a command-line-style argument torun(...)instead. See docs/01-enabling-virtual-threads.md.
The benchmark, re-run
The version of this post it replaces benchmarked Boot 3.4.0 / JDK 21.0.3 with wrk on a 4-core/8GB box. This rewrite runs on a 2 vCPU sandbox with no wrk available, so rather than copy the old numbers forward next to a changed version string, this repo builds its own concurrent load generator — a java.net.http.HttpClient backed by a virtual-thread executor, used only as the client — and reports what actually happened on this run, on this hardware, labeled honestly as such. The physics doesn’t change with the box size; the absolute numbers do.
I/O-bound: 600 concurrent requests, 300ms simulated downstream call
platform threads : total=600 success=600 wall=1325ms p50=733ms p99=1201ms
virtual threads : total=600 success=600 wall=1042ms p50=658ms p99=720ms
(01-io-bound-benchmark.txt, from LoadBenchmarkTest) 600 concurrent requests against Tomcat’s default 200-thread pool queue in roughly three sequential batches, each paying the full 300ms — which is why the platform-thread wall time lands near 3×300ms plus scheduling overhead. Virtual threads unmount for the duration of the sleep and don’t queue behind a fixed pool size at all. The shape matches the original 2025 benchmark; these numbers are this sandbox’s own, not copied from a bigger machine.
CPU-bound: 60 concurrent requests, 20,000× SHA-256 per request
This is the scenario worth reading slowly, because the first version of this measurement was wrong, and finding out why is a better lesson than the number itself.
First cut of this benchmark, no warm-up round: virtual threads finished a CPU-bound endpoint 4× faster than platform threads. That’s not physically possible — a CPU-bound virtual thread never yields, so it competes for the same physical cores a platform thread would, with no structural advantage either way. The cause:MessageDigest.digestand the servlet dispatch path get JIT-compiled per class, not per Spring context, and this benchmark starts a freshApplicationContextfor each scenario inside the same JVM. Platform threads always ran first; by the time the virtual-thread scenario ran, the hot loop was already JIT-warmed. A benchmarking artifact, not a thread-model result — caught because the number was implausible, not by reading the code more carefully.
Fix: an untimed warm-up round through the identical code path before every timed measurement, for both scenarios. After the fix, at a concurrency of 60 (deliberately above this sandbox’s 2 vCPUs, forcing genuine core-bound saturation for both models):
platform threads : total=60 success=60 wall=208ms p50=130ms p99=202ms
virtual threads : total=60 success=60 wall=201ms p50=187ms p99=196ms
(02-cpu-bound-benchmark.txt) Close, with the two trading the lead by a few percent across repeated runs. That’s the expected result for CPU-bound work — consistent with every virtual threads article’s claim that there’s no meaningful benefit here, but arrived at by running it, catching a real measurement bug, fixing the methodology, and re-running it, rather than assuming the claim didn’t need checking. Full methodology, including the warm-up fix in context: docs/02-benchmark-methodology.md.
- If your own CPU-bound benchmark shows virtual threads winning decisively, check your warm-up before you check your thread model.
- Match concurrency to something past your real platform-thread pool size for the I/O case, and past your core count for the CPU case — otherwise neither test forces the behaviour you’re trying to observe.
The pinning advice that expired
Every pre-2025 virtual threads article — including the version of this post it replaces — says some version of: a virtual thread that enters a synchronized block and then blocks inside it cannot unmount; it pins its carrier for the whole operation. That was true through JDK 23. JEP 491, “Synchronize Virtual Threads without Pinning”, shipped GA in JDK 24 and changed it: ordinary synchronized blocks and methods, and Object.wait(), no longer pin. This repo runs JDK 25, so it inherits that behaviour, and the old advice is now wrong for anyone on JDK 24+.
PinningJep491Test starts 60 virtual threads, each entering synchronized on its own distinct lock object — so no thread ever contends with another for the lock itself, and any serialization observed is carrier pinning, not ordinary lock contention — then sleeping 250ms while still holding it:
availableProcessors (default virtual-thread carrier pool size) = 2
virtual threads = 60, each holds a DISTINCT monitor for 250ms
predicted wall time IF PINNED (pre-JDK-24 behaviour): ~7500ms
predicted wall time IF NOT PINNED (JDK 24+ behaviour): ~250ms, independent of carrier count
actual wall time: 251ms
verdict: NOT PINNED -- matches JEP 491's documented JDK 24+ behaviour
(03a-pinning-jep491-proof.txt, from PinningJep491Test) On JDK 23 or earlier this would serialize into roughly 30 sequential batches on a 2-carrier pool — about 7.5 seconds. It took 251ms. Not “a bit better” — the entire pinning cost gone for this pattern.
A stronger version of the same proof already exists on this blog: spring-async-demo’s pinning chapter, companion to @Async in Spring Boot 4, runs the identical class file on two real JDKs — 21.0.12.1 and 25.0.4.1 — and shows the guarded run going from 4806ms to 301ms. This repo only had JDK 25 available to test against; that one has the direct before/after on two real installs.
The diagnostic flag every article tells you to use no longer does anything
-Djdk.tracePinnedThreads=full used to be the standard way to find pinning: pre-JDK-24, the JVM printed a stack trace to stdout every time a virtual thread pinned. JEP 491 removed the property along with most of the pinning it used to report on. scripts/check-trace-pinned-threads-removed.sh runs the same synchronized-then-sleep scenario twice, once with the flag and once without:
-- without -Djdk.tracePinnedThreads=full --
java.version=25.0.4.1
jdk.tracePinnedThreads=null
Running a virtual thread that holds a monitor across Thread.sleep(200)...
Done. If jdk.tracePinnedThreads still worked on this JDK, a pinned-thread stack trace would have printed above while the virtual thread was inside doWorkHoldingMonitor.
-- with -Djdk.tracePinnedThreads=full --
java.version=25.0.4.1
jdk.tracePinnedThreads=full
Running a virtual thread that holds a monitor across Thread.sleep(200)...
Done. If jdk.tracePinnedThreads still worked on this JDK, a pinned-thread stack trace would have printed above while the virtual thread was inside doWorkHoldingMonitor.
RESULT: no pinned-thread stack trace was printed by either run -- the flag is inert on this JDK, consistent with JEP 491
(03b-trace-pinned-threads-removed.txt, produced by check-trace-pinned-threads-removed.sh) Setting the property has no effect at all on JDK 25 — not an error, not a deprecation warning, just silence either way. A team that kept it in their JVM flags through a JDK upgrade gets zero signal from it going forward and might not notice.
What replaces it: JEP 491 kept the jdk.VirtualThreadPinned JFR event for the pinning that’s still possible, now reporting both the pinning reason and the carrier thread’s identity. The remaining trigger, per the JEP itself, is narrower than the old blanket advice: a virtual thread that calls native code (a native method, or the Foreign Function & Memory API) which itself calls back into Java code that blocks or synchronizes. Reproducing that needs JNI, out of scope for a Spring Boot demo — treat it as still real, still worth the JFR event, and much rarer in ordinary application code than “avoid all synchronized” implied.
Diagnosing a suspected pinning problem on JDK 24+? Delete-Djdk.tracePinnedThreads=fullfrom your flags — it does nothing — and capture thejdk.VirtualThreadPinnedJFR event instead (jcmd <pid> JFR.startor-XX:StartFlightRecordingat startup). If your JDBC driver’s documentation still warns aboutsynchronized-based pinning from before it was tested against JDK 24, verify against your actual JDK version rather than trusting the warning as written.
- JEP 491 in full, including the exact wording on what still pins: openjdk.org/jeps/491
- The dual-JDK before/after: spring-async-demo/async/docs/08-virtual-threads-and-pinning.md
The JDBC driver warning needs the same correction
The old post singled out MySQL Connector/J before 9.0 and older PostgreSQL JDBC driver versions for using synchronized internally in their socket I/O paths, making every database call a pinning event. That correction from the section above applies here without exception: if a driver’s pinning came from a plain synchronized block around socket I/O — the common case — JEP 491 fixes it for free on JDK 24+, with no driver upgrade required. The only mechanism left that could still pin a driver is the narrower native-callback case, unusual for a JDBC driver’s I/O path. Treat “upgrade your JDBC driver to fix pinning” as obsolete advice on JDK 24+, and verify with the jdk.VirtualThreadPinned JFR event against your actual driver and JDK rather than assuming either the old warning or this correction applies to your exact setup.
ThreadLocal, and ScopedValue’s finalized (and changed) API
Virtual threads aren’t pooled — they’re created per-task and discarded when done, so a heavy object stashed in ThreadLocal lives for the entire request rather than being cleaned up when a pooled thread is reassigned. At high concurrency that accumulates. The old post recommended ScopedValue as the replacement, using ScopedValue.runWhere(CTX, value, () -> {...}). Two things changed since:
- JEP 506 finalized
ScopedValuein JDK 25 — no longer preview, no--enable-previewflag needed. Confirmed by compiling and running aScopedValueexample on this repo’s JDK 25 with no preview flags at all. ScopedValue.runWhere(...)does not exist on the finalized API.javap -p java.lang.ScopedValueagainst this JDK’s runtime classes shows no such method. The finalized shape returns aCarrierfromwhere(...), with.run(...)or.call(...)on it:
private static final ScopedValue<ExpensiveContext> CTX = ScopedValue.newInstance();
public static String runWithContext(String requestId) {
return ScopedValue.where(CTX, new ExpensiveContext(requestId))
.call(ScopedValueDemo::processRequest);
// CTX is automatically cleared once this scope exits -- no cleanup needed
}
(ScopedValueDemo.java, verified against javap -p java.lang.ScopedValue on JDK 25) Real output, no preview flag:
ScopedValue.where(CTX, ctx).call(ScopedValueDemo::processRequest) -> handled req-42
no --enable-preview flag used on this run
(03c-scoped-value-finalized.txt, from ScopedValueTest) Code copied from a pre-JDK-25 ScopedValue article using runWhere will not compile on this JDK — a small API surface change, but the kind javap catches in seconds and a snippet written from memory would not.
Never pool virtual threads
Unchanged. Virtual threads are cheap to create; pooling them adds synchronization overhead for no benefit and defeats the design:
// WRONG: pooling virtual threads
ExecutorService pool = Executors.newFixedThreadPool(100, Thread.ofVirtual().factory());
// RIGHT: unbounded virtual-thread-per-task executor
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
This repo’s own load generator uses exactly this pattern for its HttpClient executor — see fireConcurrent() in LoadBenchmarkTest.java, quoted above for the I/O and CPU benchmarks.
Production checklist for Boot 4.1 / JDK 25
| Item | Status on JDK 25 |
|---|---|
| Enable | spring.threads.virtual.enabled=true — one property, unchanged since Boot 3.2 |
| Are you actually on JDK 24+? | JEP 491 needs it — synchronized still pins on JDK 21–23 |
-Djdk.tracePinnedThreads=full | Delete it on JDK 24+ — inert. Use the jdk.VirtualThreadPinned JFR event. |
| Expected win | I/O-bound endpoints only — nothing for CPU-bound work, measured directly above |
| Still-possible pinning | Native-callback code paths (JNI, FFM API), not plain synchronized |
| Per-request context | Prefer ScopedValue over ThreadLocal, using the finalized JDK 25 API (where(...).run(...), not runWhere) |
| Executors | Never pool virtual threads |
Frequently Asked Questions
Do I need to change my existing controllers or services? No. @RestController methods, @Service beans, and @Async methods work unmodified — the switch is infrastructure-level.
I’m still on JDK 21 — does any of the pinning correction apply to me? No. JEP 491 needs JDK 24+. On JDK 21–23, the old advice — avoid synchronized across blocking calls, watch for pinning with tracePinnedThreads — is still your advice, unchanged.
Will virtual threads replace WebFlux? For most REST APIs, largely yes — they close the performance gap while removing the reactive learning curve. For a direct three-way measurement against WebFlux specifically, see Virtual Threads vs Reactive vs Platform Threads.
AI Prompts for Virtual Threads on Boot 4.1
Audit for Obsolete Pinning Advice
Here is my Spring Boot service code and JVM flags: [paste here]. I’m running JDK [version]. Flag any use of -Djdk.tracePinnedThreads, any comments warning about synchronized-based pinning, and any ThreadLocal usage that could move to ScopedValue. Tell me which of these are still relevant on my actual JDK version and which are obsolete.
What it does: Catches advice that stopped applying once JEP 491 shipped, scoped to the JDK version actually in use.
Decide Whether Virtual Threads Help This Endpoint
Here is my controller method: [paste here]. Classify its workload as I/O-bound, CPU-bound, or mixed, and tell me whether enabling spring.threads.virtual.enabled would meaningfully help it, based on where it actually spends time.
What it does: Turns the I/O-vs-CPU distinction from this post into a per-endpoint judgment on a real codebase.
Further reading
- virtual-threads-benchmark — companion repository, every transcript quoted above, regenerated by
scripts/run-all.sh - spring-async-demo’s dual-JDK pinning proof, companion to @Async in Spring Boot 4
- Virtual Threads vs Reactive (WebFlux) vs Platform Threads
- JEP 444: Virtual Threads
- JEP 491: Synchronize Virtual Threads without Pinning
- JEP 506: Scoped Values
No Comments yet!