Key Takeaways
- JDK 24's JEP 491 removes the monitor-related carrier pinning that made Java 21 virtual-thread rollouts risky for synchronized blocking code. Residual pinning still exists around native frames, class loading, and local file I/O on Linux, so the JDK Flight Recorder (JFR) jdk.VirtualThreadPinned event remains part of the rollout checklist.
- The main production risk has shifted from carrier-thread starvation to downstream-resource exhaustion. Once virtual threads remove the servlet-thread ceiling, connection pools, rate limits, file descriptors, and downstream services become the real concurrency boundaries.
- ThreadLocal caching silently stops caching under virtual threads. In the companion benchmark, a ThreadLocal.withInitial() cache initialized two hundred times under platform threads and 443,267 times under virtual threads for the same workload, a 2,216x ratio that showed up first as unexplained GC pressure.
- Scoped Values, finalized in JDK 25 via JEP 506, are the right replacement for application-owned request context. They avoid InheritableThreadLocal propagation surprises and propagate automatically into StructuredTaskScope child tasks. One API change at finalization: ScopedValue.orElse(null) is no longer permitted.
- Virtual threads simplify blocking request-response services but do not replace reactive backpressure. Spring MVC plus virtual threads is a strong default for blocking I/O services; Spring WebFlux remains the right choice for streaming, SSE, WebSockets, and backpressure-sensitive systems.
Introduction
Enabling virtual threads in a Spring Boot 3 service is a one-line configuration change. What happens in the week after that change varies considerably depending on how much synchronized code lives in your dependency stack, whether your usage of the ThreadLocal class assumes thread reuse, and whether your connection pool is sized for dozens of OS threads or tens of thousands of virtual ones.
We evaluated virtual-thread adoption for high-throughput, I/O-intensive services where the bulk of wall-clock time is spent waiting on downstream APIs and database queries rather than computing. The early observations matched what the JEP 444, Virtual Threads, had predicted: I/O-bound endpoints scaled further on the same heap and thread counts that previously required careful pool tuning stopped being a constraint.
What also emerged were failure modes the migration guides did not prominently flag: silent caching failures that don't throw, context propagation that works in development and disappears under load, and a downstream-resource bottleneck shape that no JDK upgrade can fix on its own. This article is a tour of those observations, what they mean operationally, and what to do about them.
The numbers and dashboards cited throughout come from a public benchmark that captures the same failure modes in a controlled, reproducible form: same JVM flags, same dependencies, same host, and explicitly documented profile differences.
For the ThreadLocal scenario, the effective difference is the threading model. For the JDBC scenario, the connection-pool ceiling is intentionally varied to show where the bottleneck moves. It exists so any reader can recreate the patterns described here on their own hardware, without relying on production telemetry that cannot be shared. You can find the source code, dashboards, and raw output at this GitHub repository.
Figure 1 describes the benchmark topology. Two app instances on the same host share identical JVM configuration, identical dependencies and identical backends; only the threading-model toggle and the connection-pool size differ between profiles.
This setup keeps the environment stable while making the profile differences explicit, so the deltas can be interpreted against a controlled benchmark rather than environmental noise.
Figure 1: The benchmark topology. (Image source: created by author)
The shape and magnitude of the headline effect, captured in a controlled scenario on a c7i.2xlarge (8 vCPU, 15 GiB RAM) running Ubuntu 24.04 with Temurin 25.0.3, five hundred concurrent users for sixty seconds against an endpoint that exercises the ThreadLocal failure mode described later in this article:
| Metric | Platform Threads | Virtual Threads | Δ |
| Throughput (req/s) | 3,594 | 7,426 | +107% |
| p99 latency (ms) | 147 | 53 | −64% |
| Error rate (%) | 0.00 | 0.00 | — |
| SimpleDateFormat ThreadLocal initializations | 200 | 443,267 | 2,216× |
This endpoint is an I/O-style request path backed by a WireMock downstream with a three hundred millisecond simulated response delay rather than a throughput claim for virtual threads in general. The endpoint also carries an instrumented ThreadLocal cache so that allocation behavior is observable while the request exercises the same servlet scheduling path as a real service. Figure 2 demonstrates the side-by-side run telemetry across the benchmark window.
[Click here to expand image above to full-size]
Figure 2: Side-by-side run telemetry across the benchmark window with the platform running first and virtual running second. (Image source: created by author)
In this figure, the Throughput (top) peaks at about four thousand requests per second for platform vs. ten thousand requests per second for virtual. Furthermore, for p99 latency (second), both modes reach into the hundreds of milliseconds under load, with virtual (orange) consistently lower than platform (green) and p999 (blue) above both, as expected under sustained load. The Heap used (third) reveals a second-order effect: platform mode (green) shows a slow, smooth ramp with infrequent GC cycles, while virtual mode (yellow) shows a much more aggressive sawtooth; virtual mode allocates faster because every request rebuilds its ThreadLocal cache (the same phenomenon Figure 3 quantifies). Garbage collection (GC) pause time (bottom) confirms this: Virtual mode triggers an approximate 4.5 millisecond GC cluster during the stress run and the platform stays quiet.
The takeaway is double-edged: Virtual mode does more allocation work AND still wins on throughput and latency. GC stays under 5 milliseconds per pause even while doing more work, so it doesn't become the new bottleneck. But a production rollout should expect higher GC activity proportional to allocation pressure. Figure 3 is where that pressure comes from.
JDK 24 removed the most visible monitor-related failure mode from early virtual-thread rollouts. JDK 25, the first Long-Term Support (LTS) release to include JEP 491 and finalized Scoped Values, is the natural target for production teams choosing between JDK 21 LTS and JDK 25 LTS in 2026. What follows is a map of where the risk actually sits today, which problems are solved, and which require deliberate design decisions regardless of JDK version.
The Risk Profile Shifted, It Did Not Disappear
The dominant failure mode in JDK 21 was carrier-thread exhaustion from synchronized blocks. Virtual threads share a pool of underlying carrier threads managed by a fork-join scheduler with parallelism equal to the number of available processors. In JDK 21, a virtual thread that entered a synchronized block could not unmount from its carrier even if it then blocked on I/O. The carrier thread stayed occupied. If enough virtual threads simultaneously piled into synchronized blocks, all carriers became unavailable and the scheduler could not assign work to any remaining virtual thread. The system stalled as a result.
This was neither hypothetical nor unique to one organization. Netflix's JVM Ecosystem team documented exactly this failure across multiple Spring Boot 3 services on Java 21 with embedded Tomcat. We saw the same shape during our own JDK 21 evaluation, in services where bursts of contention on synchronized blocks in a logging or metrics dependency starved the carrier pool. The symptom was instances that stopped serving traffic while the JVM remained alive, accompanied by a persistent increase in sockets stuck in the CLOSE_WAIT state. When engineers pulled a standard thread dump, it showed an apparently idle JVM with no waiting monitors and no obvious blocking; virtual thread stacks do not appear in the output delivered by running the jstack command. Using the Thread.dump_to_file -format=json option in the jcmd command instead revealed thousands of virtual threads created but unable to be scheduled, with all carrier threads pinned inside Tomcat's connection handling code. Their write-up, Java 21 Virtual Threads – Dude, Where's My Lock?, is the clearest published account of how pinning produces a deadlock that looks like silence rather than an error.
JEP 491, Synchronize Virtual Threads without Pinning, delivered in JDK 24, rearchitected monitor ownership tracking so that virtual threads are identified by their own identity rather than their carrier's identity. Virtual threads can now unmount inside synchronized blocks, enter Object.wait(), and reacquire monitors after waking, all without pinning the carrier. JEP 491 removes the specific monitor-related pinning mechanism that was the root cause of Netflix's stall class; for most services, this means the primary adoption blocker is gone and a JDK upgrade is sufficient.
The residual pinning cases that remain are narrower and less likely to produce cascading stalls in typical application code as shown in this table:
| Scenario | JDK 21 | JDK 24+ (and 25 LTS) |
| Blocking I/O inside synchronized code | Pins carrier | No longer pins due to JEP 491 |
| Object.wait()inside synchronized code | Pins carrier | No longer pins due to JEP 491 |
| Native method frame (JNI / FFM) | Pins carrier | Still pins |
| Class loading / class initializer | Pins carrier | Still pins |
| File I/O (local disk, Linux) | Pins carrier | Still pins |
| Diagnostic option | -Djdk.tracePinnedThreads | JFR jdk.VirtualThreadPinned(default 20 ms threshold in JDK 25) |
Two of the residual cases matter in practice. Native frame pinning shows up in applications that call into native libraries with Java callbacks. The native code may hold raw stack pointers, so unmounting cannot be guaranteed as safe. For most web services this is rare, but applications using certain cryptography providers or specialized I/O libraries should verify with JFR before assuming pinning is gone. File I/O is the more common surprise. File I/O still pins on Linux. No production-ready io_uring integration exists in the JDK. Reports from JDK mailing-list discussions cite kernel-version compatibility and container-runtime support as practical constraints on doing so. Third-party Project Panama-based libraries (such as the experimental JUring) demonstrate roughly four times read throughput by binding directly to io_uring, but they are out-of-tree.
To detect residual pinning, configure the JDK Flight Recorder (JFR) to emit jdk.VirtualThreadPinned events and run your load-test suite before production rollout. The -Djdk.tracePinnedThreads system property that existed in JDK 21 was removed in JDK 24.
ThreadLocal: Two Failure Modes That Arrive Silently
Monitoring pinning is the right first step. The second problem requires an audit of your code rather than an observability configuration, which produces no exceptions, no warnings, and no obvious performance degradation until you look for it.
The ThreadLocal class was designed in 1998 for long-lived platform threads in a pool. A thread pool of two hundred threads lives for hours or days. Per-thread storage allocated once and reused thousands of times is an acceptable pattern. Virtual threads are short-lived and are not reused across requests. Each new virtual thread gets a fresh instance of a ThreadLocalMap.
Two things follow from this that are worth understanding as separate failure modes: caching that silently stops caching and an InheritableThreadLocal context that becomes null in forked children. Each is described below.
Caching That Silently Stops Caching
A common production pattern is using ThreadLocal.withInitial() to lazily allocate an expensive-to-create, non-thread-safe object once per thread and reuse it across requests. The SimpleDateFormat class is the canonical example. JDBC-adjacent objects and certain serialization instances follow the same pattern. The same pattern appears in JSON serializers, reusable byte buffers, instances of the ObjectMapper class, and any library-level cache that assumes a small, stable population of long-lived threads. With virtual threads, the allocation happens once per task. The result is discarded when the task completes. The ThreadLocal appears to work correctly, never throwing. It just computes the value fresh on every request.
Both failure modes surfaced during our virtual-thread evaluation. The cache-miss case appeared first as elevated GC pressure and steadily climbing allocation rates on services that had not changed except for the threading flag; the symptom is generic enough that it could be attributed to "virtual threads having higher overhead", if you don't go looking. The fastest way to make the cause obvious is to instrument the ThreadLocal itself. Subclass it and increment a Micrometer counter on every call to the initialValue() method and the cache-miss rate becomes a directly observable metric. The companion benchmark wires exactly this instrumentation into a controlled scenario so the difference is visible without needing access to production telemetry. Consider this sixty-second run at five hundred concurrent users on a cache-using endpoint:
```
Platform mode (port 8080)
threadlocal_initializations_total{cache_name="SimpleDateFormat"} 200
Virtual mode (port 8081), same workload, same JVM flags
threadlocal_initializations_total{cache_name="SimpleDateFormat"} 443,267
```
Platform threads initialized each ThreadLocal once per pooled Tomcat thread and then cached the value forever, exactly what application code assumes. Virtual threads are ephemeral with every HTTP request running on a fresh virtual thread, so the "cached" ThreadLocal is allocated on every single request. The ratio is 2,216×,. The two counters were captured side by side from the /actuator/prometheus Spring Boot Actuator endpoint on each instance immediately after the test finished. Figure 3 demonstrates the call rate during a threadlocal-stress scenario.
Figure 3: InstrumentedThreadLocal.initialValue() call rate during the threadlocal-stress scenario. (Image source: created by author)
The Y-axis is logarithmic (base 10). The blue plateau on the left is the platform-mode run, holding steady around six operations per second, exactly what application code assumes when it caches a SimpleDateFormat in a ThreadLocal. The orange plateau on the right is the virtual-mode run on the same code under identical load, sustaining about 9,800 operations per seconds, three orders of magnitude higher allocation pressure for the same cache key. The "cached" object is silently being rebuilt on every request because virtual threads are not reused. This is the centerpiece failure mode this article describes; the metric is threadlocal_initializations_total where a live panel is in the benchmark repository.
The performance consequence in the same run was throughput over 107 percent and p99 latency is down sixty-four percent with zero errors on either side. The platform-mode throughput is constrained by the two hundred-thread Tomcat pool queueing the workload internally. Virtual threads remove that ceiling. Latency drops because the queueing component of tail latency disappears.
InheritableThreadLocal Context That Becomes Null in Forked Children
The second failure showed up in our incident timelines. Tracing context, security principals, and request correlation IDs propagated correctly into the virtual thread handling the request but silently disappeared when that thread forked child tasks via StructuredTaskScope.fork(). The child threads read null. This is not a regression. Rather, it is the documented behavior of how StructuredTaskScope creates threads. But it is the kind of thing that works in local tests, where concurrency is low and context propagation is rarely exercised and fails in production at the exact moment you need the tracing context to understand an incident.
JEP 444, the JEP that introduced virtual threads, warns about this issue directly. Virtual threads support ThreadLocal for backward compatibility, but because virtual threads can be numerous, you should use ThreadLocal only after careful consideration and should not use it to pool costly resources. The JDK team removed many internal uses of ThreadLocal from java.base for this reason.
The New Dominant Failure Mode: The Bottleneck Moved Downstream
JEP 491 removed monitor pinning as the most likely first failure for a virtual-thread rollout. In production reports through 2025, across the services we evaluated, the most consistent post-upgrade pattern was downstream-resource exhaustion, including connection-pool ceilings, file-descriptor limits, downstream API rate limits, and database query queues. The throughput improvements landed as expected. The next operational page almost always involved something downstream filling up. When virtual threads remove the Tomcat-pool ceiling that was previously throttling concurrency, the next bottleneck downstream, wherever it was, starts taking the pressure.
The JDBC scenario in the companion benchmark reproduces this shape directly. Using the same five hundred concurrent users and the same sixty-second run, both modes write to the same Postgres instance through HikariCP with a pool sized twenty for platform and fifty for virtual:
| Metric | Platform (pool=20) | Virtual (pool=50) | Δ |
| Throughput (req/s) | 624 | 1,539 | +147% |
| p99 latency (ms) | 1,363 | 613 | −55% |
| Error rate (%) | 36.91 | 37.08 | identical |
The throughput and latency gains are real, but the identical 37 percent error rate is the load-bearing observation. Removing the threading-model ceiling did not remove the bottleneck, it moved it. Both modes saturate their HikariCP pool at this offered load. The platform-mode pool fills earlier (twenty connections, two hundred Tomcat threads competing for them). The virtual-mode pool fills later with fifty connections and unbounded virtual threads competing. Both pools fill. Figure 4 demonstrates the HikariCP behavior during the JDBC scenario.
Figure 4: HikariCP behaviour during the JDBC scenario. (Image source: created by author)
The top panel shows active connections: platform mode (represented with blue) saturates at its configured ceiling of twenty, virtual mode (represented with orange) saturates at its ceiling of fifty. Both reach their max and stay there during the load window.
The bottom panel shows pending acquisitions. The queue of requests waiting for a connection that doesn't exist spikes to roughly 450 in both modes when the pool fills. Removing the threading-model ceiling did not remove the bottleneck. Rather the threading-model ceiling moved the bottleneck from the Tomcat thread pool to the HikariCP connection pool, as predicted by the article's central thesis.
The mitigation is the conceptual inversion that virtual threads enable. Pool-based concurrency expressed both work isolation and resource bounding through the same mechanism, the thread pool. Virtual threads decouple them. Work isolation becomes unbounded (every request gets its own virtual thread). Resource bounding becomes an explicit instance of the Semaphore class at every shared resource. This pattern is explicitly recommended by Oracle's Nicolai Parlog in Managing Throughput with Virtual Threads, which advocates pooling permits rather than threads at every constrained downstream resource:
private static final Semaphore DB_PERMITS = new Semaphore(50);
String queryDatabase(String id) throws InterruptedException {
DB_PERMITS.acquire();
try {
return jdbcTemplate.queryForObject(
"SELECT name FROM items WHERE id = ?", String.class, id);
} finally {
DB_PERMITS.release();
}
}
This is the new model with virtual threads everywhere and semaphores at every shared resource. It is a deeper change than using virtual threads instead of a thread pool. It requires application code to make resource bounding explicit, in places where the thread pool used to do it implicitly. Note that semaphores limit concurrency. They do not substitute for backpressure semantics. If your downstream resource needs the producer to slow down when the consumer falls behind, a semaphore alone will not suffice; it will just queue or reject.
Note also that HikariCP itself has no virtual-thread-specific fix merged in HikariCP as of mid-2026. The community PR to replace internal synchronized blocks with ReentrantLock (HikariCP #2055) was closed. The maintainer explicitly chose to wait for JEP 491 rather than maintain a parallel locking strategy. Post-JEP 491, HikariCP's bottleneck is the connection ceiling, not the lock. Pre-JDK 24 only, a Hikari-plus-Logback initialization deadlock under virtual threads was reported in Issue #2293. This issue was fixed by upgrading to JDK 24+, where JEP 491 sidesteps the deadlock entirely.
Scoped Values: Finalized in JDK 25, Worth Adopting Now
Figure 5 demonstrates the two ThreadLocal failure modes covered above (left column) mapped to their ScopedValue replacements (right column).
Figure 5: ThreadLocal vs. Scoped Values in Virtual Threads (Image source: created by author)
In the top row, per-request reinitialization of a cached object, is fixed by binding the value once per request scope.
In the bottom row, the null context propagation through StructuredTaskScope.fork() was fixed by ScopedValue's automatic inheritance into child threads forked inside the binding scope. The rest of this section walks through the migration mechanics; this figure is the summary view.
JEP 506, Scoped Values, is finalized in JDK 25 after five rounds of preview. A ScopedValue is immutable within its binding scope, structurally bound to the block that created it and is propagated automatically to child threads in a StructuredTaskScope without copying. It solves both ThreadLocal failure modes described above. There is no reinitialization on every virtual thread because context propagation is explicit and structured. Likewise, there is no null in forked children because the binding is inherited by design.
// Before: InheritableThreadLocal for request context
private static final InheritableThreadLocal<RequestContext> CTX =
new InheritableThreadLocal<>();
void handleRequest(Request req) {
CTX.set(new RequestContext(req.userId(), req.traceId()));
try {
processRequest();
} finally {
CTX.remove(); // easy to omit on error paths
}
}
// Children forked via StructuredTaskScope read null from CTX.
// After: ScopedValue (JDK 25, JEP 506)
private static final ScopedValue<RequestContext> CTX = ScopedValue.newInstance();
void handleRequest(Request req) {
ScopedValue.runWhere(CTX,
new RequestContext(req.userId(), req.traceId()),
this::processRequest);
// context cleared automatically when block exits; no finally needed.
}
// Children forked via StructuredTaskScope.fork() inherit CTX automatically.
The migration from InheritableThreadLocal to ScopedValue requires deliberate refactoring because the read API differs, but it is straightforward in most application code. The binding site becomes the entry point of your request handler rather than a field set operation. Callers read through CTX.get() the same way they did before. For tracing frameworks and security-context propagation, scoped values are the strongest built-in option for application-owned request context on JDK 25.
One semantic change came with finalization: ScopedValue.orElse(null) is no longer permitted. Instead, use the orElse(someNonNullDefault) or isBound() methods first. Code written against the preview API may need this small adjustment.
For caching patterns that genuinely need per-thread reuse, the fix is different. Use a bounded fixed-size ExecutorService with platform threads for the work that needs cached objects. Keep virtual threads for the I/O-bound portions of the request.
The WebFlux Migration Decision
Setting the property, spring.threads.virtual.enabled=true, in Spring Boot 3.2+ does not change the request-handling model in a Spring WebFlux application. WebFlux runs on Netty's event loop, which manages its own non-blocking thread model independently of Tomcat's thread pool. This enables the virtual-thread property configuration in Tomcat and Jetty, not Netty. In practice, the benefit applies to servlet-container request handling such as Tomcat or Jetty, not to Netty-based WebFlux request handling. Note that spring.threads.virtual.enabled remains opt-in, false by default, in both Spring Boot 3.5 and 4.0. Enabling virtual threads with Spring is still a deliberate decision, not a quiet default.
This fact is worth stating explicitly because a number of teams set the flag, saw requests still running on Netty reactor threads, and concluded virtual threads were not working. They were correct that virtual threads were not involved and they were correct that the configuration had no effect. That is the expected behavior, not a bug.
The migration question is therefore upstream: should this service move from WebFlux to Spring MVC? The answer depends on why the service adopted reactive programming in the first place.
Services that chose WebFlux primarily to avoid blocking I/O at scale have the clearest case for migration. With virtual threads on JDK 24 and later, blocking JDBC calls, synchronous HTTP clients, and standard Jakarta Persistence (JPA), queries all scale acceptably without the reactive programming model. The code becomes simpler, stack traces are readable, and debugging behaves normally. The controller layer changes from Mono<T> to T, Flux<T> to List<T>, and Reactor Mono/Flux.flatMap() operator chains become sequential method calls. The fan-out work that previously used reactive zip is expressible as concurrent blocking calls with a StructuredTaskScope or a bounded executor.
The database layer requires more thought. R2DBC has to be removed in favor of JDBC or JPA. For services that chose R2DBC because they believed it would outperform JDBC at scale, the honest assessment is that the performance characteristics of JDBC on virtual threads versus R2DBC vary by workload, connection-pool configuration, and driver implementation. I have not found a public apples-to-apples benchmark that settles this issue for general request-response workloads. Measuring in your own environment before committing to the migration is prudent.
A Note on the Framing
This is not a WebFlux-is-obsolete argument. It is a decision filter. If WebFlux was adopted solely to survive blocking I/O concurrency, MVC plus virtual threads may significantly simplify the service. If WebFlux was adopted for streaming, backpressure or long-lived event flows, keep it. The Reactor team has not published an official recommendation to migrate off WebFlux. The pattern of moving blocking services to MVC is an emerging community practice, not an endorsed migration path.
Where to Keep WebFlux
Server-sent events, WebSocket endpoints, and services that act as streaming gateways with real backpressure requirements are not good candidates for this migration. Reactor's backpressure model exists because a fast producer can overwhelm a slow consumer in ways that blocking I/O cannot express. Virtual threads do not change that constraint. If your service exists to stream data from Kafka to an HTTP client and needs to slow down the Kafka consumer when the HTTP client falls behind, WebFlux is still the right tool. The migration case is specifically for services that adopted reactive programming as the only available path to I/O concurrency, not for services that need reactive semantics.
Where Structured Concurrency Stands
Structured Concurrency is worth learning but not yet something to expose in stable public APIs. JDK 25 ships it as a fifth preview via JEP 505. Later previews continue to refine the Joiner API. The model is sound for request fan-out, cancellation, and task lifetime management, but teams should expect source-level changes across JDK upgrades until it finalizes. The most significant JDK 25 change was replacing public constructors on StructuredTaskScope with static factory methods via StructuredTaskScope.open(), which breaks code written against JDK 24's preview API. Scoped Values are different because they were finalized in JDK 25 and they are stable to adopt in production today.
A Practical Adoption Sequence
The sequence we used for our blocking request-response services on Spring Boot 3.2+, ordered to surface failure modes before they reach production:
Start on JDK 25 LTS
JEP 491 is in JDK 24 and default in JDK 25; Scoped Values are finalized in JDK 25. For production-conservative teams choosing between JDK 21 LTS and JDK 25 LTS in 2026, JDK 25 is the natural target. If you must stay on JDK 21 for compatibility reasons, the entire risk profile of this article still applies and pinning is a real production concern; the article's first half is the one to study.
Verify the Threading Model Before Load Testing
A quick check on a health endpoint confirms virtual threads are active:
@GetMapping("/health")
public Map<String,Object> health() {
return Map.of(
"thread", Thread.currentThread().getName(),
"virtual", Thread.currentThread().isVirtual()
);
}
If virtual=false appears, the service is running WebFlux or the property did not apply. Fix that issue before proceeding.
Audit ThreadLocal Use in Your Code and First-Tier Dependencies
Identify any ThreadLocal.withInitial() used for caching and any InheritableThreadLocal used for context propagation. The caching using either one needs conversion to a bounded thread-pool pattern or acceptance that the cache is per-task rather than per-thread. The context-propagation uses should migrate to ScopedValues on JDK 25.
Add a Semaphore at Each Bounded Resource
This is the conceptual inversion described in the bottleneck moved section above. Connection pools, rate-limited downstream APIs, file-descriptor budgets, and anything with a configured maximum concurrency need explicit bounding under virtual threads. The implicit bounding that came from the thread pool is gone.
5. Run JFR Before Production Rollout
Configure jdk.VirtualThreadPinned events (a default of twenty millisecond threshold in JDK 25) and execute your standard load test. Pinning from native calls in your dependency stack will surface here. For most pure-Java web services, this produces nothing. For services with JNI-heavy dependencies, it identifies exactly where residual carrier-thread pressure originates.
Observability Recipe for Virtual Threads in Production
Three concrete things we wish we had enabled on day one of the rollout: each maps to one of the failure modes above:
- JFR
jdk.VirtualThreadPinnedis enabled by default in JDK 25 at a twenty millisecond threshold.
Pair it withjdk.VirtualThreadSubmitFailed. Alert on either. This approach catches the residual pinning categories before they become incidents jcmd <pid> Thread.dump_to_file -format=jsonproduces a virtual-thread-aware structured dump grouped by carrier.
This is the diagnostic that was missing when Netflix hit their stall (jstack omitted virtual threads from its output). Keep the command in your runbook. For local debugging, IntelliJ added virtual-thread-aware thread-dump rendering in late 2025, with the caveat that capturing virtual thread dumps inside the IDE requires running the app under the debugger. Older IDE versions only show carrier threads.
- A
ThreadLocal.initialValue()counter on anyThreadLocalyou care about.
SubclassThreadLocaland increment a metrics counter ininitialValue(). A spike in this rate is the signature of the cache-miss failure mode this article describes. The benchmark repository contains a reference implementation,InstrumentedThreadLocal. Figure 3 above is exactly what that counter looks like in Grafana when the failure mode is active.
What Virtual Threads Do Not Change
Virtual threads improve throughput for I/O-bound concurrency. They do not improve latency for CPU-bound work. They do not add backpressure semantics. Finally, they do not make slow external services fast. A service that spends most of its wall-clock time in computation, data transformation, or inference does not benefit from switching its threading model. The scheduler overhead of creating thousands of virtual threads for CPU-bound tasks is a cost with no corresponding gain.
The correct scope of the adoption decision: if your service is primarily waiting on I/O (databases, downstream APIs, file reads), blocking I/O on virtual threads is now a viable model at scale. If your service is primarily computing or if it needs to apply backpressure to a data stream, the threading model is not your bottleneck and this change does not help.
Conclusion: Where This Leaves Us
JDK 25 is the first LTS that includes JEP 491's pinning fix and finalized Scoped Values. For teams choosing between Java 21 LTS and Java 25 LTS in 2026, the Java concurrency story is meaningfully different on each side of that gap. On JDK 25, the JVM has absorbed the monitor-pinning problem and provides a stable context-propagation primitive through Scoped Values. The remaining adoption work is in your own code, such as auditing ThreadLocal usage, bounding every downstream resource explicitly, keeping the JFR pinning event on with alerts, and staying on the JDK release train.
The deeper shift is conceptual. Pool-based concurrency expressed both work isolation and resource bounding through the same mechanism, the thread pool. Virtual threads decouple thread pools. Work isolation is unbounded (every request gets its own virtual thread) and resource bounding becomes an explicit semaphore at every shared resource. That decoupling is the right model. The previous coupling was an artifact of OS-thread cost, not a design choice, butrequires application code to make resource bounding explicit, in places where the thread pool used to do it implicitly. That is the practical lift in 2026, which is smaller than the lift JDK 21 required.
The Java concurrency story is now meaningfully cleaner than it was in 2021. Virtual threads handle I/O scaling, Scoped Values handle context propagation, and Structured Concurrency, once finalized, will handle coordinated concurrent work with explicit lifetime management. JDK 24 resolved the carrier-pinning problem that blocked broad adoption. JDK 25 finalized the context API. For most blocking web services, the remaining adoption decisions are about dependency audits and pool sizing, not about fundamental JVM risk.
Methodology: Reproducing the Numbers
All numeric data in this article comes from a public benchmark designed to keep the environment stable while making the profile differences explicit, so any reader can re-create the failure modes described above on their own hardware. The topology is shown in Figure 1.
The profiles intentionally differ in two ways: the threading model and, in JDBC scenarios, the configured connection pool ceiling. For the ThreadLocal endpoint, the pool difference is irrelevant because that endpoint does not exercise JDBC. For the JDBC scenario, the pool difference is deliberate and models the resource-bound shift that follows removing the Tomcat thread ceiling.
- Repository
- Host: AWS EC2 c7i.2xlarge (8 vCPU Intel Sapphire Rapids, 15 GiB RAM)
- OS: Ubuntu Server 24.04 LTS, kernel 6.17
- JDK: Eclipse Temurin 25.0.3+9 (LTS)
- Application: Spring Boot 3.4, embedded Tomcat, HikariCP, Spring RestClient, blocking JDBC
- Backing services: PostgreSQL 16 (
max_connections=300,shared_buffers=128MB); WireMock 3.9.1 with a 300 ms canned response delay - Profiles compared: platform (Tomcat
pool=200, HikariCPpool=20) vs. virtual (spring.threads.virtual.enabled=true, HikariCP pool=50) - JVM flags (identical across profiles):
-Xms512m -Xmx512m -XX:+UseG1GC -Dhttp.maxConnections=5000, JFR continuous with jdk.VirtualThreadPinned at threshold=0
- Load: JMeter 5.6.3 in network_mode: host; warmup thirty second ramp, measurement sixty second, run order platform → thirty second cooldown → virtual. The ConstantThroughputTimerkeeps offered load stable across profiles; the reported throughput figures are server-completed requests under that offered load, not a rate fixed by the client.
- Observability: Prometheus + Grafana, plus node-exporter (OS), postgres-exporter (DB internals), and cAdvisor (containers), providing the metric shape a Datadog/Dynatrace install would surface. Figures 2, 3, and 4 are direct screenshots of these dashboards.
- Reproduction: make up && make up-app && make benchmark-full(ormake benchmark-quickfor a five-minute smoke); raw JTLs are gitignored, summary tables under theresults/summaries/directory are committed
The benchmark is intentionally controlled: single-instance, synthetic workloads, explicit profile differences. That control is what makes the failure modes visible in isolation rather than buried in production noise. Real environments will shift the magnitudes depending on dependency versions, downstream latencies, and pool configuration. What will not shift are the patterns themselves, such as ThreadLocal caches that assume thread reuse, connection pools that become the new ceiling after the thread-pool ceiling is gone, and pinning events that disappear on JDK 24. The companion repository exists so any reader can verify these patterns on their own hardware, adjust the parameters to match their stack, and observe these failure modes in a controlled setting without waiting for them to surface as a production incident.