How do you create virtual threads, and why is creating millions of them cheap?
answer
- Thread.ofVirtual().start(...) / Thread.startVirtualThread(...)
- Executors.newVirtualThreadPerTaskExecutor() = one VT per task
- Cheap: no OS thread, no big fixed stack, JVM-scheduled
- Stack lives on the heap, grows on demand
- Don't pool virtual threads — create one per task
basics
~10 sUse Thread.ofVirtual().start(...) or Executors.newVirtualThreadPerTaskExecutor(). They're cheap because a virtual thread doesn't reserve a big OS-thread stack — its stack lives on the heap and grows as needed, so millions fit.
solid answer
~50 sYou create virtual threads with the JDK's factory APIs rather than pooling them. The simplest is Thread.ofVirtual().start(runnable), or Thread.startVirtualThread(runnable). For task fan-out, Executors.newVirtualThreadPerTaskExecutor() gives one fresh virtual thread per submitted task — you don't size or reuse a pool. They are cheap for three reasons: (1) a virtual thread is not backed by an OS thread, so creating one needs no system call; (2) it doesn't reserve a large fixed native stack (~1 MB for a platform thread) — its stack is stored on the heap and grows/shrinks on demand, so an idle one costs only a small amount of memory; (3) the JVM, not the OS, schedules them, so there's no kernel bookkeeping per thread. Because each one is so light, you create a new virtual thread per task — pooling virtual threads is an anti-pattern, since the whole point is that creation is free.
go deeper
Can name Thread.ofVirtual().start and newVirtualThreadPerTaskExecutor and state that virtual threads are cheap to create.
Explains the three cost reasons (no OS thread/syscall, heap-stored growable stack, JVM scheduling) and the one-thread-per-task idiom.
Articulates why pooling virtual threads is an anti-pattern and how to bound concurrency on downstream resources with a semaphore instead of capping threads.
Reasons about memory/heap implications at extreme counts, thread-local pitfalls under per-task threads, and migration patterns for code that previously relied on bounded platform-thread pools.
## What you're creating A **virtual thread** is a `java.lang.Thread` scheduled by the **JVM** (the Java runtime) instead of the operating system. Unlike a **platform thread** (a Java thread backed one-to-one by an **OS thread**), a virtual thread has no dedicated OS thread of its own; it borrows one (a **carrier**) only while actually running. ## How to create one There are three common ways, all from the standard library (Java 21+): 1. **`Thread.ofVirtual().start(runnable)`** — a builder that configures and starts a single virtual thread. `Thread.ofVirtual().name("worker").unstarted(runnable)` builds one without starting it. 2. **`Thread.startVirtualThread(runnable)`** — a one-liner shortcut that builds and starts a virtual thread. 3. **`Executors.newVirtualThreadPerTaskExecutor()`** — an `ExecutorService` that creates **one new virtual thread per submitted task**. This is the idiomatic way to run many concurrent tasks: you submit work, and each gets its own throwaway virtual thread. Crucially, you do **not** size these like a platform-thread pool. With platform threads you carefully pick a pool size because threads are scarce; with virtual threads you create one per task and let the JVM multiplex them onto a small set of carriers. ## Why creation is cheap Three reasons: **1. No OS thread, no system call.** Creating a platform thread requires a **system call** (a request into the OS kernel) to allocate an OS thread — relatively slow. A virtual thread is a plain Java object managed by the JVM; creating it is roughly as cheap as allocating an object, with no kernel involvement. **2. No large fixed stack.** Each **stack** holds a thread's local variables and its chain of in-progress method calls. A platform thread reserves a big, fixed native stack — commonly around **1 MB** — up front. A million of those would need ~1 TB of memory, which is impossible. A virtual thread instead stores its stack on the **heap** (the JVM's general-purpose memory), and it **grows and shrinks on demand**. An idle or shallow virtual thread holds only a few hundred bytes to a few kilobytes, so **millions** fit in ordinary heap. **3. JVM scheduling, not kernel bookkeeping.** The OS scheduler tracks every OS thread it manages; that bookkeeping doesn't scale to millions. Virtual threads are scheduled by the JVM and only consume a real OS thread (a carrier) for the brief moments they run, so the kernel only ever sees the small set of carriers. ## The 'one thread per task' mindset Because creation is essentially free, the recommended pattern is **one virtual thread per task** and let it die when the task ends. **Pooling virtual threads is an anti-pattern**: pools exist to *reuse* an expensive resource, but virtual threads are cheap, and pooling would also reintroduce the limits (and bugs like leaked thread-locals) that pools bring. Likewise, don't try to cap concurrency by limiting the number of virtual threads — use a semaphore around the scarce downstream resource instead. ## A caution Cheap creation does **not** mean unlimited *useful* concurrency: if every virtual thread hits the same constrained resource (a 10-connection database pool, say), you still bottleneck there. Virtual threads remove the *thread* limit, not every limit.
- Should you reuse virtual threads via a pool to save creation cost?No. Pooling is for reusing expensive resources; virtual threads are cheap, so you create a fresh one per task (e.g. via newVirtualThreadPerTaskExecutor) and let it terminate. Pooling adds no benefit and risks stale thread-local state.
- If virtual threads are unlimited, how do you avoid overwhelming a small database connection pool?Bound the scarce resource, not the threads: use a Semaphore (or the pool's own limit) so only N virtual threads hold a connection at once. The other virtual threads just block cheaply until a permit is free.
saying these in an interview costs you the question
- Pooling virtual threads ('newFixedThreadPool of virtual threads') — defeats the point
- Assuming a virtual thread reserves ~1 MB like a platform thread
- Thinking creating a virtual thread makes a system call / new OS thread
- Believing cheap threads remove downstream limits like a small DB connection pool