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:
@@ -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 & 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user