Understanding Carrier Thread Pinning in Java 21 Virtual Threads (And How to Catch It Before Production)

java dev.to

When Java 21 dropped, a lot of teams (including mine) were eager to flip the switch:

spring.threads.virtual.enabled=true
Enter fullscreen mode Exit fullscreen mode

The pitch for Virtual Threads (JEP 444) sounded like an architectural silver bullet: user-mode threads managed by the runtime instead of the operating system, dropping thread memory footprints from 1MB down to kilobytes, and allowing millions of concurrent tasks without complex reactive code.

And for 90% of straightforward I/O tasks, it works out of the box.

Then high load hits staging, and suddenly you notice latency spikes, HTTP timeouts, and connection pools acting weird. You check CPU and memory usage, and they look completely relaxed.

What actually happened? You ran into Carrier Thread Pinning.

Here is a practical breakdown of what pinning is, why it happens, and how to catch it before it hits your production users.


How Project Loom Actually Schedules Work

Virtual threads don't run on bare metal by themselves. The JVM mounts a virtual thread onto an underlying OS platform thread managed by an internal FIFO ForkJoinPool. That platform thread is called the Carrier Thread.

Under normal circumstances:

  1. A virtual thread runs its task on a carrier.
  2. It hits a blocking I/O operation (like an HTTP call or reading from a socket).
  3. Loom unmounts the virtual thread from the carrier thread.
  4. The carrier thread is now free to pick up another waiting virtual thread.
  5. Once the I/O operation finishes, the virtual thread is placed back in the queue and resumes on whatever carrier thread is available next.

This unmounting mechanism is why virtual threads can scale so well.

Where Things Go Wrong: Pinning

Pinning occurs when a virtual thread cannot be unmounted from its carrier during a blocking operation.

Because the virtual thread is stuck to the carrier, that OS thread cannot pick up any other work. If your server has 8 CPU cores, the default carrier pool size is usually 8. If 8 virtual threads get pinned simultaneously waiting on a slow external service or database call, your entire thread pool is starved.

Thousands of other virtual threads might be waiting in memory, but zero carrier threads are available to run them.


The Main Culprits

According to JEP 444, pinning primarily happens in two places:

1. The classic synchronized block

This is where most teams get caught. If a virtual thread acquires a monitor lock via synchronized and blocks inside, the JVM cannot unmount its execution frame:

// Risky inside high-concurrency virtual thread flows:
public synchronized byte[] fetchOrderData(String id) {
    // If this HTTP call takes 300ms, the entire carrier thread is locked up for 300ms.
    return httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()).body();
}
Enter fullscreen mode Exit fullscreen mode

Older libraries and legacy JDBC drivers frequently wrapped socket operations inside synchronized blocks.

2. Native calls (JNI / FFM)

If a virtual thread enters native code via JNI or the Foreign Function & Memory API and blocks there, it stays pinned to its carrier until the native call returns.


The Fix: Moving to ReentrantLock

The fix for monitor pinning is straightforward: swap synchronized for ReentrantLock.

The concurrency primitives inside java.util.concurrent were refactored in OpenJDK to support unmounting cleanly:

private final ReentrantLock lock = new ReentrantLock();

public byte[] fetchOrderData(String id) {
    lock.lock();
    try {
        // While waiting on I/O, the virtual thread will unmount properly.
        return httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()).body();
    } finally {
        lock.unlock();
    }
}
Enter fullscreen mode Exit fullscreen mode

(Note: OpenJDK's Loom team has been working on removing the synchronized pinning restriction in newer builds via JEP 491, but if you run Java 21 LTS, this remains an active concern).


How to Detect Pinning in Your Applications

You do not want to wait for thread starvation to show up in production logs. Here are the three practical ways to spot it:

1. JVM Flags for Quick Checks

When testing locally or in a staging environment, start your service with:

java -Djdk.tracePinnedThreads=short -jar my-service.jar
Enter fullscreen mode Exit fullscreen mode
  • short: Prints single-line notifications showing the method that pinned the carrier.
  • full: Dumps full stack traces.

Keep in mind that full can easily spam your logs if an active pinning loop is present.

2. JDK Flight Recorder (JFR)

If you use JFR in production, look for the event:

jdk.VirtualThreadPinned
Enter fullscreen mode Exit fullscreen mode

You can profile your application and inspect the recordings in JDK Mission Control or Grafana to see which classes trigger the event most often.

3. Real-Time Telemetry & Actuator Metrics (LoomDoctor)

JVM flags are fine for manual debugging, and JFR is great for post-mortems, but in microservice architectures you usually want live metrics, Prometheus alerts, and health indicators.

To solve this for our services, I created an open-source diagnostics tool called LoomDoctor.

It hooks into the runtime to track carrier pinning occurrences, monitors carrier pool saturation, and exposes the data through a native Spring Boot Actuator endpoint (/actuator/loomdoctor):

{"status":"WARNING","carrierThreads":{"total":8,"active":7,"pinned":5},"starvationRisk":"HIGH","pinningRatio":"62.5%","detectedPinningSources":[{"class":"com.example.service.LegacyPaymentService","method":"processTransaction","cause":"SYNCHRONIZED_MONITOR","occurrences":1420}]}
Enter fullscreen mode Exit fullscreen mode

If you want to see how pinning and pool starvation behave in real time without setting up code, I also put together an Interactive Virtual Thread Simulator in my portfolio labs.


Quick Production Checklist

Before rolling out spring.threads.virtual.enabled=true across critical services:

  1. Check legacy dependencies: Verify that third-party SDKs or older drivers don't use synchronized around network calls.
  2. Do not oversize connection pools: Virtual threads do not mean your database can handle 50,000 active connections. Size your HikariCP pool based on database capacity, not thread count.
  3. Use ReentrantLock where locks surround blocking operations.
  4. Instrument carrier pools: Add alerts on carrier thread saturation, not just CPU and heap usage.

Have you hit carrier thread pinning in your Java 21 projects yet? How did your team catch it?

Source: dev.to

arrow_back Back to Tutorials