Files
spring-async-demo/virtual-threads-benchmark/docs/04-scoped-value-and-checklist.md
T
asmhatre f506b01389 Add virtual-threads-benchmark: re-run Spring Boot 4.1 / JDK 25 benchmarks, JEP 491 pinning fixed
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.
2026-09-18 08:52:59 +00:00

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:

  1. JEP 506 (rel=nofollow) finalized ScopedValue in JDK 25 -- it's no longer a preview feature and needs no --enable-preview flag. Confirmed by compiling and running a ScopedValue example on this repo's JDK 25 with no preview flags at all.
  2. ScopedValue.runWhere(...) does not exist on the finalized API. javap -p java.lang.ScopedValue against this JDK's runtime classes shows no such method. The finalized shape is ScopedValue.where(scopedValue, value).run(runnable) (a Carrier returned by where, 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 -- synchronized still pins on JDK 21-23.

  • Delete -Djdk.tracePinnedThreads=full from your flags on JDK 24+; it's inert. Use the jdk.VirtualThreadPinned JFR 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 ScopedValue over ThreadLocal for new code holding per-request context, using the finalized JDK 25 API (ScopedValue.where(...).run(...), not the old preview runWhere).

  • Never pool virtual threads.

  • Further reading: JEP 444 (rel=nofollow), JEP 491 (rel=nofollow), JEP 506 (rel=nofollow).