Java Virtual Threads: Scale Blocking Work with Admission Control
Use Java virtual threads for high-throughput blocking work, limit scarce resources with semaphores, and diagnose the pinning that remains in JDK 25.
Virtual threads let a Java service keep a straightforward thread-per-task design while supporting far more concurrent waiting tasks than a platform-thread pool. They improve throughput when tasks spend substantial time blocked on I/O; they do not make CPU work faster or reduce one request’s latency. Create one virtual thread per task, then limit access to scarce downstream resources separately.
Virtual threads became final in JDK 21. This article targets JDK 25, where blocking while holding a Java monitor no longer pins a virtual thread. That changed in JDK 24, so advice based on JDK 21 that replaces every synchronized block to avoid pinning is now out of date.
A virtual thread represents a task
A platform thread occupies an operating-system thread throughout its life. A virtual thread runs on a platform thread called its carrier, but can unmount when it blocks and let the carrier run another virtual thread. The JDK 25 virtual-thread guide describes this scheduling model and recommends virtual threads for high-throughput applications with many waiting tasks.
Use the per-task executor rather than a fixed pool:
import java.time.Duration;
import java.util.concurrent.Executors;
public class WaitingTasks {
static String fetch(int id) throws InterruptedException {
Thread.sleep(Duration.ofMillis(20)); // stands in for blocking I/O
return "item-" + id + " on virtual=" + Thread.currentThread().isVirtual();
}
public static void main(String[] args) throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var first = executor.submit(() -> fetch(1));
var second = executor.submit(() -> fetch(2));
System.out.println(first.get());
System.out.println(second.get());
}
}
}
Compile and run it on JDK 21 or later:
javac WaitingTasks.java
java WaitingTasks
Both result lines end in virtual=true. Closing the ExecutorService waits for submitted tasks to finish. The factory creates a new virtual thread for every task; it is not a pool of reusable virtual workers.
Throughput and latency are different outcomes
A sleeping or I/O-bound virtual thread normally releases its carrier, so a small carrier set can support many pending operations. That can raise total completed requests per unit of time when platform threads were the bottleneck. It does not shorten the sleep, network round trip, database query, or CPU calculation inside one task.
Long-running CPU-bound tasks still compete for the same processors. Moving them from platform threads to virtual threads adds no cores. Keep CPU parallelism near the machine’s useful parallel capacity, and measure before changing an executor used primarily for computation.
Virtual threads also work best with synchronous blocking APIs. Wrapping an already asynchronous pipeline in virtual threads usually adds another concurrency model without recovering the readable thread-per-task control flow that virtual threads are meant to enable.
Limit the resource, not the threads
Creating a million virtual threads does not create a million database connections or enlarge an upstream service’s capacity. A fixed thread pool used as an admission gate mixes two policies: how tasks are represented and how many may use a resource. With virtual threads, keep one thread per task and place the bound at the constrained operation.
import java.util.concurrent.Semaphore;
final class LimitedService {
private final Semaphore permits = new Semaphore(10);
String call() throws InterruptedException {
permits.acquire();
try {
return callRemoteService();
} finally {
permits.release();
}
}
private String callRemoteService() {
return "ok";
}
}
The finally block is essential: otherwise exceptions leak permits and eventually stop all callers. A database connection pool already provides an admission limit, so an extra semaphore around that same pool can duplicate the queue without increasing safety.
Admission control also prevents a fast producer from creating an unbounded amount of pending downstream work. Virtual threads make blocked tasks cheap relative to platform threads, not free; their stacks, task objects, captured data, and results still consume memory.
Pinning changed in JDK 24
When a virtual thread is pinned during a blocking operation, it cannot unmount and its carrier remains occupied. JDK 21 could pin while code blocked inside synchronized, in a native method, or in a foreign function. JEP 491 changed monitor implementation in JDK 24 so virtual threads can unmount while holding monitors. In JDK 25, the official guide lists native methods and foreign functions as the remaining pinning cases.
This version boundary matters during reviews. On JDK 21 through 23, a frequently executed monitor that surrounds long blocking I/O can justify migration to ReentrantLock. On JDK 24 and later, do not replace clear synchronized code merely because of old pinning guidance. Native or foreign calls can still capture carriers, so measure those boundaries.
Diagnose before redesigning
JDK Flight Recorder exposes jdk.VirtualThreadPinned, enabled by default with a 20 ms threshold in JDK 25. Record the application under realistic load, then inspect relevant events:
java -XX:StartFlightRecording:filename=virtual.jfr,dumponexit=true MyService
jfr print --events jdk.VirtualThreadPinned,jdk.VirtualThreadSubmitFailed virtual.jfr
jdk.VirtualThreadSubmitFailed can reveal resource failures while starting or unparking virtual threads. For a snapshot of live virtual and platform threads, use the virtual-thread-aware dump command:
jcmd <pid> Thread.dump_to_file -format=json threads.json
Do not infer scalability from thread count alone. Measure completed work, queue time, downstream saturation, memory, CPU, and pin events together. A good virtual-thread migration leaves blocking code simple, represents each concurrent task with its own virtual thread, and puts explicit limits only around resources that actually need them.