Companion module for the rewritten post 'Virtual Threads on Spring Boot 4.1: The Benchmarks, Re-Run, and the Pinning Advice That Expired', retitled and re-benchmarked on Boot 4.1.1 / JDK 25.0.4.1 (the original post was written against Boot 3.4 / JDK 21). Covers: I/O-bound and CPU-bound throughput (platform vs virtual threads, including a JIT-warmup benchmarking bug this build caught and fixed), JEP 491 proof that synchronized no longer pins a virtual thread's carrier across a blocking call as of JDK 24 (obsoleting the old avoid-synchronized advice), proof that -Djdk.tracePinnedThreads=full is inert on JDK 25, and JEP 506's finalized ScopedValue API (JDK 25 GA, no --enable-preview, and a different shape than the old preview API). Kept as its own module rather than a new top-level repository, alongside the existing async/ module, which already has a stronger dual-JDK JEP 491 proof that this module's docs cross-link to instead of duplicating.
4.4 KiB
4. ScopedValue, the JDBC driver advice, and a production checklist
Previous: 03-pinning-diagnosis.md | README
ScopedValue is finalized in JDK 25, with a different API than the preview version
The old post's ThreadLocal gotcha recommended ScopedValue as "the Project Loom-native
replacement," using ScopedValue.runWhere(CTX, value, () -> { ... }). Two things changed:
- JEP 506 (
rel=nofollow) finalizedScopedValuein JDK 25 -- it's no longer a preview feature and needs no--enable-previewflag. 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 isScopedValue.where(scopedValue, value).run(runnable)(aCarrierreturned bywhere, with.run(...)or.call(...)on it):
private static final ScopedValue<ExpensiveContext> CTX = ScopedValue.newInstance();
ScopedValue.where(CTX, new ExpensiveContext()).run(() -> {
// CTX is accessible in this scope and any method called from here
processRequest();
// automatically cleared when the scope exits
});
Code copied from a pre-JDK-25 ScopedValue article using runWhere will not compile on this
JDK. This is a small API surface change, but it's the kind of thing javap catches in seconds
and a fabricated-from-memory snippet would not.
The JDBC driver pinning advice needs the same correction as the general case
The old post's second gotcha was "many JDBC drivers use synchronized internally in their
socket I/O paths, so every DB call becomes a pinning event" -- true through JDK 23, and the
reason MySQL Connector/J and older PostgreSQL JDBC driver versions got singled out for their
internal locking. Chapter 3 already established that ordinary
synchronized-then-block no longer pins as of JDK 24. That correction applies here without
exception: if a driver's pinning was caused by 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 narrower native-callback pinning case from chapter 3 is the only mechanism left that could
still affect a driver, and it needs the driver to call native code that calls back into
blocking Java -- unusual for a JDBC driver's I/O path. Treat "upgrade your JDBC driver to fix
pinning" as obsolete advice on JDK 24+; verify with jdk.VirtualThreadPinned JFR events on
your actual driver and JDK version rather than assuming either the old warning or this
correction applies to your exact setup.
Never pool virtual threads -- unchanged
Nothing about JEP 491 changes this. 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 (LoadBenchmarkTest.fireConcurrent) uses exactly this pattern
for its HttpClient executor.
Production checklist for Boot 4.1 / JDK 25
-
Enable with
spring.threads.virtual.enabled=true-- one property, unchanged since Boot 3.2. -
Confirm you're actually on JDK 24+ before relying on JEP 491 --
synchronizedstill pins on JDK 21-23. -
Delete
-Djdk.tracePinnedThreads=fullfrom your flags on JDK 24+; it's inert. Use thejdk.VirtualThreadPinnedJFR event instead. -
Profile I/O-bound endpoints for the real win; don't expect anything from CPU-bound work -- chapter 2 measured this directly, including a benchmarking bug that briefly suggested otherwise.
-
If you still see pinning on JDK 24+, suspect native-callback code paths (JNI, Foreign Function & Memory API), not plain
synchronized. -
Prefer
ScopedValueoverThreadLocalfor new code holding per-request context, using the finalized JDK 25 API (ScopedValue.where(...).run(...), not the old previewrunWhere). -
Never pool virtual threads.
-
Further reading: JEP 444 (
rel=nofollow), JEP 491 (rel=nofollow), JEP 506 (rel=nofollow).