What are the different ways to create and start a virtual thread in Java, and how do they differ?
answer
- startVirtualThread = create+start one-liner
- ofVirtual() -> builder -> start vs unstarted
- unstarted = NEW state, start later
- newVirtualThreadPerTaskExecutor = one VT per task, no pool
- never pool virtual threads
basics
~10 sUse Thread.startVirtualThread(runnable) to create and start one immediately, Thread.ofVirtual().start(runnable) for the same via a builder, .unstarted(runnable) to build without starting, or Executors.newVirtualThreadPerTaskExecutor() to get one virtual thread per submitted task.
solid answer
~40 sThere are a few entry points. Thread.startVirtualThread(runnable) is the one-liner: it builds and starts a virtual thread in one call. Thread.ofVirtual() returns a Thread.Builder.OfVirtual where you can set a name, an uncaught-exception handler, etc., then call .start(runnable) to start it or .unstarted(runnable) to get a ready-but-not-started Thread you start later. For running many tasks, Executors.newVirtualThreadPerTaskExecutor() returns an ExecutorService that spawns a brand-new virtual thread per submitted task rather than pooling — virtual threads are cheap enough that pooling them is an anti-pattern. All of these produce daemon-by-nature, unpooled threads scheduled onto a small set of platform carrier threads. Pick the builder when you need configuration, the executor when you have a stream of tasks, and the one-liner for quick fire-and-forget work.
go deeper
Can name at least one creation API (e.g. Thread.startVirtualThread) and knows virtual threads are cheap and not pooled.
Distinguishes all four APIs, knows start vs unstarted, and uses newVirtualThreadPerTaskExecutor with try-with-resources for many tasks.
Explains why per-task (no pooling) is correct, configures the builder (name, handler), and reasons about daemon/priority being fixed.
Frames creation-API choice within an application's concurrency model and migration strategy, and can justify the per-task executor as the default server idiom over legacy fixed pools.
## Background: what a virtual thread is A **thread** is an independent path of execution. Before Java 21, every Java `Thread` was a **platform thread** — a thin wrapper over an operating-system (OS) thread. OS threads are expensive: each reserves a large stack (often ~1 MB) and the OS kernel schedules them, so a JVM can practically run only a few thousand. That limits how many concurrent blocking tasks (e.g. waiting on the network) you can have. A **virtual thread** (Java 21+, JEP 444) is a thread managed by the JVM, not the OS. Many virtual threads share a small pool of platform threads called **carrier threads**. When a virtual thread runs, it is *mounted* onto a carrier; when it blocks, it *unmounts* and frees that carrier. You can have **millions** of virtual threads. They are meant for **throughput of blocking, I/O-bound tasks**, not for CPU-bound number crunching. ## The creation APIs (the coverage of this topic) There are four ways to make one, all producing the same kind of object (`Thread` whose `isVirtual()` is true): 1. **`Thread.startVirtualThread(Runnable)`** — the convenience one-liner. It *creates and starts* the virtual thread immediately and returns the started `Thread`. Use for quick fire-and-forget tasks. 2. **`Thread.ofVirtual()`** — returns a **`Thread.Builder.OfVirtual`**, a fluent builder. You can configure it: `.name("worker-", 0)` (a prefix plus a counter), `.inheritInheritableThreadLocals(false)`, `.uncaughtExceptionHandler(h)`. Then: - **`.start(Runnable)`** — builds *and starts* it, returning the running `Thread`. - **`.unstarted(Runnable)`** — builds it in the **NEW** (not-yet-started) state and returns it; you call `.start()` on it later. Useful when you need a reference before it runs. 3. **`Executors.newVirtualThreadPerTaskExecutor()`** — returns an `ExecutorService`. Unlike a normal thread pool, it does **not** reuse threads: it creates a *new* virtual thread for *each* task you `submit`/`execute`. Because virtual threads are cheap, there is no pool to size. As an `ExecutorService` it works with try-with-resources (it implements `AutoCloseable`), so closing it waits for all submitted tasks to finish. This is the idiomatic choice for serving many concurrent requests. There is also `Thread.ofPlatform()` — the parallel builder for *platform* threads — useful to know exists for contrast, but it does not create virtual threads. ## Key properties of created virtual threads - They are always **daemon** in effect and have a **fixed priority** (`NORM_PRIORITY`); calling `setDaemon`/`setPriority` has no effect. - They are **unnamed by default** (empty string) unless you set a name via the builder — relevant for logging/debugging. - You **do not pool** them. Pooling exists to amortize the cost of expensive platform threads; virtual threads are cheap, so a per-task model is correct. ## Choosing - One-off task, no config → `Thread.startVirtualThread`. - Need a name/handler, or need the `Thread` object before it runs → `Thread.ofVirtual()...start/unstarted`. - A stream of tasks (a server handling requests) → `newVirtualThreadPerTaskExecutor()`.
- Why is pooling virtual threads considered an anti-pattern?Pools exist to amortize the high cost of creating OS-backed platform threads. Virtual threads are cheap to create and discard, so pooling adds contention and limits concurrency for no benefit — a new-thread-per-task model is correct.
- What does unstarted() give you that start() does not?A Thread object in the NEW state that hasn't begun executing, so you can hold a reference, configure or store it, and call start() at a chosen moment.
saying these in an interview costs you the question
- Claiming you should pool virtual threads like platform threads
- Thinking newVirtualThreadPerTaskExecutor reuses a fixed set of threads
- Confusing .start() (runs it) with .unstarted() (returns it NEW)
- Believing setPriority/setDaemon meaningfully change a virtual thread