skip to content

How do you create virtual threads, and why is creating millions of them cheap?

level: juniorimportance: should knowfreq 58%

answer

  1. Thread.ofVirtual().start(...) / Thread.startVirtualThread(...)
  2. Executors.newVirtualThreadPerTaskExecutor() = one VT per task
  3. Cheap: no OS thread, no big fixed stack, JVM-scheduled
  4. Stack lives on the heap, grows on demand
  5. Don't pool virtual threads — create one per task

basics

~10 s

Use 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 s

You 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

for a junior

Can name Thread.ofVirtual().start and newVirtualThreadPerTaskExecutor and state that virtual threads are cheap to create.

for a middle

Explains the three cost reasons (no OS thread/syscall, heap-stored growable stack, JVM scheduling) and the one-thread-per-task idiom.

for a senior

Articulates why pooling virtual threads is an anti-pattern and how to bound concurrency on downstream resources with a semaphore instead of capping threads.

for a principal

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

context