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.
81 lines
4.4 KiB
Markdown
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`).
|