Files
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

81 lines
4.4 KiB
Markdown

# 4. ScopedValue, the JDBC driver advice, and a production checklist
[Previous: 03-pinning-diagnosis.md](03-pinning-diagnosis.md) | [README](../README.md)
## 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](https://openjdk.org/jeps/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):
```java
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](03-pinning-diagnosis.md) 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:
```java
// 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](02-benchmark-methodology.md) 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](https://openjdk.org/jeps/444) (`rel=nofollow`), [JEP 491](https://openjdk.org/jeps/491) (`rel=nofollow`), [JEP 506](https://openjdk.org/jeps/506) (`rel=nofollow`).