Java Virtual Threads: Project Loom, Pinning Hazards, and Production Migration
Key Takeaways
- →Virtual threads fix I/O-bound thread pool exhaustion — in our migrations, container counts dropped 50–80% for the same throughput on I/O-bound services, with no reactive rewrite required
- →Thread pinning silently kills the benefit: any synchronized block during I/O pins the carrier thread — replace with ReentrantLock
- →Detect pinning in production with JFR's jdk.VirtualThreadPinned event before it causes outages
The classic Spring Boot production thread-pool exhaustion incident. A service with a 200-thread executor handles requests averaging ~100 ms of I/O (one database round-trip plus one payment gateway call). Throughput caps at ~2 thousand requests per second per container with CPU sitting near 10 percent. The team scales horizontally to 25 containers to hit 50 thousand requests per second — paying for a service that is 90 percent idle. We migrated this exact shape on multiple production teams.
The Thread Pool Exhaustion Problem
The classic Spring Boot thread-pool exhaustion pattern. A Spring Boot service with a 200-thread executor, each request averaging ~100 ms of I/O (one database round-trip + one payment gateway call). Throughput caps at ~2,000 RPS per container with CPU sitting near 10%. The team scales horizontally to ~25 containers to hit 50K RPS — paying for a service that's 90% idle. We've migrated this exact shape multiple times. [Spring Boot virtual threads]
The fix is virtual threads[JEP 444, 2023]. Same code, same blocking calls, no reactive rewrite. Throughput jumps to 50K+ RPS on a fraction of the containers. Then the staging crashes start: a synchronized block in a connection-pool wrapper is pinning carrier threads — silently converting virtual threads back into platform threads under load. The p99 latency spikes from 12 ms to 4 s during the pinning episodes, and there's no monitoring to detect it.
JEP 491[JEP 491, 2025] (Java 24, March 2025) removed the synchronized pinning limitation described in this article. If you're running
Java 24 or later, virtual threads can hold object monitors without pinning to carrier threads — the ReentrantLock
migration below is no longer required. The guidance in this article remains relevant for Java 21–23 deployments.
Virtual threads[JEP 444, 2023] apply M:N scheduling to Java: thousands of lightweight threads share a small carrier pool, unmounting during I/O to free carriers for other work. Enable with one Spring Boot property[Spring Boot virtual threads]. Defeat with pinning (synchronized blocks during I/O on Java 21–23) and carrier starvation; detect both via JFR events.
- Replace every
synchronizedblock during I/O withReentrantLockbefore migration (required for Java 21–23; unnecessary on Java 24+ per JEP 491[JEP 491, 2025]) - Enable
jdk.VirtualThreadPinnedJFR event to detect pinning under load - In our migrations, we cut container count by 50–80% for the same throughput on I/O-bound services
What Virtual Threads Actually Are
Virtual threads are user-space threads managed by the JVM scheduler rather than the operating system. A platform thread maps 1:1 to an OS thread and costs 1–2 MB of stack memory. A virtual thread starts with a few kilobytes (grown dynamically) and schedules onto a small pool of carrier threads — typically one per CPU core.
The mount-unmount-remount cycle is the entire model in one picture:
sequenceDiagram
participant V as Virtual thread<br/>app code
participant C as Carrier thread<br/>OS thread
participant IO as Blocking I/O<br/>DB, HTTP, file
Note over V,C: Step 1 — mount
V->>C: schedule onto carrier
C->>C: run app code
Note over V,C: Step 2 — unmount on block
C->>IO: blocking call (e.g. JDBC)
C->>V: park virtual thread<br/>save continuation
Note over C: Carrier free —<br/>picks up another virtual thread
Note over V,IO: Step 3 — I/O completes
IO-->>V: response ready
Note over V,C: Step 4 — remount on any available carrier
V->>C: reschedule
C->>C: resume continuation
Note over V,C: From the dev's view, the thread<br/>"blocked" — but no OS thread waited.
The diagram is the entire payoff: blocking calls feel synchronous from app code, but the OS-level thread is never held idle[JEP 444, 2023].
The key behaviour: when a virtual thread calls blocking I/O (database query, HTTP call, file read), the JVM automatically unmounts it from its carrier thread. The carrier thread immediately picks up another virtual thread. When the I/O completes, the virtual thread reschedules onto an available carrier. The thread appears to block from the developer's perspective, but no OS thread is held idle.
This is the M:N threading model — many virtual threads scheduled onto N carrier threads — that languages like Go (goroutines) and Erlang (processes) have used for years. Java 21 brings it to the JVM without requiring a rewrite to a different programming model[JEP 444, 2023].
Platform threads are limited by OS resources — in our experience, systems become unstable above 5,000–10,000 platform threads. Virtual threads operate in a fundamentally different regime: spawning 100,000 of them is routine, because most are parked in the JVM heap waiting for I/O rather than holding OS thread stacks.
Workload Classification & the Quick Start
Virtual threads are not a universal improvement. Their benefit depends entirely on workload profile:
| Workload Type | Virtual Thread Benefit | Why |
|---|---|---|
| I/O-bound, high concurrency | High | Threads spend most time blocked on network/disk; VTs unmount during waits |
| I/O-bound, low concurrency | Low | Thread pool is not the bottleneck |
| Mixed I/O + CPU | Moderate | Benefits proportional to I/O ratio |
| CPU-bound | None or negative | VTs add scheduling overhead; no concurrency gain |
| Reactive (WebFlux) | None | Already non-blocking; VTs cannot improve optimal |
Decision framework: If your service is bottlenecked by thread pool exhaustion (requests queuing for a free thread while CPU sits at 10–20%), virtual threads will help. If the bottleneck is CPU, database connection limits, or downstream service latency, they won't. [Spring Boot virtual threads]
Migrate with three parallel work streams: (1) enable virtual threads, (2) audit and replace pinning sources, (3) address ThreadLocal usage. Skip any and you'll have virtual threads enabled but no benefits.
Migration: From Pools to Per-Task Threads
The configuration change is minimal. Spring Boot 3.2+[Spring Boot virtual threads] handles everything. The framework detects the virtual threads setting and automatically applies it to web requests (Tomcat connector), async tasks (@Async), scheduled tasks (@Scheduled), and message listeners. As of late 2025 the feature is firmly mainstream — the Spring team now actively recommends enabling virtual threads on Java 21+ (Java 24+ for the best experience, since JEP 491 removes synchronized pinning).[Spring Boot virtual threads]
spring:
threads:
virtual:
enabled: true
datasource:
hikari:
maximum-pool-size: 50 # Reduce from 200—VTs share efficiently
minimum-idle: 10In code, replace ThreadPoolTaskExecutor with virtual threads:
// Old way — thread pool exhaustion bottleneck
@Configuration
public class OldThreadConfig {
@Bean
public ThreadPoolTaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(200);
executor.setMaxPoolSize(400);
executor.setQueueCapacity(1000);
executor.initialize();
return executor;
}
}
// New way — one line
@Configuration
public class VirtualThreadConfig {
@Bean
public AsyncTaskExecutor applicationTaskExecutor() {
return new TaskExecutorAdapter(
Executors.newVirtualThreadPerTaskExecutor()
);
}
}Start with a non-critical service — a recommendation engine or search feature where degradation doesn't cause outages. This gives you a safe canary before rolling out to critical paths.
Results we observed after enabling virtual threads on our I/O-bound services: P99 latency down 40–70%, container count down 50–80% for the same throughput, memory per container roughly flat. The latency improvement comes primarily from eliminating thread pool queuing: requests get their own virtual thread immediately instead of waiting for a pooled platform thread to become available.
The Three Pinning Hazards
Virtual threads introduce three silent failure modes that eliminate benefits under load. The pinning failure mode in one picture — every carrier thread held hostage by a synchronized lock on Java 21–23, leaving the rest of the workload starved:
graph TB
subgraph Healthy["Healthy carrier pool — virtual threads unmount during I/O"]
C1A["Carrier 1"] --> V1A["VT #1<br/>idle"]
C2A["Carrier 2"] --> V2A["VT #2<br/>running"]
C3A["Carrier 3"] --> V3A["VT #3<br/>idle"]
Q1["Queue: VT #4..N<br/>ready to mount"]
end
subgraph Pinned["Pinning episode — Java 21–23, synchronized + I/O"]
C1B["Carrier 1"] -->|"PINNED<br/>blocked I/O<br/>inside synchronized"| V1B["VT A"]
C2B["Carrier 2"] -->|"PINNED"| V2B["VT B"]
C3B["Carrier 3"] -->|"PINNED"| V3B["VT C"]
Q2["Queue: VT D..Z<br/>cannot mount<br/>p99 latency spikes"]
end
Healthy -->|"add synchronized<br/>around blocking call"| Pinned
style V1B fill:#fdd
style V2B fill:#fdd
style V3B fill:#fdd
style Q2 fill:#fdd
JEP 491 (Java 24) eliminates this — synchronized monitors no longer pin. The diagram is the failure mode every Java 21–23 codebase has to audit before flipping the virtual-threads switch.
Gotcha #1: Synchronized Blocks Pin Carrier Threads
When a virtual thread holds a synchronized block or monitor during blocking I/O, the JVM cannot unmount it[JEP 444, 2023]. The carrier thread is held for the entire operation, eliminating concurrency. With a default carrier pool of 4–16 threads, even a handful of pinned carriers can starve the entire system.
Note: This limitation was eliminated in Java 24 via JEP 491[JEP 491, 2025]. The guidance below is required for Java 21–23; unnecessary on Java 24+.
// BAD on Java 21–23 — pins the carrier thread
private synchronized void updateCache(String key, Object value) {
cache.put(key, value); // If this blocks (unlikely but possible with some cache implementations)
}
// GOOD for Java 21–23 — use ReentrantLock instead (not needed on Java 24+)
private final Lock lock = new ReentrantLock();
private void updateCache(String key, Object value) {
lock.lock();
try {
cache.put(key, value);
} finally {
lock.unlock();
}
}For Java 21–23, a full codebase audit for synchronized is non-negotiable. Expect dozens to hundreds of occurrences. Many will be in third-party libraries you cannot modify. For libraries you cannot change, either pin calls to a bounded platform thread pool or wait for library updates. On Java 24+, this becomes optional.
Library compatibility as of early 2026:
| Library | VT-Safe Guidance | Notes |
|---|---|---|
| HikariCP | Recent versions | Default Spring Boot pool; check release notes for virtual thread compatibility |
| PostgreSQL JDBC | Recent versions | Check release notes for synchronized removal or VT safety claims |
| MySQL Connector/J | Recent versions | Check release notes for virtual thread compatibility |
| Oracle JDBC | Recent versions | Thin driver support varies; OCI driver uses JNI (always pins) |
| Apache HttpClient 5 | Recent versions | Non-blocking I/O core; check for VT compatibility claims |
| Jackson | Recent versions | Generally safe; check release notes |
| Hibernate | Recent versions | Session management; check release notes for VT safety |
| BouncyCastle | Recent versions | Check release notes for pinning hazards |
For Java 24+: With JEP 491 eliminating synchronized pinning, library version concerns are significantly reduced. Most major JDBC drivers and frameworks have addressed pinning in recent releases. Verify with your driver's release notes, but the urgency is lower on Java 24+.
Run mvn dependency:tree and cross-reference every library doing I/O. For Java 21–23, a single outdated JDBC driver can negate the entire migration; on Java 24+, the risk is substantially lower.
Gotcha #2: ThreadLocal Memory Explosion
ThreadLocals are everywhere: security contexts, transaction managers, request IDs. With virtual threads, you might create millions in a request lifecycle. Each one gets its own ThreadLocal copy. Result: memory explosion.
// Disaster with virtual threads — 100,000 threads × 1KB ThreadLocal = 100MB
private static final ThreadLocal<UserContext> USER_CONTEXT = new ThreadLocal<>();
// Use ScopedValue instead (preview since Java 21; final in JDK 25, JEP 506)
private static final ScopedValue<UserContext> USER_CONTEXT =
ScopedValue.newInstance();
public void handleRequest(HttpServletRequest request) {
var userContext = extractUserContext(request);
ScopedValue.where(USER_CONTEXT, userContext)
.run(() -> processRequest());
}Scoped values are immutable, lightweight, and designed for virtual threads. Migrating an established codebase from ThreadLocal is a separate project — plan it as a future work stream.
Gotcha #3: Carrier Starvation
If all carrier threads are pinned by synchronized blocks during slow I/O, new virtual threads cannot mount. The system appears deadlocked even though no actual deadlock exists. In production, this manifests as a sudden throughput cliff under load, with CPU idle but requests timing out.
Enable JFR to detect this:
# Terminal — logs a warning for every pin lasting >20ms
java -Djdk.tracePinnedThreads=full Application.jar
# Or via JFR flight recording in production
java -XX:StartFlightRecording=filename=pinning.jfr,settings=pinning.jfc Application.jarCreate a pinning.jfc profile:
<?xml version="1.0" encoding="UTF-8"?>
<configuration version="2.0">
<event name="jdk.VirtualThreadPinned">
<setting name="enabled">true</setting>
<setting name="stackTrace">true</setting>
<setting name="threshold">20 ms</setting>
</event>
</configuration>Analyze with jfr print --events VirtualThreadPinned pinning.jfr | head -100. Each event shows the stack trace. Any frame containing synchronized is your fix target.
For continuous production monitoring without JFR overhead, expose pinning as a metric:
@Component
public class VirtualThreadPinningMetrics {
private final MeterRegistry registry;
private final AtomicLong pinnedCount = new AtomicLong();
@PostConstruct
public void startPinningMonitor() {
var rs = new RecordingStream();
rs.enable("jdk.VirtualThreadPinned").withThreshold(Duration.ofMillis(20));
rs.onEvent("jdk.VirtualThreadPinned", event -> {
pinnedCount.incrementAndGet();
log.warn("Pinned {}ms at {}", event.getDuration().toMillis(),
event.getStackTrace());
});
rs.startAsync();
registry.gauge("jvm.virtual_thread.pinned.count", pinnedCount);
}
}Alert when pinning frequency exceeds baseline. A spike after a library upgrade is usually a synchronized block added in the new version.
Structured Concurrency for Fan-Out
Structured concurrency (StructuredTaskScope, previewed again as JEP 505 in JDK 25, the September 2025 LTS — still a preview API, so compile with --enable-preview) complements virtual threads — it gives concurrent fan-out operations the same lifecycle discipline as sequential code. It is not final yet: JEP 505 is the fifth preview, and a sixth preview (JEP 525) is proposed for JDK 26, so expect the API below to keep shifting — pin the JDK you compile against.
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Joiner;
// Order processing: fan out to three services concurrently, fail if any fails
public OrderConfirmation processOrder(Order order) throws InterruptedException {
// JDK 25 form: open(...) + a Joiner policy. The ShutdownOnFailure /
// ShutdownOnSuccess public constructors were removed in this preview round.
try (var scope = StructuredTaskScope.open(Joiner.<Object>awaitAllSuccessfulOrThrow())) {
var inventory = scope.fork(() -> inventoryService.checkStock(order));
var payment = scope.fork(() -> paymentService.charge(order));
var shipping = scope.fork(() -> shippingService.getQuote(order));
// join() blocks until all succeed, throwing FailedException (and cancelling
// the survivors) on the first failure — the old throwIfFailed() call is gone.
scope.join();
return new OrderConfirmation(
inventory.get(),
payment.get(),
shipping.get()
);
}
// Scope closed = all child threads are guaranteed done
}If payment fails, the awaitAllSuccessfulOrThrow() Joiner immediately cancels inventory and shipping — no manual cleanup needed. This replaces CompletableFuture.allOf() chains and is much cleaner.
Production Checklist
Before enabling virtual threads:
- Audit codebase for
synchronized— classify each as safe (no blocking I/O inside) or hazardous (required for Java 21–23; optional on Java 24+ due to JEP 491) - Replace hazardous
synchronizedblocks withReentrantLock(Java 21–23 only; see JEP 491 note above) - Verify all third-party libraries are reasonably recent (see compatibility table; less critical on Java 24+)
- Reduce database and HTTP connection pool sizes — the ceiling is now the pool, not thread count
- Add JFR
jdk.VirtualThreadPinnedevent recording to load test environment (diagnostic; pinning is eliminated on Java 24+)
When enabling virtual threads:
- Set
spring.threads.virtual.enabled=true - Verify
@Asyncmethods run on virtual threads viaThread.currentThread().isVirtual() - Run load tests and collect JFR recording for pinning events
- Analyse pinning events — fix any with >20ms duration
- Monitor P50/P99 latency under load — validate improvement
- Monitor container memory — watch for ThreadLocal growth
After enabling virtual threads:
- Export carrier utilization and virtual thread count as metrics
- Alert on carrier utilization > 95% (starvation signal)
- Alert on
jdk.VirtualThreadSubmitFailedevents - Plan ThreadLocal → ScopedValue migration as separate project
Detect pinning in CI before it ships to production
The diagnostic gap most teams hit: pinning episodes only show up under realistic concurrency, and JFR recordings are bulky to ship around. Below is a JFR-event-tap that fails the build if any virtual-thread pinning episode exceeds the threshold — paste it into your load-test runner so a synchronized-during-I/O regression is caught at PR time, not 3am:
import jdk.jfr.consumer.RecordingStream;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;
public final class PinningGuard implements AutoCloseable {
private static final long PIN_THRESHOLD_NANOS = Duration.ofMillis(20).toNanos();
private static final long FAIL_THRESHOLD = 5;
private final RecordingStream stream;
private final LongAdder pinCount = new LongAdder();
private final AtomicLong worstPinNanos = new AtomicLong();
public PinningGuard() {
this.stream = new RecordingStream();
stream.enable("jdk.VirtualThreadPinned").withThreshold(Duration.ofMillis(1));
stream.onEvent("jdk.VirtualThreadPinned", event -> {
long durationNanos = event.getDuration().toNanos();
if (durationNanos > PIN_THRESHOLD_NANOS) {
pinCount.increment();
worstPinNanos.accumulateAndGet(durationNanos, Math::max);
System.err.printf(
"PIN: %dms in thread=%s stack=%s%n",
Duration.ofNanos(durationNanos).toMillis(),
event.getThread() == null ? "?" : event.getThread().getJavaName(),
event.getStackTrace());
}
});
stream.startAsync();
}
public void assertNoPinningRegression() {
long count = pinCount.sum();
if (count > FAIL_THRESHOLD) {
throw new AssertionError(String.format(
"Pinning regression: %d events over %dms (worst=%dms). " +
"Audit recent synchronized changes or move to ReentrantLock.",
count,
Duration.ofNanos(PIN_THRESHOLD_NANOS).toMillis(),
Duration.ofNanos(worstPinNanos.get()).toMillis()));
}
}
@Override public void close() { stream.close(); }
}
// Usage in your load test or integration test:
// try (PinningGuard guard = new PinningGuard()) {
// runLoadFor(Duration.ofMinutes(2));
// guard.assertNoPinningRegression();
// }The only configuration knob is PIN_THRESHOLD_NANOS (default 20ms — anything shorter is rarely actionable) and FAIL_THRESHOLD (5 episodes per test run). On Java 24+ this guard becomes optional but still costs nothing to leave in — JEP 491 eliminated synchronized-driven pinning, but third-party native code can still pin if it parks the carrier.
- Document any pinning sources that cannot be fixed (library constraints)
Frequently Asked Questions
What is thread pinning in Java virtual threads?
Thread pinning occurs when a virtual thread holds a synchronized block or monitor during a blocking I/O call, preventing it from unmounting from its carrier thread. The carrier thread is blocked for the duration, eliminating the concurrency benefit. Replace synchronized with ReentrantLock to avoid pinning.
When should I use virtual threads vs reactive (WebFlux)?
Use virtual threads for I/O-bound services where thread pool exhaustion is the bottleneck — you get the concurrency of reactive with sequential, debuggable code. Use reactive if you already have a mature WebFlux codebase, need built-in backpressure, or run pure non-blocking pipelines where reactive has a slight performance edge.
Do virtual threads improve CPU-bound workload performance?
No. Virtual threads add scheduling overhead for CPU-bound work and provide no benefit since the bottleneck is computation, not thread blocking. They are designed for I/O-bound workloads where threads spend most time waiting on network or disk.
How do I detect virtual thread pinning in production?
Use Java Flight Recorder (JFR) with the jdk.VirtualThreadPinned event enabled. JFR captures the stack trace where pinning occurs, showing the exact synchronized block causing the issue. You can also set -Djdk.tracePinnedThreads=short for development diagnostics.
Keep Reading
- Java CompletableFuture: Async Orchestration Patterns for Production — The async patterns virtual threads aim to replace, and when CompletableFuture is still the better tool
- Go vs Java in 2026: An Honest Performance Comparison for Backend Services — How Java's virtual threads stack up against Go's goroutines in real benchmarks
- Spring Boot REST: JPA, Validation, Exception Handling, and Testing — The Spring Boot patterns that benefit most from enabling virtual threads
- Java Streams: Pipeline Internals, Performance Traps, and Production Patterns — When
parallelStreamis the wrong tool: virtual threads handle blocking I/O without ForkJoinPool starvation - GraalVM Native Images in Production — Virtual threads + GraalVM native: 80 ms cold start with 50k-thread concurrency on a tiny container
Engineering Team
A multidisciplinary team of backend engineers, architects, and DevOps practitioners shipping deep dives into distributed systems and production infrastructure.
Read Next
Modern Java Features: A Practical Guide from Java 8 to 21
The features that changed Java: lambdas, streams, records, sealed classes, pattern matching, and virtual threads. Java 8 to 21.
GraalVM Native Images in Production: From 5-Second Startup to 50ms
From 5-second Spring Boot cold starts to 50ms with GraalVM native images. The real gotchas, wins, and whether it's worth it.
Go vs Java in 2026: An Honest Performance Comparison for Backend Services
An honest Java (Spring Boot) vs. Go (Gin) performance comparison under load tests in 2026. Comparing throughput, memory footprint, cold starts, and AWS costs.