Skip to content

Java Virtual Threads: Project Loom, Pinning Hazards, and Production Migration

BackendBytes Engineering Team
BackendBytes Engineering Team
17 min read
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.

Java 24+ Update: Synchronized Pinning Eliminated

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.

TL;DR

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 synchronized block during I/O with ReentrantLock before migration (required for Java 21–23; unnecessary on Java 24+ per JEP 491[JEP 491, 2025])
  • Enable jdk.VirtualThreadPinned JFR 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].

Java Virtual Threads M:N Model

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 TypeVirtual Thread BenefitWhy
I/O-bound, high concurrencyHighThreads spend most time blocked on network/disk; VTs unmount during waits
I/O-bound, low concurrencyLowThread pool is not the bottleneck
Mixed I/O + CPUModerateBenefits proportional to I/O ratio
CPU-boundNone or negativeVTs add scheduling overhead; no concurrency gain
Reactive (WebFlux)NoneAlready 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: 10

In 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:

LibraryVT-Safe GuidanceNotes
HikariCPRecent versionsDefault Spring Boot pool; check release notes for virtual thread compatibility
PostgreSQL JDBCRecent versionsCheck release notes for synchronized removal or VT safety claims
MySQL Connector/JRecent versionsCheck release notes for virtual thread compatibility
Oracle JDBCRecent versionsThin driver support varies; OCI driver uses JNI (always pins)
Apache HttpClient 5Recent versionsNon-blocking I/O core; check for VT compatibility claims
JacksonRecent versionsGenerally safe; check release notes
HibernateRecent versionsSession management; check release notes for VT safety
BouncyCastleRecent versionsCheck 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.jar

Create 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 synchronized blocks with ReentrantLock (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.VirtualThreadPinned event recording to load test environment (diagnostic; pinning is eliminated on Java 24+)

When enabling virtual threads:

  • Set spring.threads.virtual.enabled=true
  • Verify @Async methods run on virtual threads via Thread.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.VirtualThreadSubmitFailed events
  • 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

BackendBytes Engineering Team
BackendBytes Engineering Team

Engineering Team

A multidisciplinary team of backend engineers, architects, and DevOps practitioners shipping deep dives into distributed systems and production infrastructure.

Read Next