Test Java Virtual-Thread Code for Pinning and Cancellation
A test that submits a thousand virtual-thread tasks and waits for them proves only that they ended. This guide measures the ten connections behind them instead: a probe that asserts the concurrency bound is reached and never exceeded, a 500 ms deadline that has to reach the wait for a connection, the Semaphore release bug that inflates permits on cancellation, JDK 27 structured concurrency timeouts, and a JFR RecordingStream check for the pinning that survives JEP 491.

jdk.VirtualThreadPinned with a JFR RecordingStream during the test. On JDK 24 and later a synchronized block no longer pins; native frames and class initialisers still do.Here is a test that passes on every machine and tells you almost nothing:
@Test
void handlesAThousandConcurrentRequests() throws Exception {
Set<String> threads = ConcurrentHashMap.newKeySet();
List<Future<Order>> results = new ArrayList<>();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000; i++) {
results.add(executor.submit(() -> {
threads.add(Thread.currentThread().toString());
return orders.load(randomId());
}));
}
}
assertEquals(1_000, results.size());
assertEquals(1_000, threads.size()); // "a thousand requests in parallel"
}
Every assertion in it is true by construction. newVirtualThreadPerTaskExecutor() is documented to start "a new virtual Thread for each task", and "the number of threads created by the Executor is unbounded", so a thousand submissions produce a thousand threads whether the code underneath is correct or not. The close() at the end of the try block "waits until all tasks have completed execution", so the test cannot observe a request that was slow, stuck, or late. It only finishes later.
What the test was supposed to be about sits behind orders.load: a pool of ten connections. JEP 444 is blunt about what the thousand threads add: "Virtual threads are not faster threads — they do not run code any faster than platform threads. They exist to provide scale (higher throughput), not speed (lower latency)." The throughput of that test is set by the pool and the query time, and the thread count is a number the executor chose for you.
Measure the ten connections, not the thousand threads
Count at the resource that is actually limited, because that is where the bug shows up: eleven queries in flight against a pool of ten, or nine forever because a permit leaked.
For a unit-level test, replace the data source with a double that behaves like a ten-connection pool and records what happened to it. The semaphore in it stands in for the pool. Oracle's virtual threads guide is explicit that a real pool already plays this role: "Database connection pools themselves serve as a semaphore", and "there is no need to add an additional semaphore on top of the connection pool." So in production the bound lives in the pool, and in the test the double reproduces it with a counter attached.
final class TenConnectionPool implements OrderStore {
final Semaphore connections = new Semaphore(10, true);
final AtomicInteger inFlight = new AtomicInteger();
final AtomicInteger peak = new AtomicInteger();
final LongAdder servedCount = new LongAdder();
final LongAdder rejectedAtDeadline = new LongAdder();
private final Duration queryTime;
TenConnectionPool(Duration queryTime) {
this.queryTime = queryTime;
}
@Override
public Optional<Order> load(OrderId id, Instant deadline) throws InterruptedException {
long budget = Duration.between(Instant.now(), deadline).toNanos();
if (!connections.tryAcquire(budget, TimeUnit.NANOSECONDS)) {
rejectedAtDeadline.increment();
return Optional.empty();
}
try {
peak.accumulateAndGet(inFlight.incrementAndGet(), Math::max);
Thread.sleep(queryTime); // the query, as far as the caller can tell
servedCount.increment();
return Optional.of(new Order(id));
} finally {
inFlight.decrementAndGet();
connections.release();
}
}
}
Two details in that double are deliberate. The semaphore is fair: the Semaphore Javadoc recommends that "semaphores used to control resource access should be initialized as fair, to ensure that no thread is starved out from accessing a resource", and it warns that "the untimed tryAcquire methods do not honor the fairness setting". The timed form used here does. And the peak is updated after the permit is taken, so it measures queries, not callers waiting in line.
A thousand requests, a 500 ms deadline, ten connections
A thousand requests arrive at once, each with a 500 ms deadline, against ten connections whose queries take 50 ms.
@Test
void boundsDatabaseConcurrencyAndShedsLoadAtTheDeadline() {
var pool = new TenConnectionPool(Duration.ofMillis(50));
var service = new OrderService(pool);
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000; i++) {
OrderId id = new OrderId(i);
executor.submit(() -> service.lookup(id, Instant.now().plusMillis(500)));
}
}
long served = pool.servedCount.sum();
long shed = pool.rejectedAtDeadline.sum();
assertEquals(10, pool.peak.get(), "the bound must be reached, and never exceeded");
assertEquals(1_000, served + shed, "every request is either served or shed");
assertTrue(served >= 60 && served <= 100, () -> "served " + served);
assertEquals(10, pool.connections.availablePermits(), "every connection came back");
assertEquals(0, pool.inFlight.get());
}
Run on JDK 26, the numbers were 90 served, 910 shed, a peak of 10 and 520 ms elapsed. That is Little's Law, which JEP 444 uses to motivate the whole feature: ten connections at 50 ms each is 200 queries a second, and half a second of budget holds about a hundred of them. The other nine hundred threads existed, waited, and gave up on time.
Each assertion guards a different failure. Asserting peak == 10 rather than peak <= 10 matters, because a peak of one also satisfies the upper bound and means something serialised every query, perhaps a lock held across the call. The served-plus-shed total catches a request that vanished into a Future nobody reads. The served range is the Little's Law estimate with room for scheduling noise: it fails if the deadline is ignored (all thousand served, after five seconds) or fires too early.
The cost is real time. The test cannot be faster than its deadline, and the Semaphore times its waits against the system clock, so there is no fake clock to inject. Keep one test like this per bounded resource, not one per endpoint.
The pool default that outlives your deadline
The double honours the request's deadline because it was written to. A real pool uses its own configuration, and the defaults are rarely aligned with anyone's latency budget. HikariCP's README makes the arithmetic concrete: maximumPoolSize defaults to 10, so the pool in this worked example is the default pool. When it is exhausted, "calls to getConnection() will block for up to connectionTimeout milliseconds before timing out", and connectionTimeout defaults to 30000.
So a request with a 500 ms deadline, arriving behind nine hundred others, can wait thirty seconds for a connection before anything tells it no. A fixed pool of request threads used to run out first and push back upstream. With a virtual thread per request nothing runs out first, and the pool's wait becomes the request's latency.
So set connectionTimeout in the test configuration to production's value, and assert on it once so it cannot drift. Mind its floor too: the README states the "lowest acceptable connection timeout is 250 ms", so a deadline shorter than that cannot be enforced by the pool at all and has to be enforced before the call reaches it. Run the same thousand-request shape against a real database container, following the approach from part five, and compare its served and shed counts with the double's. If they disagree by an order of magnitude, the pool's timeout is the first thing to read.
A permit released by a request that never held one
Where there is no pool, only a downstream service that takes ten calls at a time, the guide's advice is a Semaphore rather than a fixed thread pool. The obvious way to write it hides a mistake no happy-path test finds.
// Wrong: if acquire() is interrupted, finally still releases a permit
// this request never received.
Rate quote(Currency from, Currency to) throws InterruptedException {
try {
permits.acquire();
return rates.fetch(from, to);
} finally {
permits.release();
}
}
// Right: only a request that acquired a permit can give one back.
Rate quote(Currency from, Currency to) throws InterruptedException {
permits.acquire();
try {
return rates.fetch(from, to);
} finally {
permits.release();
}
}
The wrong version passes every test that does not cancel anything. The Javadoc for release() says: "There is no requirement that a thread that releases a permit must have acquired that permit by calling acquire()." Semaphores "have no notion of ownership". Each interrupted waiter adds a permit, and the limit of ten quietly becomes a limit of whatever the last cancellation storm left behind.
How large that gets depends on timing, which is why it survives review. Submitting a thousand requests to the wrong version, cancelling them all 120 ms later and reading availablePermits() gave 980 on JDK 26 and 20 on JDK 21. Either way, the next burst sends that many concurrent calls to a service that can take ten.
Cancel mid-flight and count what came back
The permit assertion only catches that bug if the test cancels requests while some are waiting and some are working. Build that state on purpose: a query slow enough that every connection is busy and hundreds are queued, then cancel everything.
@Test
void cancelledRequestsReleaseEveryConnection() throws Exception {
var pool = new TenConnectionPool(Duration.ofSeconds(5));
var service = new OrderService(pool);
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
List<Future<?>> requests = new ArrayList<>();
for (int i = 0; i < 1_000; i++) {
OrderId id = new OrderId(i);
requests.add(executor.submit(() -> service.lookup(id, Instant.now().plusSeconds(10))));
}
await().until(() -> pool.inFlight.get() == 10); // saturated: ten working, the rest queued
requests.forEach(request -> request.cancel(true));
executor.shutdown();
assertTrue(executor.awaitTermination(2, TimeUnit.SECONDS),
"a task ignored its interrupt and is still holding something");
assertEquals(0, pool.inFlight.get());
assertEquals(10, pool.connections.availablePermits());
}
await() is whatever polling helper the suite already uses; wait on the pool's state, not on a sleep. With the correct release pattern, the run reported ten in flight before cancellation and, after it, terminated with zero in flight and ten permits.
Three pieces of the contract are doing work here. Future.cancel(true) interrupts "the thread executing this task (when known by the implementation)", and for a task that has not started, "this task should never run". Its return value "does not necessarily indicate whether the task is now cancelled", so the test asserts on the resource, not on what cancel returned.
The executor is shut down and awaited with a limit instead of closed, because close() waits with no limit at all. A task that swallows InterruptedException turns a close()-based test into a hang; the timed wait turns it into a failure message.
And a real query is not a sleep. JEP 444 specifies that the blocking I/O methods of java.net.Socket are interruptible on a virtual thread, and that "interrupting a virtual thread blocked on a socket will unpark the thread and close the socket." If your driver reads through such a socket, a cancelled request can hand a closed connection back to the pool. Whether the pool evicts it is a question for the integration-level version of this test: cancel a batch, then run one more ordinary query and assert it succeeds.
Scope timeouts in JDK 27 structured concurrency
When one request fans out into several calls, the deadline and the cancellation have to cover all of them. That is what StructuredTaskScope is for, and in JDK 27 it is still a preview API: "Programs can only use StructuredTaskScope when preview features are enabled", which means compiling with javac --release 27 --enable-preview and running tests with --enable-preview.
OrderView view(OrderId id, Duration budget) throws InterruptedException, ExecutionException {
try (var scope = StructuredTaskScope.open(
Joiner.<Object>allSuccessfulOrThrow(),
cf -> cf.withTimeout(budget))) {
var order = scope.fork(() -> orders.load(id, Instant.now().plus(budget)));
var stock = scope.fork(() -> inventory.levelsFor(id));
scope.join();
return new OrderView(order.get(), stock.get());
}
}
@Test
void aSlowDependencyCancelsTheWholeRequest() {
inventory.respondAfter(Duration.ofSeconds(5));
var failure = assertThrows(ExecutionException.class,
() -> service.view(new OrderId(7), Duration.ofMillis(500)));
assertInstanceOf(StructuredTaskScope.CancelledByTimeoutException.class, failure.getCause());
assertEquals(10, pool.connections.availablePermits(), "the order lookup gave its connection back");
assertEquals(0, inventory.callsInProgress());
}
The timeout semantics are exactly what the test asserts: "if the timeout expires before or while waiting in join() then the scope is cancelled, which cancels all incomplete subtasks, and join() throws an ExecutionException with a CancelledByTimeoutException as the cause." What makes the last two assertions safe to write immediately after the call is the scope's close behaviour: "The close method always waits for threads executing subtasks to finish, even if the scope is cancelled." When view returns, nothing it forked is still running.
That guarantee has a price the test will expose. JEP 533 warns that subtasks that "block on methods that are not interruptible, may delay the closing of a scope indefinitely." A 500 ms budget that takes five seconds to fail is that warning, observed. Time the call and assert an upper bound on it as well as the exception type.
The API also keeps moving between previews. JDK 27 added a third type parameter for the exception join() throws, made the stock joiners throw ExecutionException, removed Joiner.awaitAll() and replaced onTimeout() with timeout(). Keep scope code behind one method per fan-out, as above, so an upgrade changes that method and not every test that exercises it.
Pinning since JDK 24, and a test that catches it
Pinning is a virtual thread that blocks without releasing its carrier platform thread. Before JDK 24 the usual cause was blocking inside synchronized. JEP 491, delivered in JDK 24, changed the JVM so that "virtual threads can acquire, hold, and release monitors, independently of their carriers", and described that as eliminating "nearly all cases" of pinning. JDK 21 is still an LTS release, though, and on it the old behaviour stands.
What remains after JEP 491 is narrower and harder to spot in a code review. The JDK 27 guide says a virtual thread "is pinned when it runs a native method or a foreign function", and JEP 491 lists the rest: native code calling back into Java that blocks, blocking while loading a class, "blocking inside a class initializer", and waiting for another thread to finish initialising a class.
The diagnostic changed too. The jdk.tracePinnedThreads system property from JEP 444 was removed in JDK 24, and JEP 491 is direct about it: "setting it on the command line will have no effect." A flag left in a Gradle jvmArgs block from the JDK 21 era is now silent, which is indistinguishable from "no pinning" unless you know. What remains is the JFR event jdk.VirtualThreadPinned, "enabled by default with a threshold of 20 ms", which since JEP 491 also reports the reason for the pin and the carrier thread.
JFR can be read from inside a test. RecordingStream.stop(), available since JDK 20, "stops a started stream and waits until all events in the recording have been consumed", and its own Javadoc example is a test method asserting that no network events occurred. The same shape works for pinning:
static List<RecordedEvent> pinsDuring(ThrowingRunnable workload) throws Throwable {
List<RecordedEvent> pins = new CopyOnWriteArrayList<>();
try (var recording = new RecordingStream()) {
recording.enable("jdk.VirtualThreadPinned").withoutThreshold().withStackTrace();
recording.onEvent("jdk.VirtualThreadPinned", pins::add);
recording.startAsync();
workload.run();
recording.stop();
}
return pins;
}
@Test
void theDetectorSeesAPinWhenThereIsOne() throws Throwable {
// Blocking inside a class initializer still pins on JDK 24+.
assertFalse(pinsDuring(() -> runOnVirtualThreads(4, SlowToInitialize::touch)).isEmpty());
}
@Test
void orderLookupsDoNotPin() throws Throwable {
var pins = pinsDuring(() -> runOnVirtualThreads(100, () -> service.lookup(new OrderId(1), soon())));
assertTrue(pins.isEmpty(), () -> pins.getFirst().getStackTrace().toString());
}
The first test is the negative control, and it is not optional. Without it, an empty list is equally consistent with "no pinning" and "the recording never started". SlowToInitialize is a class whose static initialiser sleeps for 100 ms; the four threads that touch it produced five pinned events on JDK 26, with the reason Freeze or preempt failed. The same probe with Thread.sleep inside synchronized reported four events on JDK 21 and none on JDK 26, which is JEP 491 made visible.
Dumps for when the test hangs
When one of these tests hangs instead of failing, the traditional thread dump will mislead you. jcmd <pid> Thread.print shows platform threads and only the virtual threads currently mounted, and a thousand requests queued on a semaphore are not mounted. Take the newer dump instead:
jcmd <pid> Thread.dump_to_file -format=json build/dumps/lookup-hang.json
jcmd <pid> Thread.vthread_scheduler
jfr print --events jdk.VirtualThreadPinned,jdk.VirtualThreadSubmitFailed build/test.jfr
The JSON dump lists every thread, virtual ones included, and groups threads forked in a StructuredTaskScope under their scope. It is not a consistent snapshot: the guide notes it does not pause the VM and "doesn't perform deadlock detection", so read the timestamps before concluding two threads were waiting on each other. Name the request threads with Thread.ofVirtual().name("lookup-", 0).factory() so they are searchable in the file. An assistant can summarise which frames nine hundred threads share; the probe's counts are what confirm the diagnosis. To compare JDK 21 and JDK 27 output from the same run, the diff checker shows which pins disappeared after the upgrade.
None of this is about JUnit's own parallel test execution, which part three covers. If both are in play, pin JUnit's parallelism first so the only concurrency that varies is the code's.
First changes for a virtual-thread suite
Find the test that proves your virtual-thread code works and delete any assertion about thread counts or thread names. Replace it with a probe on the one resource that request path cannot have more of: connections, permits, sockets to a partner. Assert that the peak equals the bound and that the served and shed counts add up to what you sent.
Then open the pool configuration the tests use and write down its connection timeout next to the request deadline. If the timeout is longer, the deadline is fiction for any request that queues, and that is the first fix.
Last, add the pinning probe with its negative control, run it on the JDK production uses, and delete -Djdk.tracePinnedThreads from every build file on JDK 24 or later. Print the peak, the served and shed counts, and the permits after cancellation in the test output, so a regression shows up as a changed number rather than a slower build.
Questions about testing virtual-thread code
Do I still need to replace synchronized with ReentrantLock?
Not for pinning on JDK 24 or later. JEP 491 withdraws the earlier advice: "Once the synchronized keyword no longer pins virtual threads, such migration will no longer be necessary", and code already migrated need not be reverted. On JDK 21 the advice stands for locks held across blocking calls. Either way, holding a lock while waiting on I/O still serialises every caller, which is what the peak == 10 assertion exists to catch.
How many platform threads does the virtual-thread scheduler actually use?
By default, as many as there are available processors, tunable with jdk.virtualThreadScheduler.parallelism. Some blocking operations temporarily add carriers to compensate, up to jdk.virtualThreadScheduler.maxPoolSize, and JEP 491 notes a default limit of 256. A test that passes only after raising parallelism has usually found a pinning problem, not a sizing one.
Primary references
- OpenJDK: JDK Project and JDK 27. That JDK 27 reached general availability on 15 September 2026 and JDK 28 is in development, and that JDK 27 ships JEP 533, Structured Concurrency (Seventh Preview).
- Oracle Java SE Support Roadmap. That Java SE 21 and 25 are LTS releases and JDK 27 is not, which is why the article treats JDK 21 pinning behaviour as still current for many teams.
- JEP 444: Virtual Threads. Delivered in JDK 21. That virtual threads "are not faster threads" and provide throughput rather than lower latency; the Little's Law framing; the advice to limit concurrency with semaphores rather than pooled threads; that socket I/O is interruptible on a virtual thread and interrupting "will close the socket"; the scheduler properties
jdk.virtualThreadScheduler.parallelismandmaxPoolSize; the originaljdk.tracePinnedThreadsproperty;jcmd Thread.dump_to_file; and the JFR events includingjdk.VirtualThreadSubmitFailed. - JEP 491: Synchronize Virtual Threads without Pinning. Delivered in JDK 24. That monitors are now held by virtual threads independently of carriers; the remaining pinning cases (native and foreign-function callbacks, class loading, class initializers, waiting on class initialization); that
jdk.VirtualThreadPinnednow carries the pin reason and carrier thread; thatjdk.tracePinnedThreadswas removed and "setting it on the command line will have no effect"; the withdrawn advice to migrate toReentrantLock; and the scheduler's default limit of 256 platform threads. - JEP 533: Structured Concurrency (Seventh Preview). Delivered in JDK 27 as a preview API. The JDK 27 changes (third type parameter,
ExecutionExceptionfrom the stock joiners, removal ofawaitAll(),timeout()replacingonTimeout());withTimeoutproducing anExecutionExceptioncaused byCancelledByTimeoutException; that close "always waits for threads executing subtasks to finish" and that non-interruptible subtasks "may delay the closing of a scope indefinitely"; the--enable-previewflags; and scope grouping in the JSON thread dump. - Oracle Java SE 27 Core Libraries: Virtual Threads. That a virtual thread is pinned when it runs a native method or foreign function; that "database connection pools themselves serve as a semaphore" with no need for another on top; what
Thread.printandThread.dump_to_fileeach include, and that the latter is not a consistent snapshot and "doesn't perform deadlock detection";Thread.vthread_scheduler; andjdk.VirtualThreadPinnedbeing "enabled by default with a threshold of 20 ms". - Java SE 27 API: Semaphore. That "there is no requirement that a thread that releases a permit must have acquired that permit", that semaphores have no notion of ownership, the recommendation to initialise resource-guarding semaphores as fair, that untimed
tryAcquireignores fairness, and the timedtryAcquirecontract. - Java SE 27 API: Future. The
cancel(boolean)contract: an unstarted task "should never run", a running one is interrupted whenmayInterruptIfRunningis true, and the return value "does not necessarily indicate whether the task is now cancelled". - Java SE 27 API: ExecutorService and Executors. That
close()waits until all tasks complete, with no limit; and thatnewVirtualThreadPerTaskExecutor()starts a new virtual thread per task and the thread count "is unbounded". - Java SE 27 API: RecordingStream and EventSettings. That
stop(), since JDK 20, waits until all recorded events have been consumed, with a Javadoc example monitoring a test method; and thewithoutThreshold()andwithStackTrace()settings used by the pinning probe. - Java SE 27 API: StructuredTaskScope. That it is a preview API usable only with preview features enabled, that a timeout cancellation "interrupts the threads executing the subtasks that have not completed", and the
Thread.ofVirtual().name(prefix, start).factory()naming pattern. - HikariCP README. That
maximumPoolSizedefaults to 10, that an exhausted pool blocksgetConnection()"for up toconnectionTimeoutmilliseconds", thatconnectionTimeoutdefaults to 30000 and its "lowest acceptable connection timeout is 250 ms". README at release 7.1.0.
JVM Quality Architecture
JVM Quality Architecture
Get part 7: Make JUnit Failures Useful in CI
It is live now: read part 7. The form below subscribes you to The Automation Brief, the site newsletter.
No spam, no sending cadence promised. Unsubscribe any time.