 Java, Spring and Web Development tutorials  1. Introduction
With Virtual threads, we can scale application performance for any I/O-intensive workflows. By contrast, with the platform threads, we had to carefully manage the expensive operating system resources and utilize non-blocking I/O, which involved complicated asynchronous code like CompletableFuture.
Even though virtual threads solve the scarcity problem with limited operating system threads, certain scenarios can cause platform thread blocking.
In this tutorial, we’ll learn different pinning scenarios with an example code. We’ll also debug and implement the fix for one such example. We’ll also learn the scenarios resolved in JDK 24.
2. Virtual Thread Pinning Scenarios
Virtual threads are a short-lived, lightweight thread construct that gets mounted onto the platform “carrier” thread by the JVM scheduler. It then executes the task and unmounts from the platform thread. The platform thread becomes available for the next task.
However, there are situations where the virtual thread and the corresponding carrier thread get blocked for a longer duration.
While this does not affect the business logic, it hampers the application’s scalability. A few common reasons are running heavy CPU-related tasks, waiting while holding a lock, or being blocked within a native method execution.
Though we should avoid using the virtual threads for any CPU-intensive operation. We’ll still need to understand other scenarios.
2.1. Synchronized Method or Block
Let’s imagine a real-world example of adding products to the shopping cart.
We’ll implement the CartService class with an update method with the productId as a lock:
public class CartService {
private final Map<String, Integer> products;
private final Map<String, Object> locks = new ConcurrentHashMap<>();
public void update(String productId, int quantity) {
Object lock = locks.computeIfAbsent(productId, k -> new Object());
synchronized (lock) {
simulateAPI();
products.merge(productId, quantity, Integer::sum);
}
LOGGER.info("Updated Cart for {} {}", productId, quantity);
}
}
In the above code, we’re calling a simulated API instead of an actual one for demonstration purposes.
We’ll simulate the downstream API call with a thread sleep method:
private void simulateAPI() {
try {
Thread.sleep(50);
} catch (InterruptedException ex) {
throw new RuntimeException(ex);
}
}
Next, we’ll try to debug the above code.
2.2. Debug Pinning
Let’s test the above code using the Java flight recorder.
We’ll verify the pinning by running the CartService update method inside a virtual thread and assert the JFR events:
@Test
void givenJFRIsEnabled_whenVThreadIsBlocked_thenDetectVThreadPinned() throws Exception {
Path file = Path.of("pinning.jfr");
try (Recording recording = new Recording()) {
recording.enable("jdk.VirtualThreadPinned")
.withThreshold(Duration.ofMillis(1));
recording.start();
Thread th = Thread.ofVirtual().start(() ->
cartService.update("test1", 2));
th.join();
recording.stop();
recording.dump(file);
}
try (RecordingFile rf = new RecordingFile(file)) {
assertTrue(rf.hasMoreEvents());
while (rf.hasMoreEvents()) {
RecordedEvent event = rf.readEvent();
System.out.println(event);
assertEquals("jdk.VirtualThreadPinned", event.getEventType().getName());
assertEquals("Virtual Thread Pinned", event.getEventType().getLabel());
}
}
Files.delete(file);
}
In the above test, we assert that the RecordedEvent includes the jdk.VirtualThreadPinned event.
Also, we can see the jdk.VirtualThreadPinned event in the console log:
jdk.VirtualThreadPinned {
startTime = 13:28:30.738 (2026-03-29)
duration = 101 ms
eventThread = "" (javaThreadId = 32, virtual)
}
Additionally, we can view the pinned event in the JDK Mission Control dashboard:
Alternatively, we can detect pinning using the -Djdk.tracePinnedThreads=full VM flag:
VirtualThread[#21]/runnable@ForkJoinPool-1-worker-1 reason:MONITOR
java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:199)
java.base/jdk.internal.vm.Continuation.onPinned0(Continuation.java:393)
java.base/java.lang.VirtualThread.parkNanos(VirtualThread.java:635)
java.base/java.lang.VirtualThread.sleepNanos(VirtualThread.java:807)
java.base/java.lang.Thread.sleep(Thread.java:507)
com.baeldung.virtualthread.synchronize.CartService.simulateAPI(CartService.java:35)
com.baeldung.virtualthread.synchronize.CartService.update(CartService.java:22) <== monitors:1
The above logs confirm that the platform thread is blocked.
Additionally, any code with Method-level synchronization, synchronization with wait, or the LockSupport.park method causes the pinning.
2.3. Native Method
The pinning can also occur in the native method calls. The native method can block either for any I/O operation or callbacks to Java code, which in turn blocks on a monitor.
We’ll implement a simple native method:
public class NativeDemo {
static {
System.loadLibrary("native-lib");
}
public native String nativeCall();
}
The reason for the pinning is that the thread has no control on native stack and needs to wait for the native method call to return.
Similarly, we can expect the same when running the native function using the Foreign Function API:
public void execute() {
LOGGER.info("Running foreign function sleep...");
Linker linker = Linker.nativeLinker();
SymbolLookup stdlib = linker.defaultLookup();
MethodHandle sleep = linker.downcallHandle(stdlib.find("sleep")
.orElseThrow(), FunctionDescriptor.of(JAVA_INT, JAVA_LONG));
try {
sleep.invoke(100);
} catch (Throwable ex) {
throw new RuntimeException(ex);
}
}
In the above code, the foreign function sleep method is defined by first creating the Linker object and then including the downcall MethodHandle instance.
As of JDK 21/24, JFR does not emit the jdk.VirtualThreadPinned event for blocking that occurs within native or FFM code.
Still, we can confirm the pinning by analyzing the thread dump and its impact on other virtual threads.
2.4. Static Initializer Block
Let’s imagine a class with a static initializer block.
We’ll implement a static initializer block with a Thread.sleep method to block the platform thread:
public class HeavyClass {
static {
try {
Thread.sleep(100);
} catch (InterruptedException ex) {
throw new RuntimeException(ex);
}
LOGGER.info("static initialization done");
}
}
In the above code, we expect the virtual thread to get pinned as the static initializer holds an intrinsic lock.
2.5. Custom Class Loader
We’ll implement a custom class loader to verify virtual thread pinning in the above scenario.
First, let’s implement a CustomClassLoader class by extending the ClassLoader class and overriding the findClass method:
class CustomClassLoader extends ClassLoader {
private final Path classDir;
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
LOGGER.info("Finding class for {}", name);
try {
Path file = classDir.resolve(name.replace('.', '/') + ".class");
byte[] bytes = java.nio.file.Files.readAllBytes(file);
Thread.sleep(100);
return defineClass(name, bytes, 0, bytes.length);
} catch (InterruptedException | IOException ex) {
LOGGER.error("Error while finding class file {}", ex.getMessage());
throw new ClassNotFoundException(ex.getMessage(), ex);
}
}
}
Then, we’ll need to implement the loadClass method to override the system classloader:
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
LOGGER.info("Load class for {}", name);
Class<?> clazz = findLoadedClass(name);
if (clazz == null) {
try {
clazz = findClass(name);
} catch (ClassNotFoundException ex) {
clazz = super.loadClass(name, resolve);
}
}
if (resolve) {
resolveClass(clazz);
}
return clazz;
}
We’ll run a similar test for this class loader with the JFR recording enabled and verify the pinned thread event.
This is also true for any virtual thread that uses the same class while another thread initializes it.
3. Implement Synchronization Without Pinning
Although the JVM tries to compensate for the pinning by temporarily increasing the parallelism of the virtual thread scheduler, subjected to the default maximum of 256.
Still, we can overcome this with Java’s advanced concurrency control support.
We can use the Loom-aware locking mechanism, such as the ReentrantLock or ReadWriteLock class. The lock implementation within the java.util.concurrent.locks package does not cause the pinning.
We’ll implement the above CartService’s update method using the ReentrantLock lock:
public void update(String productId, int quantity) {
Lock lock = locks.computeIfAbsent(productId, k -> new ReentrantLock());
try {
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
try {
simulateAPI();
products.merge(productId, quantity, Integer::sum);
} finally{
lock.unlock();
}
LOGGER.info("Updated Cart for {} {}", productId, quantity);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
If we run the earlier test again with the fixed version, we don’t observe any thread pinning event and blocking in the other threads.
Another approach to resolving the unnecessary pinning is to upgrade to JDK 24 or newer versions, where developers have fixed the issue.
We recommend upgrading the Java version if the upgrading effort is significantly smaller than reimplementing the code across services.
4. Pinning Scenarios Resolved in JDK 24
As part of JEP-491, the pinning issue is resolved for the synchronize method and block.
The Java team changed the implementation of the synchronized keyword, and now the virtual thread can acquire, hold, and release the lock independently of its carrier thread.
The system still expects pinning in the native method, class loader, and class initializer.
5. Benchmark
We’ll implement a JMH benchmark test for the earlier CartService update method with the AverageTime and Throughput modes:
@BenchmarkMode({ Mode.AverageTime, Mode.Throughput })
@OutputTimeUnit(TimeUnit.SECONDS)
@Warmup(iterations = 1, time = 5, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 3, time = 5, timeUnit = TimeUnit.SECONDS)
@Fork(value = 2)
@State(Scope.Benchmark)
public class BenchmarkVirtualThread {
private final CartService cartService = new CartService();
@Param({"100", "1000", "10000"})
private int CONCURRENCY;
@Benchmark
public void benchmark() throws InterruptedException, IOException {
List<Thread> threads = new ArrayList<>();
IntStream.range(0, CONCURRENCY).forEach(i ->
threads.add(Thread.startVirtualThread(() -> cartService.update(UUID.randomUUID().toString(), 2))));
threads.forEach(th -> {
try {
th.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
}
}
In the above test, we’re using the benchmark parameter for concurrency control, and iterating the method 3 times after the initial warm-up.
We’ll now execute the above benchmark test in both JDK 21 and 25 versions.
First, we’ll see the performance report for JDK 21 version:
Benchmark (CONCURRENCY) Mode Cnt Score Error Units
BenchmarkVirtualThread.benchmark 100 thrpt 6 2.081 ± 0.008 ops/s
BenchmarkVirtualThread.benchmark 1000 thrpt 6 0.214 ± 0.058 ops/s
BenchmarkVirtualThread.benchmark 10000 thrpt 6 0.023 ± 0.001 ops/s
BenchmarkVirtualThread.benchmark 100 avgt 6 0.479 ± 0.011 s/op
BenchmarkVirtualThread.benchmark 1000 avgt 6 4.468 ± 0.033 s/op
BenchmarkVirtualThread.benchmark 10000 avgt 6 44.056 ± 0.279 s/op
Then, let’s confirm the report for JDK 25 version:
Benchmark (CONCURRENCY) Mode Cnt Score Error Units
BenchmarkVirtualThread.benchmark 100 thrpt 6 18.392 ± 0.206 ops/s
BenchmarkVirtualThread.benchmark 1000 thrpt 6 10.061 ± 0.170 ops/s
BenchmarkVirtualThread.benchmark 10000 thrpt 6 1.005 ± 0.029 ops/s
BenchmarkVirtualThread.benchmark 100 avgt 6 0.058 ± 0.015 s/op
BenchmarkVirtualThread.benchmark 1000 avgt 6 0.108 ± 0.036 s/op
BenchmarkVirtualThread.benchmark 10000 avgt 6 0.962 ± 0.030 s/op
From the above data, we can confirm that the throughput and average time have significantly improved in the latest JDK version.
6. Conclusion
In this article, we’ve learned. We’ve implemented a fix for the synchronized method scenario. Also, we’ve explored how, in the later version, some of the pinning issues are resolved.
As always, the example code can be found over on GitHub. The post Synchronize Virtual Thread Without Pinning first appeared on Baeldung.
Content mobilized by FeedBlitz RSS Services, the premium FeedBurner alternative. |