Skip to Content

Java Virtual Thread Pinning

Virtual Thread Pinning - What it is, how to detect it, and how to avoid it

Posted on

What is Virtual Thread Pinning?

Before we understand what thread pinning is, we need some context on how virtual threads work in Java. Until recently, there was only one kind of Thread in Java - a platform thread. For each platform thread in Java there is a corresponding thread managed by the operating system. These are comparatively expensive to create and there are a limited amount of them. It is common to see applications pool or reuse platform threads because of these limitations.

Enter Virtual Threads. These threads are not tied one-to-one to a platform thread. Instead they are “mounted” and “unmounted” from what we call a Carrier Thread, as decided by the JVM. For example, if we create a Virtual Thread and have it do some work for us, it may be mounted to one carrier thread and then unmounted when the JVM detects the virtual thread is waiting for something to happen. When the virtual thread is ready to do work again, the JVM may mount it to a completely different carrier thread. It is this kind of scheduling flexibility that allows us to use the platform/carrier threads more fully. Any time we would normally wait, we don’t need to monopolize the carrier thread. The JVM can schedule other work to be done while our virtual thread waits.

Since Virtual Threads are cheap to create and don’t take up a lot of memory, we can create as many of them as we want and throw them away when they’ve finished their task. We don’t need to pool Virtual Threads like we had to with Platform Threads.

This is great, and Virtual Threads are going to allow us to get even more performance out of Java and the JVM. However we need to be careful and watch out for Virtual Thread Pinning. This happens when a Virtual Thread is mounted to a carrier thread but the JVM is not able to unmount it.

A Virtual Thread may become pinned to its carrier thread when…

  1. It runs code in a synchronized block or method.

  2. It runs a native method or Foreign Function .

An application that exhibits virtual thread pinning isn’t bad or incorrect, but it could be suboptimal. If our code is monopolizing a carrier thread with a pinned virtual thread, that carrier thread is unavailable to perform work for other virtual threads that may be waiting. In the worst case, if we do that enough times concurrently and you’re back where you started without having virtual threads at all.

Virtual Thread pinning may not be the end of the world but it is worth watching out for and possibly preventing if we’re able.

Detecting Pinned Threads via Logging

During development and testing it’s helpful know if our code is pinning virtual threads or not. There are a couple of ways to do this. First, we can add -Djdk.tracePinnedThreads=full to the command line when run our java program. This will cause the JVM to log when it detects virtual thread pinning to standard out.

For example, here’s what prints out when a virtual thread running my doSomethingSynchronized method gets pinned.

Thread[#42,ForkJoinPool-1-worker-2,5,CarrierThreads]
    java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:183)
    java.base/jdk.internal.vm.Continuation.onPinned0(Continuation.java:393)
    java.base/java.lang.VirtualThread.parkNanos(VirtualThread.java:621)
    java.base/java.lang.VirtualThread.sleepNanos(VirtualThread.java:793)
    java.base/java.lang.Thread.sleep(Thread.java:507)
    com.ginsberg.example.pinning.Main.lambda$doSomethingSynchronized$0(Main.java:15) <== monitors:1
    java.base/java.lang.VirtualThread.run(VirtualThread.java:309)

Specifying full gives us the full stack trace, which may make it hard to find the useful line (which I have highlighted, also the log message itself contains a <== pointer). If you want a more concise version, you can specify -Djdk.tracePinnedThreads=short to get just the possibly-relevant-to-you part.

Thread[#32,ForkJoinPool-1-worker-1,5,CarrierThreads]
    com.ginsberg.example.pinning.Main.lambda$doSomethingSynchronized$0(Main.java:15) <== monitors:1

In my experiments with this it seems to only log the first time we pin a thread in each location. I suspect this is by design and I didn’t run my code long enough to determine if this logs only once per JVM lifetime or if it resets and eventually logs more than once.

Detecting Pinned Threads via JFR

If logging to standard out isn’t your style, JFR (Java Flight Recorder) has a few new events to cover the lifecycle of virtual threads. Listening for the jdk.VirtualThreadPinned JFR event may be for you.

try (final RecordingStream eventStream = new RecordingStream()) {
    eventStream.enable("jdk.VirtualThreadPinned").withStackTrace();
    eventStream.onEvent("jdk.VirtualThreadPinned", System.out::println);
    eventStream.start();
}

With the RecordingStream started (perhaps in another platform thread?) we now get much richer event information every time a virtual thread is pinned to a carrier thread.

jdk.VirtualThreadPinned {
  startTime = 08:15:07.505 (2024-01-09)
  duration = 999 ms
  eventThread = "synch-1" (javaThreadId = 41, virtual)
  stackTrace = [
    java.lang.VirtualThread.parkOnCarrierThread(boolean, long) line: 677
    java.lang.VirtualThread.parkNanos(long) line: 636
    java.lang.VirtualThread.sleepNanos(long) line: 793
    java.lang.Thread.sleep(long) line: 507
    com.ginsberg.example.pinning.Main.lambda$doSomethingSynchronized$0(int) line: 15
    ...
  ]
}

As you can see, the jdk.VirtualThreadPinned event will tell you the duration of pin (999 ms in our case), which eventThread it was performed on (“synch-1”), and a stackTrace (if enabled) showing where the call was made. In this example I’ve just printed the event out as-is, but you could write it to a structured log that your monitoring system can pick up on, hand it off to your telemetry code, expose it through JMX, or whatever else you’d like. There is a lot of flexibility with the JFR streaming method.

Note that there are a lot of different ways you could use JFR to gather this information, I’ve just illustrated one of them.

How To Avoid Pinning

Once we find some virtual thread pinning in our code, what do we do about it? As mentioned above, one way to pin a virtual thread to a carrier thread is by using a synchronized method or block. For example, this doSomethingSynchroinzed method has a synchronized block that may end up being pinned if run in a virtual thread.

public class Main {

  private Object lock = new Object();

  // NO: Synchronized ay pin virtual thread to a carrier thread
  public void doSomethingSynchronized() {
      synchronized(lock) {
          someLongRunningWork();
      } 
  }
}

To get around that, we can replace the synchronized block with a ReentrantLock.

public class Main {

  private Lock lock = new ReentrantLock();

  // Yes: Using ReentrantLock instead of synchronized
  public void doSomethingLocked() {
      try {
          lock.lock();
          someLongRunningWork();
      } finally {
          lock.unlock();
      }
  }
}

Materially we are doing the same thing we would if we had synchronized - acquire a lock, do some work, release the lock. The big difference with this method is that we don’t have a synchronized block in here (nor does the implementation of ReentrantLock) and hence we won’t pin this thread to a carrier thread.

The other way pinning can happen (via native method calls or foreign function calls) doesn’t have an easy fix. There is no easy change to make in those cases so you’ll have to handle it on a case-by-case basis. Either learn to live with a bit of virtual thread pinning (again, our code isn’t broken it’s just not optimal) or switch out your dependency for something that doesn’t have this behavior.

Thanks for reading! I hope that helped.