vt-pinning: diagnosing virtual thread pinning companion code

Reproduces the pinning JEP 491 did not fix (native JNI frames) with a real
native library, alongside the monitor case it did fix. Covers the
jdk.VirtualThreadPinned JFR event, reading pinned vs unmounted virtual
threads from a jcmd Thread.dump_to_file, the measured throughput cost on a
capped carrier pool, and a bounded-executor pattern to contain the blast
radius.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01FhzLY5p6okFva3qsnsRyvM
This commit is contained in:
2026-09-30 06:35:35 +00:00
co-authored by Claude Sonnet 5
parent ff183da163
commit 0819993c51
18 changed files with 725 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
/*
* A minimal JNI native method used by every demo in this module. It does exactly one thing:
* call back into the Java class that invoked it, on the same native stack frame, so that
* whatever the callback does (in every demo here, a Thread.sleep) executes while native frames
* are still on the virtual thread's stack - which is precisely what a continuation cannot freeze
* across, and therefore what still pins a virtual thread's carrier on every JDK version,
* including JDK 24+ after JEP 491 fixed monitor pinning.
*
* Every demo class in this module declares the same native method name
* (blockingNativeCall) and static callback name (sleepCallback), just in different classes, so
* one shared native function - looked up by class name at call time - covers all of them.
*/
#include <jni.h>
#include <string.h>
/* JNI_OnLoad isn't required here; each demo class binds via System.loadLibrary + a matching
* Java_<class>_blockingNativeCall symbol would normally be needed per class. Instead we export
* one symbol per demo class below, all doing the identical call-back, to avoid duplicating this
* file four times for four otherwise-identical native methods. */
static void callSleepCallback(JNIEnv *env, jclass cls) {
jmethodID mid = (*env)->GetStaticMethodID(env, cls, "sleepCallback", "()V");
if (mid == NULL) {
return; /* let the pending exception propagate back into Java */
}
(*env)->CallStaticVoidMethod(env, cls, mid);
}
JNIEXPORT void JNICALL Java_com_ankurm_vtpinning_NativePinningDemo_blockingNativeCall
(JNIEnv *env, jclass cls) {
callSleepCallback(env, cls);
}
JNIEXPORT void JNICALL Java_com_ankurm_vtpinning_ThreadDumpPinnedDemo_blockingNativeCall
(JNIEnv *env, jclass cls) {
callSleepCallback(env, cls);
}
JNIEXPORT void JNICALL Java_com_ankurm_vtpinning_PinningThroughputDemo_blockingNativeCall
(JNIEnv *env, jclass cls) {
callSleepCallback(env, cls);
}
JNIEXPORT void JNICALL Java_com_ankurm_vtpinning_BoundedNativeCallDemo_blockingNativeCall
(JNIEnv *env, jclass cls) {
callSleepCallback(env, cls);
}
@@ -0,0 +1,103 @@
package com.ankurm.vtpinning;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
/**
* The fix for native-code pinning isn't eliminating it - it's architectural, and no JDK flag
* removes it. The fix is containing its blast radius: never let a native call that pins run
* directly on the same carrier pool everything else in the process shares. Route it through a
* small, explicitly-sized platform-thread executor instead, and call that executor from the
* virtual thread via a plain {@code Future.get()} - which parks normally (no native frame on
* *this* thread's stack) and unmounts, leaving the shared carrier pool free for unrelated work.
*
* <p>This demo runs {@code pinningCount} native-pinning calls alongside {@code unrelatedCount}
* ordinary virtual threads doing short, unrelated sleeps, on a carrier pool capped small enough
* that direct native calls would starve the unrelated work. {@code mode=direct} calls native code
* straight from the virtual thread (competes for the shared pool); {@code mode=bounded} routes it
* through a dedicated 2-thread executor instead.
*
* <p>Usage: {@code java -Djdk.virtualThreadScheduler.parallelism=2 BoundedNativeCallDemo
* direct|bounded}
*/
public final class BoundedNativeCallDemo {
static {
System.loadLibrary("vtpinning");
}
private static native void blockingNativeCall();
@SuppressWarnings("unused")
private static void sleepCallback() {
try {
Thread.sleep(800);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
public static void main(String[] args) throws Exception {
String mode = args.length > 0 ? args[0] : "direct";
int pinningCount = 2;
int unrelatedCount = 6;
ExecutorService nativePool = Executors.newFixedThreadPool(2);
try {
long start = System.nanoTime();
CountDownLatch pinningLatch = new CountDownLatch(pinningCount);
for (int i = 0; i < pinningCount; i++) {
Thread.ofVirtual().start(() -> {
try {
if ("bounded".equals(mode)) {
Future<?> f = nativePool.submit(BoundedNativeCallDemo::blockingNativeCall);
f.get();
} else {
blockingNativeCall();
}
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
pinningLatch.countDown();
}
});
}
CountDownLatch unrelatedLatch = new CountDownLatch(unrelatedCount);
long[] unrelatedFinishedAt = new long[unrelatedCount];
for (int i = 0; i < unrelatedCount; i++) {
int idx = i;
Thread.ofVirtual().start(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
unrelatedFinishedAt[idx] = (System.nanoTime() - start) / 1_000_000;
unrelatedLatch.countDown();
}
});
}
unrelatedLatch.await();
long unrelatedDoneMs = 0;
for (long t : unrelatedFinishedAt) {
unrelatedDoneMs = Math.max(unrelatedDoneMs, t);
}
pinningLatch.await();
long totalMs = (System.nanoTime() - start) / 1_000_000;
System.out.println("mode=" + mode
+ " parallelism=" + System.getProperty("jdk.virtualThreadScheduler.parallelism")
+ " pinningCount=" + pinningCount
+ " unrelatedCount=" + unrelatedCount
+ " unrelatedWorkDoneAtMs=" + unrelatedDoneMs
+ " totalElapsedMs=" + totalMs);
} finally {
nativePool.shutdown();
}
}
}
@@ -0,0 +1,36 @@
package com.ankurm.vtpinning;
/**
* A virtual thread that enters a {@code synchronized} block and then blocks inside it.
*
* <p>Before JEP 491 (pre-JDK 24), this pinned the carrier thread for the full duration of the
* block. JEP 491 shipped GA in JDK 24 and removed that pinning: the virtual thread can now
* acquire, hold, and release a monitor independently of its carrier, so a {@code Thread.sleep}
* (or any other blocking call) inside {@code synchronized} no longer pins.
*
* <p>Run this with {@code -Djdk.tracePinnedThreads=full} on a pre-JDK-24 runtime and it prints a
* {@code reason:MONITOR} stack trace. Run it the same way on JDK 24+ and nothing prints at all -
* not because the flag broke, but because there is nothing left to report for this specific case.
* See {@link NativePinningDemo} for the pinning that JEP 491 did not touch.
*/
public final class MonitorPinningDemo {
private static final Object LOCK = new Object();
public static void main(String[] args) throws Exception {
System.out.println("java.version=" + System.getProperty("java.version"));
System.out.println("jdk.tracePinnedThreads=" + System.getProperty("jdk.tracePinnedThreads"));
Thread t = Thread.ofVirtual().start(() -> {
synchronized (LOCK) {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});
t.join();
System.out.println("done");
}
}
@@ -0,0 +1,50 @@
package com.ankurm.vtpinning;
/**
* Reproduces the pinning JEP 491 did NOT fix: a virtual thread executing native code (a JNI
* native method, or the Foreign Function &amp; Memory API) whose native frame calls back into
* Java code that blocks. The continuation backing the virtual thread cannot be frozen across a
* native stack frame, so the carrier stays pinned for as long as the callback blocks -
* regardless of JDK version, because this is architectural, not a bug JEP 491 targeted.
*
* <p>Requires {@code libvtpinning.so} on {@code java.library.path} (built by
* {@code scripts/build-native.sh} from {@code src/main/c/vtpinning.c}). The native method calls
* straight back into {@link #sleepCallback()}, which is the only thing that actually blocks.
*
* <p>Usage: {@code java --enable-native-access=ALL-UNNAMED NativePinningDemo [sleepMillis]}
* (sleepMillis defaults to 200; pass a larger value to hold the pin open long enough to attach a
* jcmd thread dump to it - see {@link ThreadDumpPinnedDemo}).
*/
public final class NativePinningDemo {
static {
System.loadLibrary("vtpinning");
}
private static native void blockingNativeCall();
// Called back FROM native code, while native frames are still on this thread's stack.
@SuppressWarnings("unused")
private static void sleepCallback() {
try {
Thread.sleep(SLEEP_MILLIS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private static volatile long SLEEP_MILLIS = 200;
public static void main(String[] args) throws Exception {
if (args.length > 0) {
SLEEP_MILLIS = Long.parseLong(args[0]);
}
System.out.println("java.version=" + System.getProperty("java.version"));
System.out.println("jdk.tracePinnedThreads=" + System.getProperty("jdk.tracePinnedThreads"));
System.out.println("sleepMillis=" + SLEEP_MILLIS);
Thread t = Thread.ofVirtual().start(NativePinningDemo::blockingNativeCall);
t.join();
System.out.println("done");
}
}
@@ -0,0 +1,68 @@
package com.ankurm.vtpinning;
import java.util.concurrent.CountDownLatch;
/**
* What native-code pinning actually costs in wall time, versus the same workload done in a way
* that unmounts normally. Runs {@code threadCount} virtual threads, each blocking for
* {@code blockMillis}, either through the native pinning path ({@code mode=native}) or through a
* plain {@code Thread.sleep} that unmounts ({@code mode=plain}). Run with
* {@code -Djdk.virtualThreadScheduler.parallelism=N} to cap the carrier pool and make the
* difference visible on a small thread count.
*
* <p>Usage: {@code java -Djdk.virtualThreadScheduler.parallelism=2 PinningThroughputDemo
* native|plain [threadCount] [blockMillis]}
*/
public final class PinningThroughputDemo {
static {
System.loadLibrary("vtpinning");
}
private static native void blockingNativeCall();
private static volatile long BLOCK_MILLIS = 1000;
@SuppressWarnings("unused")
private static void sleepCallback() {
try {
Thread.sleep(BLOCK_MILLIS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
public static void main(String[] args) throws Exception {
String mode = args.length > 0 ? args[0] : "native";
int threadCount = args.length > 1 ? Integer.parseInt(args[1]) : 4;
if (args.length > 2) {
BLOCK_MILLIS = Long.parseLong(args[2]);
}
long start = System.nanoTime();
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
Thread.ofVirtual().start(() -> {
try {
if ("native".equals(mode)) {
blockingNativeCall();
} else {
Thread.sleep(BLOCK_MILLIS);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
latch.countDown();
}
});
}
latch.await();
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
System.out.println("mode=" + mode
+ " threadCount=" + threadCount
+ " blockMillis=" + BLOCK_MILLIS
+ " parallelism=" + System.getProperty("jdk.virtualThreadScheduler.parallelism")
+ " elapsedMs=" + elapsedMs);
}
}
@@ -0,0 +1,44 @@
package com.ankurm.vtpinning;
/**
* Companion to {@code scripts/run-all.sh}'s jcmd capture: starts one virtual thread pinned via
* native code (see {@link NativePinningDemo}) and one virtual thread merely sleeping (unpinned,
* unmounted), both long enough that a {@code jcmd <pid> Thread.dump_to_file -format=json} taken
* a second later catches both mid-flight. Compare the two entries in the dump: the pinned one
* carries a {@code "carrier"} field naming the platform thread it is stuck to and its stack shows
* {@code VirtualThread.parkOnCarrierThread}; the unpinned one has neither.
*/
public final class ThreadDumpPinnedDemo {
static {
System.loadLibrary("vtpinning");
}
private static native void blockingNativeCall();
@SuppressWarnings("unused")
private static void sleepCallback() {
try {
Thread.sleep(4000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
public static void main(String[] args) throws Exception {
System.out.println("pid=" + ProcessHandle.current().pid());
Thread pinned = Thread.ofVirtual().start(ThreadDumpPinnedDemo::blockingNativeCall);
Thread unpinned = Thread.ofVirtual().start(() -> {
try {
Thread.sleep(4000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
pinned.join();
unpinned.join();
System.out.println("done");
}
}