Virtual Thread Pinning in Spring Boot 3.2+

How synchronized blocks and ThreadLocal variables silently cripple high-throughput Project Loom applications — and how to eliminate them.

Framework
Spring Boot 3.x
Runtime
Java 21 LTS
Stability
Enterprise grade

Overview

Virtual threads (Project Loom) are designed to be lightweight and massively scalable, supporting millions of concurrent tasks on a handful of OS threads. However, 'pinning' occurs when a virtual thread becomes stuck to its carrier (platform) thread — preventing the JVM scheduler from mounting other virtual threads onto it. This commonly happens inside synchronized blocks that perform blocking I/O, or when invoking native methods. The result is silent performance degradation: no exception thrown, just thread exhaustion.

Symptom

exception_report.logFatal
// Thread Dump via jcmd <pid> Thread.dump_to_file -format=json
"VirtualThread-1" #25 virtual
   java.lang.VirtualThread.park(VirtualThread.java:582)
   java.util.concurrent.locks.LockSupport.park(LockSupport.java:371)
   ...
   at com.example.service.UserService.findUser(UserService.java:42)
   - pinned on <0x00000007abc12345> (a java.lang.Object)

// JVM Output when using: -Djdk.tracePinnedThreads=full
Thread[ForkJoinPool-1-worker-1,5,CarrierThreads]
    com.example.service.UserService.findUser(UserService.java:42) <== monitors:1

Root cause

The JVM 21 runtime cannot unmount a virtual thread from its carrier when the virtual thread enters a monitor (synchronized block or method). If I/O blocking occurs while holding that monitor — such as a database call or REST request — the entire OS carrier thread blocks. With a ForkJoinPool carrier pool of typically CPU_CORES threads, just a handful of pinned virtual threads can starve the entire scheduling pool.

Resolution

  1. Replace every synchronized block and method with java.util.concurrent.locks.ReentrantLock — virtual threads can yield while waiting to acquire it.

  2. Remove long-lived ThreadLocal usage from async/virtual thread paths; replace with ScopedValue (Preview in Java 21, stable in Java 23).

  3. Upgrade JDBC drivers to Loom-aware versions: PostgreSQL 42.6+, MySQL Connector/J 8.3+, Oracle 23.3+.

  4. Add -Djdk.tracePinnedThreads=full in staging environments to identify all pinning sites before they hit production.

  5. Set spring.threads.virtual.enabled=true in Spring Boot 3.2+ and monitor carrier thread saturation with JFR or Micrometer.

Production implementation
SafeJava 21
// ❌ PINNING TRAP — synchronized blocks pin the carrier thread
class="hi-ann">@Service
public class UserService {
    private final Object lock = new Object();

    public User findUser(Long id) {
        synchronized (lock) {
            // repository.findById() blocks on JDBC I/O
            // The entire carrier thread is now pinned!
            return repository.findById(id).orElseThrow();
        }
    }
}

// ✅ LOOM-SAFE FIX — ReentrantLock allows virtual threads to yield
class="hi-ann">@Service
public class UserService {
    private final ReentrantLock lock = new ReentrantLock();

    public User findUser(Long id) {
        lock.lock();  // Virtual thread parks here, carrier is FREE to run others
        try {
            return repository.findById(id).orElseThrow();
        } finally {
            lock.unlock();
        }
    }
}

// ✅ MODERN PATTERN — ScopedValue instead of ThreadLocal
// application.yml
// spring:
//   threads:
//     virtual:
//       enabled: true  # Enable virtual threads globally in Boot 3.2+

Deep dive

How Carrier Thread Scheduling Works

Java 21's virtual thread scheduler is a work-stealing ForkJoinPool. By default it creates CPU_CORES carrier (platform) threads. When a virtual thread calls a blocking operation — network I/O, file read, Thread.sleep() — the JVM unmounts it from the carrier and parks it. The carrier is immediately free to pick up another virtual thread from the run queue.

This cooperative scheduling is what makes one million concurrent virtual threads feasible. A carrier does real CPU work continuously; it never blocks. Pinning breaks this contract.

Identifying Pinning in Production

Use -Djdk.tracePinnedThreads=full in staging to log every pinning event with a full stack trace. In production, use Java Flight Recorder (JFR) — it has a built-in VirtualThreadPinned event with zero-allocation overhead. Alert on it in your observability stack.

Third-Party Library Risk

Many mature libraries — connection pools, caching layers, messaging clients — heavily use synchronized. HikariCP 5.1.0+ is Loom-aware. For others, audit with JFR before enabling virtual threads in production. A single pinning hot-path in a dependency can silently cap your throughput.

ScopedValue: The ThreadLocal Successor

ThreadLocal works with virtual threads but carries a memory cost: each virtual thread gets its own copy of the value, and with millions of virtual threads that can mean significant heap pressure. ScopedValue is immutable and structurally scoped to a call tree — lower memory, safer semantics, and zero copy-on-inherit cost.

Best practices

  1. Audit all third-party dependencies with JFR VirtualThreadPinned events before going live — libraries are often the biggest source of hidden pinning.

  2. Treat synchronized as a code smell in virtual-thread-heavy services; enforce a linting rule in your CI pipeline.

  3. Keep carrier pool size as Runtime.getRuntime().availableProcessors() (the default). Increasing it masks pinning bugs rather than fixing them.

FAQ

What causes Virtual Thread Pinning in Spring Boot 3.2+ in Spring Boot 3?
The JVM 21 runtime cannot unmount a virtual thread from its carrier when the virtual thread enters a monitor (synchronized block or method). If I/O blocking occurs while holding that monitor — such as a database call or REST request — the entire OS carrier thread blocks. With a ForkJoinPool carrier pool of typically CPU_CORES threads, just a handful of pinned virtual threads can starve the entire scheduling pool.
How do I fix Virtual Thread Pinning in Spring Boot 3.2+?
Replace every synchronized block and method with java.util.concurrent.locks.ReentrantLock — virtual threads can yield while waiting to acquire it. Remove long-lived ThreadLocal usage from async/virtual thread paths; replace with ScopedValue (Preview in Java 21, stable in Java 23). Upgrade JDBC drivers to Loom-aware versions: PostgreSQL 42.6+, MySQL Connector/J 8.3+, Oracle 23.3+. Add -Djdk.tracePinnedThreads=full in staging environments to identify all pinning sites before they hit production. Set spring.threads.virtual.enabled=true in Spring Boot 3.2+ and monitor carrier thread saturation with JFR or Micrometer.
Best practice #1 for preventing Java 21 · Project Loom errors?
Audit all third-party dependencies with JFR VirtualThreadPinned events before going live — libraries are often the biggest source of hidden pinning.
Best practice #2 for preventing Java 21 · Project Loom errors?
Treat synchronized as a code smell in virtual-thread-heavy services; enforce a linting rule in your CI pipeline.
Best practice #3 for preventing Java 21 · Project Loom errors?
Keep carrier pool size as Runtime.getRuntime().availableProcessors() (the default). Increasing it masks pinning bugs rather than fixing them.

Production Readiness & Observability for Virtual Thread Pinning

Virtual threads arrived in Java 21 as a transformative concurrency primitive, but moving them from a green-field demo into a hardened production service demands more than simply flipping spring.threads.virtual.enabled=true and calling it a day. Pinning — the condition where a virtual thread is stuck to its carrier platform thread because it entered a synchronized block or called a native method — can silently drain the platform thread pool under load, producing latency spikes that look indistinguishable from ordinary slow queries.

⚙ Detecting Pinning Events in Real Time

The JVM ships with a purpose-built mechanism: the jdk.VirtualThreadPinned JFR event. Enabling it costs almost nothing in production and captures the stack trace, duration, and carrier thread identity every time a pin occurs. Combine it with a continuous JFR recording profile and pipe the output into JDK Mission Control or a Micrometer JFR integration so spikes surface directly on your existing dashboards.

Spring Boot 3.2+ exposes a VirtualThreadTaskExecutorMetrics binder; wire it up and track the executor.queued and executor.active gauges — a queue that grows alongside flat active counts is the fingerprint of a pinning storm.

🔬

Carrier Thread Pool Sizing

The virtual thread carrier pool defaults to the number of available processors and is intentionally not user-configurable. Use -Djdk.virtualThreadScheduler.parallelism=N only as a temporary bridge while you audit third-party libraries — document it in your runbook and set a deadline to remove it.

📦

Library Audit Strategy

The most common pinning sources are JDBC drivers, cryptographic providers, and logging appenders. Run with -Djdk.tracePinnedThreads=full in staging under replayed production traffic. Triage by frequency and duration — a codec called once at startup is harmless; one inside every DB round-trip is critical.

🛡

Graceful Degradation

Instrument HTTP client and database statement timeouts aggressively. Set spring.datasource.hikari.connection-timeout conservatively and back it with a Resilience4j CircuitBreaker so a downstream slowdown cannot cascade into platform thread exhaustion.

Pair aggressive timeouts with structured logging that emits the virtual thread name on every log line (%thread in Logback already captures it) and you gain the correlation ID trail you need to reconstruct an incident post-mortem. Teams that wire in JFR, Micrometer gauges, and circuit breakers before go-live consistently report that their first pinning incident is a dashboard annotation rather than a 2 AM page.

Kodivio Team
Kodivio Team

AI & Web Development Specialists

The Kodivio team covers AI tools, automation, and modern web development based on real-world testing and hands-on experience.

Learn more about us →