Who actually runs your virtual threads?
6 min read
Carriers, cores, maxPoolSize, parking, unmounting and the compensation trick the JDK uses when something blocks.
When I first read “you can create a million virtual threads”, my next thought was: run on what? A laptop with 8 cores still has 8 cores. The answer is that virtual threads don’t replace platform threads. They sit on top of a small pool of them, and most of the confusing behaviour (stalls, “why is my app slow with only 3 threads busy?”, odd thread names in dumps) comes from not knowing how that pool is managed. So let’s go through it properly.
Virtual threads need carriers
A virtual thread is a normal java.lang.Thread object, but it has no OS thread of its own. When it has work to do, the scheduler picks a platform thread and runs it there. That platform thread is called the carrier. The same virtual thread may use a different carrier next time; there is no affinity.
The default scheduler is a ForkJoinPool, which is why carriers show up in dumps as ForkJoinPool-1-worker-N. Running a virtual thread is called mounting it on a carrier. Taking it off is unmounting.

Many virtual threads, few carriers. Each carrier is a real OS thread.
Cores, parallelism and maxPoolSize
Three settings control the carrier pool, and people mix them up constantly:
| Property | What it controls | Default |
|---|---|---|
jdk.virtualThreadScheduler.parallelism |
How many carriers the pool aims to keep. This is how many virtual threads can run at the same instant. | Number of CPU cores |
jdk.virtualThreadScheduler.maxPoolSize |
Hard ceiling on carriers, including temporary ones added by compensation. | 256 |
jdk.virtualThreadScheduler.minRunnable |
Minimum number of carriers allowed to be not blocked. It is passed straight to the pool’s constructor. | I haven’t verified this, so check your JDK |
The JDK reads the first two as ForkJoinPool constructor arguments. In the source, if you set maxPoolSize lower than parallelism, parallelism gets clamped down to it.
Parallel versus concurrent
This is the part worth burning into memory. With 8 cores you can have 100,000 virtual threads alive, but at most 8 are executing at once. The rest are parked, waiting for I/O, a lock or a timer, and cost almost nothing. That’s why virtual threads shine for I/O-bound work and do nothing for CPU-bound work: if every thread is crunching numbers, 8 carriers are 8 carriers.
The scheduler also doesn’t preempt. A virtual thread that spins in while(true) never yields. Rock the JVM demonstrates this nicely: set maxPoolSize=1 and a busy-looping virtual thread starves everyone else; set it to 2 and a second carrier picks up the other thread.
Park and unmount
Here is the normal life of a virtual thread that hits a blocking call, such as a socket read, Thread.sleep, BlockingQueue.take() or a java.util.concurrent lock:
- It calls the blocking method. The JDK sees it’s on a virtual thread and parks it instead of blocking the OS thread.
- The thread’s stack frames are copied from the carrier’s stack into a heap object. That’s the unmount.
- The carrier is now free and immediately picks another runnable virtual thread.
- When the I/O completes or the timer fires, something submits the virtual thread back to the scheduler.
- Some carrier (maybe a different one) mounts it again, copies the stack back, and execution continues right after the blocking call.

Per the OpenJDK notes, this friendly path covers things like Thread.sleep, Thread.join, everything blocking in java.util.concurrent, and socket accept/read/write.
Pinned: when parking can’t unmount
Sometimes a virtual thread can’t leave its carrier. JEP 444 names two cases: it is inside a synchronized block or method, or it is running a native method or foreign function. If it blocks while pinned, the carrier and the OS thread underneath are blocked too, exactly like the old days. In stack traces you may notice a frame called parkOnCarrierThread, which is the virtual thread parking the carrier itself.
Version note. JEP 491 (JDK 24) removed most of the synchronized pinning and the Object.wait limitation. On JDK 21 to 23 it’s a real concern; on 24+ it’s mostly about native frames. Check which JDK you run before you rewrite code.
Compensation
Not every non-unmountable block is a dead end. Some operations can’t release the carrier but the JDK knows they will block, so it makes up for the loss. This is compensation: the scheduler temporarily adds an extra carrier so the pool still has enough threads making progress.
Inside the JDK the mechanism is the internal class jdk.internal.misc.Blocker. Code like FileInputStream.read wraps the blocking call with Blocker.begin() and Blocker.end(...) in a try/finally. If it’s a virtual thread on a ForkJoinPool carrier, begin() goes through ForkJoinPool.ManagedBlocker, which allows an extra worker to be spun up. You can’t call Blocker from your code (it’s an internal package), but the public cousin is ForkJoinPool.managedBlock.
What compensates and what doesn’t
| Blocking operation | Unmounts? | Compensates? |
|---|---|---|
| Socket I/O, sleep, j.u.c. locks and queues | Yes | Not needed |
File I/O, InetAddress lookups, Selector selection, Object.wait (JDK 21 to 23) |
No | Yes, via ManagedBlocker |
| Anything blocking while pinned (monitor or native frame) | No | No |
Two things to remember. First, compensation is bounded by maxPoolSize, so lots of simultaneous file I/O can still exhaust it. Second, the “pinned means no compensation” rule is how people get real deadlocks: there’s an OpenJDK bug (JDK-8334304) where more pinned virtual threads than available carriers just hang forever, and a plain ForkJoinPool would have compensated on the same code.
Taking a thread snapshot
When something looks stuck, a thread dump is the first thing you reach for, and here the usual tools mislead you. Plain jstack or jcmd <pid> Thread.print doesn’t list virtual threads, and the carriers often look idle. The JDK’s own bug report on the deadlock above makes a point of how hard this is to see through Thread.getAllStackTraces().
The command that does include virtual threads is:
jcmd <pid> Thread.dump_to_file -format=json /tmp/threads.json
Carrier threads appear with the ForkJoinPool-1-worker-N naming. For hunting pinning specifically:
-Djdk.tracePinnedThreads=full(orshort) prints a stack trace when a virtual thread blocks while pinned. It works on JDK 21 to 23; I haven’t checked whether it was kept in later releases.- Flight Recorder emits
jdk.VirtualThreadPinnedevents. Someone in a Spring Boot issue saw 59 of them from three messages sent over a socket insidesynchronized, which is a good example of how quickly it adds up.
When you read a dump, ask three questions: how many carriers exist, are all of them blocked, and what are the virtual threads waiting on? If every carrier sits in a native frame or monitor wait, you’ve found your pinning problem.
Practical takeaways
- Parallelism defaults to your core count, so don’t expect virtual threads to speed up CPU work.
- Don’t pool virtual threads. Create one per task; the carriers are the pool.
- Keep blocking I/O out of
synchronizedon JDK 21 to 23. UseReentrantLockinstead, or upgrade to 24+. - Raise
maxPoolSizeonly after a dump shows carriers are actually being exhausted. - Use
Thread.dump_to_fileand JFR pinning events before guessing.