Java Virtual Threads on JDK 26: Measured Limits
100,000 sleeping tasks on virtual vs platform threads, JDK 26: where platform threads fail, why CPU work gains nothing, pinning after JEP 491, races.
Series: Java lecture 5/6 (advanced). For developers who know ExecutorService and have heard that virtual threads "make everything scale". After this lecture you can predict when virtual threads help and when they do not, find the two compile errors and one silent bug that everyone hits first, and fix a race condition three ways.
If you run a Java service that spends most of its time waiting on HTTP calls or a database, virtual threads are the biggest change to Java concurrency in two decades. If your code is busy computing, they change nothing, and our measurements show both cases. We also checked what is left of "pinning" on JDK 26, because most articles about it were written for JDK 21 and are now out of date.
Environment: OpenJDK 26 (Temurin 26+35), Apple Silicon Mac (M1 Pro, 8 cores, 16 GB), macOS. Memory is the peak resident set size reported by /usr/bin/time -l; every number is the median of 3 runs unless noted. Wall times include JVM start-up and JIT warm-up, which is fine here because the effects we measure are 10× or more.
10,000 and 100,000 sleeping tasks: where platform threads stop
The test is the classic one: start N tasks that each sleep one second, and wait for all of them. Sleeping stands in for a slow network call. The same file runs three executors.
import java.time.Duration;
import java.util.concurrent.*;
/** Start N tasks that each sleep 1 s. Usage: java SleepTasks.java <virtual|platform|pool200> <N> */
public class SleepTasks {
public static void main(String[] args) throws Exception {
String mode = args[0];
int n = Integer.parseInt(args[1]);
ExecutorService ex = switch (mode) {
case "virtual" -> Executors.newVirtualThreadPerTaskExecutor();
case "platform" -> Executors.newThreadPerTaskExecutor(Thread.ofPlatform().factory());
case "pool200" -> Executors.newFixedThreadPool(200);
default -> throw new IllegalArgumentException(mode);
};
long t0 = System.nanoTime();
try (ex) { // close() waits for all tasks
for (int i = 0; i < n; i++) {
ex.submit(() -> { Thread.sleep(Duration.ofSeconds(1)); return null; });
}
}
long ms = (System.nanoTime() - t0) / 1_000_000;
Runtime rt = Runtime.getRuntime();
long usedMb = (rt.totalMemory() - rt.freeMemory()) >> 20;
System.out.printf("%-8s n=%,d wall=%,d ms heapUsed=%d MB%n", mode, n, ms, usedMb);
}
}
| Executor | Tasks | Wall time | Peak RSS |
|---|---|---|---|
| platform thread per task | 1,000 | 1,078 ms | 124 MB |
| platform thread per task | 4,000 | 1,304 ms | 261 MB |
| platform thread per task | 10,000 | crashed | – |
| virtual thread per task | 1,000 | 1,024 ms | 57 MB |
| virtual thread per task | 4,000 | 1,040 ms | 71 MB |
| virtual thread per task | 10,000 | 1,068 ms | 90 MB |
| virtual thread per task | 100,000 | 1,791 ms | 210 MB |
| fixed pool of 200 | 10,000 | 50,375 ms (1 run) | 73 MB |
Three things stand out. First, 100,000 virtual threads used less memory than 4,000 platform threads. Second, a fixed pool of 200 is the old way to cap memory, and it pays in time: 10,000 one-second tasks in batches of 200 is 50 rounds, and 50,375 ms is exactly that. We did not run the pool at 100,000; the same arithmetic says about 500 seconds. Third, platform threads did not slow down at 10,000. They died:
[0.453s][warning][os,thread] Failed to start thread "Unknown thread" - pthread_create failed (EAGAIN) for attributes: stacksize: 2048k, guardsize: 16k, detached.
[0.453s][warning][os,thread] Failed to start the native thread for java.lang.Thread "Thread-4067"
Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached
at java.base/java.lang.Thread.start0(Native Method)
at java.base/java.lang.Thread.start(Thread.java:1486)
...
at SleepTasks.main(SleepTasks.java:18)
It says OutOfMemoryError, but the heap was nowhere near full. Thread 4,067 is the clue: sysctl kern.num_taskthreads on this Mac prints 4096, the per-process thread limit on macOS. Add the JVM's own threads (GC, JIT, signal handling) and you hit it just above 4,000 user threads. On Linux the limit comes from ulimit -u, /proc/sys/kernel/threads-max and container cgroup settings instead, so the number differs, but the message is the same. When you see "unable to create native thread", look at thread counts, not -Xmx.
How a virtual thread blocks without blocking a core
A virtual thread is a Java object scheduled by the JDK, not by the OS. The JDK runs them on a small ForkJoinPool of carrier threads, by default one per core. When a virtual thread calls a blocking method that the JDK has adapted (sleep, socket I/O, ReentrantLock, BlockingQueue, and since JDK 24 also monitors), it unmounts: its stack frames are copied to the heap and the carrier picks up another virtual thread. That is why heap use grew with the task count in the table above (about 122 MB of heap at 100,000 tasks) while RSS stayed small.
A CPU-bound case where virtual threads do not help
If tasks never block, there is nothing to unmount from. 64 tasks that each run 200 million xorshift steps:
static long work(long seed) { // pure CPU: xorshift loop
long x = seed;
for (int i = 0; i < 200_000_000; i++) { x ^= x << 13; x ^= x >>> 7; x ^= x << 17; }
return x;
}
// run(...) submits 64 tasks to the executor and sums the results;
// work(1) is called once first so the JIT has compiled it
| Executor (64 tasks) | Round 1 | Round 2 | Round 3 | Median |
|---|---|---|---|---|
| single thread | 25,362 ms | 25,622 ms | 25,024 ms | 25,362 ms |
| fixed pool of 8 | 4,385 ms | 4,530 ms | 4,332 ms | 4,385 ms |
| virtual thread per task | 4,741 ms | 4,191 ms | 4,695 ms | 4,695 ms |
| platform thread per task | 3,992 ms | 4,161 ms | 3,920 ms | 3,992 ms |
All three parallel versions are about 5.8× faster than one thread, which is what 8 cores (6 performance + 2 efficiency on the M1 Pro) can give. Virtual threads are not faster, and the spread between runs (4,191–4,741 ms) is larger than any difference between executors. Use a fixed pool or a parallel stream sized to the cores for this kind of work; it also keeps CPU-heavy tasks from starving the carriers that your I/O-bound virtual threads need.
Pinning on JDK 26: what JEP 491 fixed and what is left
On JDK 21, a virtual thread that blocked inside synchronized was pinned: it could not unmount, so it held its carrier for the whole wait. JEP 491 in JDK 24 removed that for monitors. We checked what JDK 26 does with only one carrier (-Djdk.virtualThreadScheduler.parallelism=1), so any pinning would serialize everything:
// Case 1: 100 tasks, each sleeps 100 ms while holding its own monitor
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100; i++) {
Object lock = new Object();
ex.submit(() -> { synchronized (lock) { sleep(100); } });
}
}
// Case 2: one task blocks 1 s inside a static initializer, 100 others sleep 10 ms
static class Slow { static { sleep(1000); } static void touch() {} }
$ java -Djdk.virtualThreadScheduler.parallelism=1 -cp out Pinning
synchronized + sleep : 115 ms
bystanders while <clinit> blocks: 960 ms
Case 1 took 115 ms, not the 10 s that pinning would cost (100 × 100 ms on one carrier). Monitors no longer pin. Case 2 shows what still does: while one virtual thread sleeps inside a class initializer, the 100 bystanders that only needed 10 ms each waited 960 ms, because the only carrier was stuck. With the default 8 carriers the same bystanders finished in 11 ms, which is why this kind of pinning hides in production until the pool is saturated.
The task asked us to trace this with -Djdk.tracePinnedThreads=full. On JDK 26 that flag does nothing: the program printed the same two lines and no stack trace, and the string tracePinnedThreads does not appear anywhere in the JDK 26 class library (we searched lib/modules). It was removed together with JEP 491. The replacement is the JFR event jdk.VirtualThreadPinned. Our JDK image has no jfr command-line tool, so we read the recording with the jdk.jfr.consumer API in a 10-line program:
$ java -Djdk.virtualThreadScheduler.parallelism=1 -XX:StartFlightRecording=filename=pin.jfr,settings=profile -cp out Pinning
$ java Dump.java pin.jfr
jdk.VirtualThreadPinned {
duration = 1.01 s
blockingOperation = "LockSupport.park"
pinnedReason = "VM call to Pinning$Slow.<clinit> on stack"
carrierThread = "ForkJoinPool-1-worker-1" (javaThreadId = 33)
eventThread = "" (javaThreadId = 134, virtual)
...
The recording held exactly one pinned event, the class initializer. The 100 synchronized sleeps produced none. The pinnedReason field names the cause, which is more useful than the old stack-trace dump.
Common mistakes: two compile errors and one silent bug
Most people's first virtual-thread program looks like this:
int count = 0;
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000; i++) {
ex.submit(() -> { Thread.sleep(10); }); // mistake 1
ex.submit(() -> count++); // mistake 2
}
}
Mistakes.java:8: error: unreported exception InterruptedException; must be caught or declared to be thrown
ex.submit(() -> { Thread.sleep(10); }); // mistake 1
^
Mistakes.java:9: error: local variables referenced from a lambda expression must be final or effectively final
ex.submit(() -> count++); // mistake 2
^
2 errors
Mistake 1 → what happens → fix. A block lambda that returns nothing matches submit(Runnable), and Runnable.run() cannot throw checked exceptions. Add return null; so the lambda becomes a Callable, which may throw. That is why SleepTasks above ends its lambda with return null.
Mistake 2 → what happens → fix. Lambdas capture values, not variables. The compiler stops you, which is good, because the obvious workaround (a static int field) compiles and is wrong. See the next section.
The silent one. submit() stores a task's exception in its Future. If you never call get(), the exception disappears:
main finished, no stack trace above?
Exception in thread "" java.lang.IllegalStateException: boom2
at Swallow.lambda$main$1(Swallow.java:9)
The first task threw boom through submit() and printed nothing. The second one, started with Thread.ofVirtual().start(...), reached the default handler. Note the empty thread name "": virtual threads are unnamed by default, so name them (Thread.ofVirtual().name("fetch-", 0)) if you want readable logs.
A race condition, measured, and three fixes
1,000 virtual threads each increment a shared counter 10,000 times. The right answer is 10,000,000.
static int plain;
static volatile int vol;
static final AtomicInteger atomic = new AtomicInteger();
static final LongAdder adder = new LongAdder();
static final ReentrantLock lock = new ReentrantLock();
static int locked;
run("int ++", () -> plain++, () -> plain);
run("volatile ++", () -> vol++, () -> vol);
run("AtomicInteger", atomic::incrementAndGet, atomic::get);
run("LongAdder", adder::increment, adder::sum);
run("ReentrantLock", () -> { lock.lock(); try { locked++; } finally { lock.unlock(); } }, () -> locked);
int ++ total= 7,627,061 16 ms
volatile ++ total= 1,282,858 342 ms
AtomicInteger total= 10,000,000 667 ms
LongAdder total= 10,000,000 25 ms
ReentrantLock total= 10,000,000 341 ms
In three runs plain int lost between 2.1 and 2.9 million updates. volatile lost even more (it ended at 1.3 to 2.6 million): it makes each read and write visible, but vol++ is still read, add, write, and with every write going to memory the threads collide more often. All three fixes were exact every time. The times include JIT warm-up of each lambda, so read them as a ranking rather than a benchmark: AtomicInteger ranged 458–667 ms, ReentrantLock 341–371 ms, and LongAdder 21–26 ms because it spreads the count over cells and only adds them up in sum(). For a hot counter that is read rarely, LongAdder is the right tool; when you must update two fields together, use a lock.
Structured concurrency and scoped values on JDK 26
StructuredTaskScope treats a group of subtasks as one unit: if one fails, the others are cancelled, and the scope cannot end while a child is still running. On JDK 26 it is still a preview API:
$ javac Structured.java
Structured.java:2: error: StructuredTaskScope is a preview API and is disabled by default.
import java.util.concurrent.StructuredTaskScope;
^
(use --enable-preview to enable preview APIs)
try (var scope = StructuredTaskScope.open()) { // default: all must succeed
var user = scope.fork(() -> fetch("user", 100, failUser));
var order = scope.fork(() -> fetch("order", 2_000, false));
scope.join(); // throws if any subtask failed
System.out.println(" " + user.get() + " " + order.get());
}
$ javac --release 26 --enable-preview -Xlint:preview Structured.java
Structured.java:20: warning: [preview] StructuredTaskScope is a preview API and may be removed in a future release.
$ java --enable-preview Structured
user@req-42 order@req-42
failUser=false -> 2,013 ms
order cancelled after being interrupted
caught FailedException: java.lang.IllegalStateException: user failed
failUser=true -> 108 ms
When user failed after 100 ms, the 2-second order call was interrupted and the whole request returned in 108 ms instead of waiting 2 s for a result nobody would use. req-42 comes from a ScopedValue bound around the call, and the forked subtasks inherited it. ScopedValue itself compiled without any flag on 26; with javac --release 24 it is rejected as a preview API, and with --release 25 it compiles, so it became final in JDK 25. Because the scope API is preview, its method names have changed between releases (the JDK 21 version used new StructuredTaskScope.ShutdownOnFailure()); code written against 26 may not compile on 27.
Decision table
| Situation | Use | Why |
|---|---|---|
| Thousands of tasks waiting on HTTP/DB | newVirtualThreadPerTaskExecutor() |
100,000 sleeps in 1.8 s, 210 MB |
| Backend allows only N connections | virtual threads + Semaphore(N) |
do not use a pool as a rate limiter |
| CPU-bound computation | fixed pool ≈ cores, or parallel stream | virtual threads gave no speed-up |
| Shared counter, read rarely | LongAdder |
exact and 21–26 ms vs 341+ ms |
| Several fields updated together | ReentrantLock or synchronized |
monitors no longer pin since JDK 24 |
| Fan-out that must fail fast | StructuredTaskScope (preview on 26) |
108 ms instead of 2 s on failure |
| Per-request context | ScopedValue (final since 25) |
inherited by forked subtasks |
Expensive object per thread in ThreadLocal |
rethink with virtual threads | one copy per task, not per pool thread |
Honest limits and version notes
- Virtual threads became final in Java 21:
javac --release 20still rejectsnewVirtualThreadPerTaskExecutor()as a preview API,--release 21accepts it. Everything else here was run only on 26. - The JDK 24 pinning change was observed on 26 only; the "10 s on JDK 21" figure is what pinning would cost by arithmetic, not a run.
- The 4,096-thread ceiling is a macOS setting. A Linux server will fail at a different number, and a container may fail earlier.
- RSS includes the whole JVM. The heap figure from
Runtimeis a snapshot after the tasks ended, not a peak. - No JMH. The CPU test warmed up
work()once; the race timings did not warm up, so only the ranking is meaningful.
The next lecture opens the JVM itself: JIT warm-up, garbage collectors and memory leaks, measured the same way.
Previous: Lecture 4: streams, lambdas and Optional · Next: Lecture 6: JIT, GC and memory
Related: Python lecture 5: threads, processes, asyncio and the GIL · VB.NET lecture 5: Async/Await
FAQ
Do virtual threads make CPU-bound Java code faster?
No. 64 CPU-bound tasks took 4,385 ms on a fixed pool of 8 threads and 4,695 ms with one virtual thread per task (median of 3 rounds, 8-core M1 Pro). Virtual threads help when tasks wait; for pure computation the number of cores is the limit.
Does synchronized still pin virtual threads on JDK 26?
Not for ordinary blocking. Since JDK 24 (JEP 491) a virtual thread that sleeps inside synchronized unmounts: 100 tasks sleeping 100 ms each inside their own monitor finished in about 115 ms on a single carrier. Blocking inside a class initializer still pins, and JFR recorded it as jdk.VirtualThreadPinned. The old -Djdk.tracePinnedThreads property no longer exists in JDK 26.
Is structured concurrency final in Java 26?
No. StructuredTaskScope is still a preview API on JDK 26 and javac refuses it without --enable-preview. ScopedValue, on the other hand, compiles without flags since JDK 25; with --release 24 javac still reports it as a preview API.