Back to Java 21+ Features
Q2Question 2 of 10

What is virtual thread pinning?

Key Concepts
  • Pinning occurs when a virtual thread cannot unmount from its carrier during a blocking operation
  • In Java 21 two things cause pinning: synchronized blocks and JNI native calls
  • When pinned the carrier thread is blocked, starving other virtual threads waiting for it
  • Replace synchronized with ReentrantLock on code paths that contain blocking I/O
  • Java 24 (JEP 491) removes synchronized pinning by reimplementing monitors
  • Detect pinning with -Djdk.tracePinnedThreads=short or JFR event jdk.VirtualThreadPinned
  • Keep critical sections short and free of blocking I/O regardless of lock type

Definition

Pinning is when a virtual thread cannot unmount from its carrier during a blocking call, typically because it is inside a synchronized block or a JNI call.
  • Pinning – the condition where a virtual thread is stuck on its carrier platform thread and cannot be unmounted when it blocks on I/O.
  • Cause 1: synchronized – in Java 21 the JVM monitor implementation requires the thread to stay mounted while holding a monitor lock, so any blocking I/O inside a synchronized block pins the carrier.
  • Cause 2: JNI – native code executed through JNI cannot be suspended, so the virtual thread remains mounted for the entire native call.
  • Impact – a pinned carrier cannot serve other virtual threads, reducing the effective carrier pool size and degrading throughput.
  • Fix – replace synchronized with ReentrantLock for code paths that contain blocking I/O; ReentrantLock supports virtual thread unmounting.

A bank teller (carrier) handling numbered tokens (virtual threads) can switch between waiting customers. But if a customer insists on holding the teller's hand during a phone call (synchronized block with blocking I/O), the teller is pinned and cannot serve anyone else. The fix is a walkie-talkie system (ReentrantLock) where the teller hands the customer a walkie-talkie and moves on, returning when the call finishes.

Understanding the Concepts

What is virtual thread pinning?

Pinning happens when a virtual thread cannot be unmounted from its carrier OS thread while it is blocked. Normally, when a virtual thread blocks on I/O, the JVM detaches it from the carrier and mounts another runnable virtual thread. Pinning prevents this detachment, so the carrier sits idle (or rather, blocked) for the duration of the I/O call.

In Java 21, two situations cause pinning. The first is blocking inside a synchronized block or method. The JVM's monitor (intrinsic lock) implementation requires the thread to stay mounted while holding the lock. The second is a JNI native call, where native code cannot be suspended by the JVM scheduler.

Why does pinning matter in production?

The JVM maintains a small pool of carrier threads (typically one per CPU core). If several virtual threads are pinned simultaneously, the carrier pool shrinks. When all carriers are pinned, no other virtual thread can run, and the application appears to hang.

Consider a banking transfer service with 8 carriers and 10,000 virtual threads. If 8 virtual threads enter a synchronized method that makes a database call, all 8 carriers are pinned. The remaining 9,992 virtual threads are starved until one of the database calls completes.

JAVA
package com.bank.service;

import java.math.BigDecimal;

public class AccountService {

    // WRONG: synchronized + blocking I/O = pinning
    public synchronized BigDecimal getBalance(String accountId) {
        // This database call blocks the carrier for the entire query
        return jdbcTemplate.queryForObject(
            "SELECT balance FROM accounts WHERE id = ?",
            BigDecimal.class, accountId);
    }
}

How do you fix pinning?

Replace synchronized with java.util.concurrent.locks.ReentrantLock. A virtual thread blocked on a ReentrantLock CAN be unmounted from its carrier.

JAVA
package com.bank.service;

import java.math.BigDecimal;
import java.util.concurrent.locks.ReentrantLock;

public class AccountService {

    private final ReentrantLock lock = new ReentrantLock();

    // CORRECT: ReentrantLock allows unmounting
    public BigDecimal getBalance(String accountId) {
        lock.lock();
        try {
            return jdbcTemplate.queryForObject(
                "SELECT balance FROM accounts WHERE id = ?",
                BigDecimal.class, accountId);
        } finally {
            lock.unlock();
        }
    }
}
TEXT
Before (synchronized): 8 carriers pinned, 9992 VTs starved
After (ReentrantLock):  8 carriers free to serve other VTs during DB call

How has pinning changed across Java versions?

| Version | synchronized Behavior | JNI Behavior |
|---|---|---|
| Java 21 | Pins the carrier | Pins the carrier |
| Java 22-23 | Pins the carrier | Pins the carrier |
| Java 24 (JEP 491) | No longer pins | Pins the carrier |

Java 24 reimplements monitors so that a virtual thread holding a monitor can unmount when it blocks. This removes the most common source of pinning. However, JNI calls still pin in all versions.

Even after Java 24, ReentrantLock remains preferred for new code because it offers features synchronized lacks: tryLock() with timeouts, interruptible locking, and multiple Condition objects.

How do you detect pinning?

Two detection methods are available.

The first is the JVM flag -Djdk.tracePinnedThreads=short (or =full). When a virtual thread is pinned, the JVM prints a stack trace to stderr showing where the pinning occurred.

BASH
java -Djdk.tracePinnedThreads=short -jar bank-service.jar
TEXT
Thread[#42,VirtualThread[#42]/runnable,5,CarrierThreads]
    com.bank.service.AccountService.getBalance(AccountService.java:12)
    <== monitors:1

The second is JDK Flight Recorder (JFR). The event jdk.VirtualThreadPinned is recorded whenever a virtual thread is pinned longer than a threshold. This is production-safe and does not require JVM restarts.

BASH
java -XX:StartFlightRecording=filename=recording.jfr -jar bank-service.jar
# Then analyze with JDK Mission Control

What are the rules for avoiding pinning?

  • Never put blocking I/O (JDBC, HTTP, file read, Thread.sleep) inside a synchronized block.
  • Use ReentrantLock when you need mutual exclusion around code that may block.
  • Keep synchronized sections short and CPU-only (in-memory operations).
  • Review third-party libraries for synchronized on I/O paths (many older libraries use synchronized internally).
  • Use JFR monitoring in production to detect pinning from library code.

What are common mistakes?

JAVA
// WRONG: synchronized on the entire method
public synchronized void processTransfer(String from, String to, BigDecimal amount) {
    BigDecimal balance = fetchBalance(from);    // I/O: pins carrier
    debit(from, amount);                         // I/O: still pinned
    credit(to, amount);                          // I/O: still pinned
}

// CORRECT: No lock around I/O, lock only the critical in-memory section
private final ReentrantLock balanceLock = new ReentrantLock();

public void processTransfer(String from, String to, BigDecimal amount) {
    BigDecimal balance = fetchBalance(from);    // I/O: no lock, VT unmounts freely
    balanceLock.lock();
    try {
        validateBalance(balance, amount);        // in-memory: fast
    } finally {
        balanceLock.unlock();
    }
    debit(from, amount);                         // I/O: no lock
    credit(to, amount);                          // I/O: no lock
}

What should a senior engineer know?

  • Pinning detection should be part of the CI pipeline: run integration tests with -Djdk.tracePinnedThreads=short and fail the build if pinning is logged.
  • JDBC drivers (HikariCP connection acquisition) previously used synchronized. Newer versions have been updated for virtual thread compatibility.
  • Spring Boot 3.2+ with spring.threads.virtual.enabled=true works with virtual threads, but you must verify that all middleware and connection pools are pinning-free.
  • The carrier pool can be oversized with -Djdk.virtualThreadScheduler.parallelism=N as a temporary workaround while fixing pinning, but this wastes memory.

What are common interview follow-up questions?

  • Why not just use ReentrantLock everywhere? (synchronized is simpler, auto-released, and sufficient when there is no blocking I/O inside the critical section.)
  • Does pinning cause deadlock? (No, pinning causes carrier starvation, not deadlock. The pinned virtual thread will eventually complete its I/O.)
  • Can you detect pinning at compile time? (No. It requires runtime analysis because the JVM must know which code path includes blocking I/O.)

Visual Overview

DIAGRAM
  Normal Virtual Thread (no pinning):
  ┌──────┐     ┌──────────┐     ┌──────┐
  │ VT-1 │────▶│ Carrier-1│────▶│ I/O  │
  │      │     │          │     │block │
  │      │◀────│ unmount  │     │      │
  │parked│     │          │     │      │
  │      │     │ mount    │     │      │
  │ VT-5 │────▶│ Carrier-1│     │      │
  └──────┘     └──────────┘     └──────┘
  Carrier-1 serves VT-5 while VT-1 waits.

  Pinned Virtual Thread (synchronized + I/O):
  ┌──────┐     ┌──────────┐     ┌──────┐
  │ VT-1 │────▶│ Carrier-1│────▶│ I/O  │
  │synch │     │  PINNED  │     │block │
  │      │     │  cannot  │     │      │
  │      │     │  unmount │     │      │
  │ VT-5 │     │          │     │      │
  │WAIT  │     │  blocked │     │      │
  └──────┘     └──────────┘     └──────┘
  Carrier-1 is stuck. VT-5 must wait for another carrier.

Key Takeaway

Thread pinning is the primary obstacle to virtual thread performance. It occurs when a virtual thread cannot unmount from its carrier during a blocking operation, most commonly because it is inside a synchronized block that contains I/O. When pinned, the carrier thread is blocked and unavailable to serve other virtual threads, shrinking the effective carrier pool.

The fix is straightforward: replace synchronized with ReentrantLock on any code path that contains blocking I/O. A virtual thread blocked while waiting for a ReentrantLock can be unmounted from its carrier, so the carrier remains available. Keep synchronized for short, CPU-only critical sections where it remains simpler and appropriate.

Java 24 (JEP 491) removes the synchronized pinning limitation entirely by reimplementing monitors to support virtual thread unmounting. However, ReentrantLock remains preferred for new code because it offers tryLock with timeouts, interruptible locking, and multiple Condition objects. JNI calls still cause pinning in all versions.

Detect pinning with -Djdk.tracePinnedThreads=short during development and with JFR's jdk.VirtualThreadPinned event in production. In a banking system, a TransferService that guards account updates with synchronized and makes a blocking database call inside it pins carriers under load. Switching to ReentrantLock around only the in-memory validation, with I/O calls outside any lock, restores full virtual thread scalability.

Remember one sentence: pinning happens when synchronized plus blocking I/O prevents a virtual thread from unmounting, and the fix is ReentrantLock.

---

Share
Use ← → arrow keys to navigate