Start with the one fact you already know. A traditional Java thread — a java.lang.Thread — is a thin wrapper around a thread provided by the operating system. When you create one, the JVM turns to the OS kernel and says: give me a real, kernel-scheduled thread. That single design decision, a strict one-to-one binding between your thread and a kernel thread, is the source of everything that follows — both the limitation and the breakthrough.
Everything in this guide is built on top of that fact. We'll give the threads you already know their new official name, expose the two hard limits the 1:1 binding imposes, then watch — animation by animation — how virtual threads dissolve those limits without changing a single line of the way you write code.
Why platform threads hit a wall
Let's name things precisely. The threads you already know are now officially called platform threads. Each one is permanently bound — mapped 1:1 — to an OS thread for its entire lifetime. Virtual threads are the new kind, shipped as a preview in Java 19 and made final in Java 21 (September 2023) under JEP 444.
The 1:1 binding creates two hard limits.
Limit one — memory
Each OS thread reserves a large, fixed block of stack memory: typically around 1 MB on a 64-bit JVM by default. Create 10,000 platform threads and you've reserved roughly 10 GB of stacks before doing a scrap of useful work. The OS scheduler also strains to context-switch fairly among tens of thousands of threads.
Limit two — blocking waste
Most server applications are I/O-bound, not CPU-bound. A thread handling a web request spends almost its entire life waiting — for a database query, an HTTP call to another service, a file read. While a platform thread waits on a blocking call, its underlying OS thread is parked doing nothing, yet still holds that full megabyte of stack and its slot in the scheduler. You are paying for an expensive resource to sit idle.
Drag the slider below to see the trap in numbers — how stack memory and idle waste scale as concurrency climbs under the 1:1 model.
The trap. The readable way to write server code is thread-per-request: one thread per request, in simple straight-line blocking style. But because each thread is expensive, you can only afford a few thousand. So high-concurrency systems were pushed toward asynchronous and reactive programming — which scales beautifully but is notoriously hard to read, debug, and reason about. Virtual threads exist to dissolve exactly this tradeoff.
What a virtual thread actually is
A virtual thread is a Thread that is not tied to a specific OS thread. It is a lightweight object managed entirely by the JVM. Many thousands — even millions — of virtual threads are multiplexed onto a small pool of platform threads called carrier threads.
Here is the single most important mental model in the whole topic. Read it top to bottom: cheap things at the top, expensive things at the bottom, and a narrow waist in the middle where the real resources live.
Mount, unmount, and the magic of blocking
The mechanism that makes this work is called mounting and unmounting, and it is where virtual threads earn their keep. Walk through it carefully, because this is the conceptual heart of everything.
When a virtual thread has work to do, the scheduler mounts it onto a free carrier: the virtual thread's stack is placed onto the carrier and the carrier begins executing it. So far, this looks exactly like a normal thread running.
Now the virtual thread hits a blocking operation — socket.read() waiting for data, or a JDBC query. With a platform thread, the OS thread would simply block and sit idle. With a virtual thread, the JVM intercepts the blocking call, unmounts the virtual thread from its carrier (saving its stack state back onto the heap as a small object), and frees the carrier. That carrier immediately picks up another virtual thread that is ready to run. When the I/O completes, the original virtual thread is marked ready and gets remounted — possibly onto a different carrier — resuming exactly where it left off.
Press play below and watch a single virtual thread ride a carrier, hit a blocking read, step off so another thread can run, and step back on when its data arrives.
You write plain blocking code. The JVM does the unmount and remount underneath — invisibly.
The remarkable part: none of this requires you to change how you write code. You write a plain blocking call — inputStream.read(), Thread.sleep(), a JDBC query — in normal top-to-bottom style. The JVM's reworked I/O and concurrency libraries handle the unmount and remount transparently. You get the scalability of asynchronous code with the simplicity of blocking code. That is the entire pitch.
Watch both models handle the same load
Here is the contrast that makes everything concrete. The same burst of I/O-bound requests arrives. On the left, the platform-thread model: every request needs its own OS thread, and most of those threads sit blocked-but-occupied. On the right, the virtual-thread model: two carriers stay continuously busy running whichever virtual threads are ready, because a blocked virtual thread releases its carrier.
How you actually create one
The API is deliberately tiny, because a virtual thread is a Thread. Here are the main ways to make them.
// 1. Start a single virtual thread directly
Thread vt = Thread.ofVirtual().start(() -> {
System.out.println("Running in " + Thread.currentThread());
});
vt.join();
// 2. The unstarted builder form
Thread t = Thread.ofVirtual().name("worker-1").unstarted(runnable);
t.start();
// 3. The one you'll use most: a virtual-thread-per-task executor.
// Every submitted task gets its OWN brand-new virtual thread.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // blocking — and totally fine
return fetchFromDatabase();
});
}
} // try-with-resources waits for all tasks to finishMindset shift. With platform threads you pool them, because they're scarce. With virtual threads you do not pool them. They are so cheap to create that you spawn a fresh one for every single task — even a million of them. Pooling virtual threads is an anti-pattern; the pool was only ever a workaround for platform threads being expensive.
A benchmark you can feel
The classic demonstration: launch 10,000 tasks that each just sleep for one second. Choose an executor below and run it — the simulated wall-clock time tells the whole story.
Where they help — and where they don't
This is the part people most often get wrong, so be crystal clear. Virtual threads help with I/O-bound, high-concurrency workloads — code that spends most of its time waiting. That's the overwhelming majority of server-side software: web handlers, microservices calling microservices, database-heavy endpoints, API gateways.
Virtual threads do not make CPU-bound work faster. A task that is pure number-crunching and never blocks holds its carrier the whole time and behaves just like a platform thread. You can't run more CPU-bound work in parallel than you have cores, no matter how many virtual threads you spawn. For that, a bounded pool sized to your core count is still correct.
✓ Great fit — I/O bound
- HTTP request handlers
- Service-to-service calls
- Database / JDBC queries
- File and socket reads
- Fan-out to many APIs at once
- Anything that mostly waits
✗ Poor fit — CPU bound
- Heavy number crunching
- Image / video encoding
- Cryptographic hashing
- In-memory sorting of huge arrays
- Tasks that never block
- Use a core-sized pool instead
The test is simple: does the task spend most of its life waiting? If yes, virtual threads shine. If it pins the CPU, they offer nothing a core-sized pool wouldn't.
The sharp edges
Pinning
A virtual thread normally unmounts when it blocks. In two situations it can't, and instead pins itself to its carrier — meaning the carrier is held hostage for the whole block, defeating the purpose. Historically this happened when a virtual thread blocked inside a synchronized block or method, or while executing a native method or foreign function call. If many threads pin at once, you can starve the carrier pool and watch throughput collapse.
The classic Java 21 fix was to replace synchronized around blocking operations with ReentrantLock, which is pinning-aware. Worth telling readers: in JDK 24 / JEP 491 the synchronized pinning problem was largely eliminated, so on newer JDKs this is far less of a concern — but native calls can still pin, and plenty of production code still runs on 21.
ThreadLocal at scale
Thread-locals still work on virtual threads, but the design assumed a bounded number of threads. With potentially millions of virtual threads, careless ThreadLocal use — especially large cached objects — can balloon memory, since each thread gets its own copy. The modern replacement built for this world is ScopedValue (JEP 506), which shares immutable data down a call tree without per-thread copies.
No preemption across CPU work
The scheduler is cooperative around blocking points. A virtual thread running a tight CPU loop with no blocking call won't voluntarily yield its carrier, so don't expect virtual threads to time-slice CPU-bound work the way the OS time-slices platform threads.
Structured concurrency
One last idea, because it's the idiom virtual threads were built to enable. Structured concurrency (StructuredTaskScope, via JEP 505) lets you treat a group of concurrent subtasks as a single unit of work. You fork several virtual threads, then join them, with clean error propagation and cancellation — if one subtask fails, the others are automatically cancelled.
// Fetch a user and their order in parallel, treated as one unit.
try (var scope = StructuredTaskScope.open()) {
var user = scope.fork(() -> findUser(userId)); // own virtual thread
var order = scope.fork(() -> fetchOrder(userId)); // own virtual thread
scope.join(); // wait for both; propagates any failure
return new Response(user.get(), order.get());
} // if either failed, the other was cancelled and cleaned upThis reads like sequential code, runs concurrently, and avoids the "leaked thread" and "forgotten cancellation" hazards of manual Future juggling. Virtual threads make the forked threads free; structured concurrency makes managing them safe.
The whole picture in one table
| Platform thread | Virtual thread | |
|---|---|---|
| Backed by | One OS thread (1:1) | JVM object, multiplexed onto carriers |
| Stack memory | ~1 MB, fixed | ~KB, grows on the heap |
| Practical max count | A few thousand | Millions |
| On blocking I/O | OS thread parked, idle | Unmounts, frees the carrier |
| Best for | CPU-bound work | I/O-bound, high concurrency |
| Should you pool them? | Yes | No — one per task |
| Programming style | Blocking (but expensive) | Blocking (and cheap) |
That's the complete arc: the 1:1 binding you already understood, the carrier/mount/unmount mechanism that breaks it, the cheap thread-per-task model it unlocks, the precise line between where it helps (I/O) and where it doesn't (CPU), the pinning and ThreadLocal gotchas, and the structured-concurrency idiom that ties it all together. Write plain blocking code — and let the JVM make it scale.