Pinning and Best Practices

Pinning

A virtual thread is pinned to its carrier (cannot unmount) when it blocks inside a synchronized block or a native method. In Java 21 this wastes a carrier thread and can starve others. (Java 24, JEP 491, removed the synchronized pinning, but Java 21 LTS still has it.)

// Java 21: pins the carrier during the HTTP call
synchronized (this) {
    response = httpClient.send(request, BodyHandlers.ofString());
}

// Better on Java 21: ReentrantLock lets the virtual thread unmount
lock.lock();
try {
    response = httpClient.send(request, BodyHandlers.ofString());
} finally {
    lock.unlock();
}

Example

Detect pinning while testing:

java -Djdk.tracePinnedThreads=full -jar app.jar

Example output

Thread[#29,ForkJoinPool-1-worker-1,5,CarrierThreads]
    java.base/java.lang.VirtualThread.parkOnCarrierThread(VirtualThread.java:...)
    com.shop.PriceClient.fetch(PriceClient.java:42) <== monitors:1

Best Practices

  • Do not pool virtual threads. Use newVirtualThreadPerTaskExecutor().
  • Limit access to scarce resources (DB connections, rate-limited APIs) with a Semaphore or the connection pool, not by limiting threads.
  • Avoid long synchronized blocks around I/O on Java 21; use ReentrantLock.
  • Keep ThreadLocal values small; consider ScopedValue (preview in 21).
  • Do not use them for CPU-heavy work; use a fixed pool sized to the cores or parallel streams.

Spring Boot

Example

Spring Boot 3.2+ runs Tomcat request handling, @Async and scheduled tasks on virtual threads with one property (Java 21 needed).

application.properties

spring.threads.virtual.enabled=true