skip to content

What are the different ways to create and start a virtual thread in Java, and how do they differ?

level: juniorimportance: must knowfreq 72%

answer

  1. startVirtualThread = create+start one-liner
  2. ofVirtual() -> builder -> start vs unstarted
  3. unstarted = NEW state, start later
  4. newVirtualThreadPerTaskExecutor = one VT per task, no pool
  5. never pool virtual threads

basics

~10 s

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

There 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

for a junior

Can name at least one creation API (e.g. Thread.startVirtualThread) and knows virtual threads are cheap and not pooled.

for a middle

Distinguishes all four APIs, knows start vs unstarted, and uses newVirtualThreadPerTaskExecutor with try-with-resources for many tasks.

for a senior

Explains why per-task (no pooling) is correct, configures the builder (name, handler), and reasons about daemon/priority being fixed.

for a principal

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

context