Baeldung

Java, Spring and Web Development tutorials

 

Synchronize Virtual Thread Without Pinning
2026-05-11 23:46 UTC by Saikat Chakraborty

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:

Vortua;_Thread_Pinned_Image

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.