Virtual Threads (Java 21)

Reviewed & published by Brayan K

A virtual thread is a lightweight thread scheduled by the JVM rather than the operating system, so you can run millions of blocking tasks at once on just a handful of OS threads.

Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

Before You Start

You should have met basic threads and concurrency and the ExecutorService / Future API. This lesson needs Java 21 or newer — virtual threads became a final feature in Java 21.

What You'll Learn

The Big Idea — Waiters vs Tables

💡 Analogy: Imagine a restaurant where every table (task) needs a dedicated waiter (platform thread) who must stand and wait while the kitchen cooks. With only a few waiters, most tables sit unserved. Virtual threads flip this: each table still gets its own waiter, but a waiter who is just waiting on the kitchen steps aside so another can take an order. The same handful of real staff (carrier OS threads) now serve thousands of tables, because nobody stands idle blocking a chair.

That is the whole pitch: keep the simple "one thread per request" code you already know, but stop paying the price of an OS thread for every request that is mostly waiting.

1️⃣ Creating Virtual Threads

There are two direct ways to create one. Thread.startVirtualThread(runnable) creates and starts in a single call. Thread.ofVirtual() returns a builder you can configure (name it, set it unstarted) before starting. Both yield a normal Thread object — you still join() on it.

public class Main {
    public static void main(String[] args) throws InterruptedException {
        // Start a single virtual thread and wait for it.
        Thread vt = Thread.startVirtualThread(() ->
                System.out.println("Hello from a virtual thread!"));
        vt.join();

        // Build one with a factory so we can name it.
        Thread named = Thread.ofVirtual()
                .name("worker-1")
                .unstarted(() -> System.out.println("Running on a thread named: "
                        + Thread.currentThread().getName()));
        named.start();
        named.join();

        // Confirm it really is virtual (not a platform/OS thread).
        Thread check = Thread.ofVirtual().unstarted(() -> {});
        System.out.println("isVirtual? " + check.isVirtual());
        System.out.println("A platform thread isVirtual? "
                + Thread.ofPlatform().unstarted(() -> {}).isVirtual());
    }
}

2️⃣ Why They Scale — the Executor

The headline use is Executors.newVirtualThreadPerTaskExecutor(): it spawns a brand-new virtual thread for every task you submit. Below we launch 10,000 tasks that each sleep 100ms (standing in for a network call). With platform threads, 10,000 OS threads would exhaust memory; with virtual threads, they all run on a tiny carrier pool and the whole batch finishes in well under a second.

3️⃣ Collecting Results Deterministically

Threads finish in an unpredictable order, so if you want repeatable output you must join all tasks (wait via future.get() or the executor's close()), gather results in a thread-safe structure, and sort before printing. The example below does exactly that.

import java.util.*;
import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) throws Exception {
        // Fan out work across virtual threads, then JOIN and SORT for
        // deterministic output (threads finish in unpredictable order).
        List<Integer> inputs = List.of(5, 3, 8, 1, 9, 2);
        List<String> results = Collections.synchronizedList(new ArrayList<>());

        try (ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<?>> fs = new ArrayList<>();
            for (int n : inputs) {
                fs.add(ex.submit(() -> results.add(n + " squared = " + (n * n))));
            }
            for (Future<?> f : fs) f.get();   // wait for all
        }

        Collections.sort(results);            // make order deterministic
        results.forEach(System.out::println);
    }
}

Platform vs Virtual Threads

AspectPlatform threadVirtual thread
Backed byOne OS threadJVM scheduler + carrier pool
Cost to createExpensive (~1MB stack)Cheap (a few hundred bytes)
Practical countThousandsMillions
Best forCPU-bound workI/O-bound, high concurrency
PoolingPool & reuseCreate one per task

🧠 Quick Recall

Predict the answer before opening each.

Answer: yes — that is precisely the scenario virtual threads are built for. A million platform threads would crash the JVM with out-of-memory, but a million mostly-sleeping virtual threads share a tiny carrier pool.

Answer: no. That work is CPU-bound and never blocks, so you are limited by the number of cores. Virtual threads help when tasks wait, not when they compute non-stop.

Answer: ExecutorService is AutoCloseable; its close() runs an orderly shutdown that waits for every submitted task to complete before the block exits. That is why we don't need an explicit awaitTermination.

🎯 YOUR TURN — Greet in parallel

Submit one greeting task per name to a virtual-thread executor, join them all, then sort and print for deterministic output.

import java.util.*;
import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) throws Exception {
        List<String> names = List.of("Ana", "Ben", "Cara", "Dan", "Eve");

        // YOUR TURN: submit one task per name to a virtual-thread executor.
        // Each task should add "Hello, <name>!" to a shared synchronized list.
        // After all tasks finish (use f.get()), SORT the list and print it so
        // the output is deterministic.
        //
        // Expected output:
        //   Hello, Ana!
        //   Hello, Ben!
        //   Hello, Cara!
        //   Hello, Dan!
        //   Hello, Eve!

        // your code here
    }
}

🧩 MINI-CHALLENGE — Parallel sum

Sum 1..1000 by splitting the range into 10 chunks, each computed on its own virtual thread as a Future<Long>, then add the partial sums.

import java.util.*;
import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) throws Exception {
        // MINI-CHALLENGE: compute the sum of 1..1000 in PARALLEL.
        // Split the range into 10 chunks of 100. Submit each chunk as a task
        // to a virtual-thread-per-task executor, have each return its partial
        // sum (Future<Long>), then add the partial sums together.
        //
        // Expected output:
        //   Sum 1..1000 = 500500

        // your code here
    }
}

4️⃣ A Peek at Structured Concurrency (preview)

Virtual threads make it cheap to fan out subtasks, and structured concurrency (a preview feature in Java 21, JEP 453) gives that fan-out a clean shape. With StructuredTaskScope you fork related subtasks and join them as one unit — and if one fails, the scope can cancel the rest automatically. It replaces loose collections of Futures with nested, readable structure.

// Structured concurrency is a PREVIEW feature in Java 21 (JEP 453).
// Enable with: javac --release 21 --enable-preview ...  /  java --enable-preview ...
import java.util.concurrent.StructuredTaskScope;

String fetchUserAndOrder() throws Exception {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        var user  = scope.fork(() -> loadUser());    // subtask 1
        var order = scope.fork(() -> loadOrder());   // subtask 2

        scope.join();            // wait for BOTH
        scope.throwIfFailed();   // propagate the first failure, cancel the rest

        return user.get() + " / " + order.get();
    }
}

Common Errors

Pro Tips

📋 Quick Reference

ConceptSyntaxNote
start oneThread.startVirtualThread(r)Create + start
builderThread.ofVirtual().name("x")Configure first
unstarted.unstarted(r)Start later
executornewVirtualThreadPerTaskExecutor()One thread per task
submitex.submit(callable)Returns Future
wait onefuture.get()Blocks for result
wait allex.close() (try-with-res.)Orderly shutdown
is virtual?thread.isVirtual()true / false
platform threadThread.ofPlatform()Classic OS thread
avoid pinningReentrantLockNot synchronized
structuredStructuredTaskScopePreview (JEP 453)

📚 Resources / Keep Learning

🎉 Lesson Complete!

You can now create virtual threads, run thousands of blocking tasks through newVirtualThreadPerTaskExecutor, explain why they scale for I/O while platform threads suit CPU work, dodge pinning, and recognise where structured concurrency fits.

Practice quiz

What is a virtual thread in Java 21?

  • An OS thread with a special name
  • A thread that only runs in the cloud
  • A lightweight thread managed by the JVM, not the OS
  • A deprecated thread type

Answer: A lightweight thread managed by the JVM, not the OS. Virtual threads are scheduled by the JVM and mapped onto a small pool of platform (OS) threads, so you can have millions of them.

Which call starts a single virtual thread immediately?

  • Thread.startVirtualThread(runnable)
  • new Thread().start()
  • Thread.ofPlatform().start()
  • Executors.newFixedThreadPool(1)

Answer: Thread.startVirtualThread(runnable). Thread.startVirtualThread(Runnable) creates and starts a virtual thread in one call.

Which executor creates a fresh virtual thread per task?

  • Executors.newFixedThreadPool(n)
  • Executors.newCachedThreadPool()
  • Executors.newSingleThreadExecutor()
  • Executors.newVirtualThreadPerTaskExecutor()

Answer: Executors.newVirtualThreadPerTaskExecutor(). newVirtualThreadPerTaskExecutor() spins up a new virtual thread for every submitted task — ideal because virtual threads are cheap.

Virtual threads shine most for which kind of workload?

  • CPU-bound number crunching
  • I/O-bound tasks that block a lot
  • Pure in-memory sorting
  • GPU rendering

Answer: I/O-bound tasks that block a lot. When a virtual thread blocks on I/O it is unmounted, freeing its carrier so other virtual threads run. CPU-bound work is still limited by your cores.

How many platform (OS) threads back many blocked virtual threads?

  • A small pool of carrier threads
  • One per virtual thread
  • Exactly two
  • Zero

Answer: A small pool of carrier threads. Many virtual threads share a small pool of carrier (platform) threads; a blocked virtual thread releases its carrier.

What happens when an ExecutorService from a try-with-resources block closes?

  • It cancels all tasks
  • It throws an exception
  • It waits for all submitted tasks to finish
  • Nothing

Answer: It waits for all submitted tasks to finish. ExecutorService implements AutoCloseable; close() performs an orderly shutdown that blocks until tasks complete.

How do you check if a Thread is virtual?

  • thread.isLight()
  • thread.isVirtual()
  • thread.getType()
  • thread.virtual

Answer: thread.isVirtual(). Thread.isVirtual() returns true for virtual threads and false for platform threads.

Why should you avoid pooling virtual threads?

  • They cannot be pooled
  • Pooling corrupts them
  • The JVM forbids it
  • They are so cheap to create that pooling adds no value

Answer: They are so cheap to create that pooling adds no value. Virtual threads are cheap to create and discard, so the classic 'reuse expensive threads' rationale for pools disappears — create one per task.

What can pin a virtual thread to its carrier (preventing unmount)?

  • A simple loop
  • Blocking inside a synchronized block or a native call
  • Returning a value
  • Calling join()

Answer: Blocking inside a synchronized block or a native call. Blocking while holding a monitor (synchronized) or inside native code can pin the virtual thread, tying up its carrier. Prefer ReentrantLock in hot paths.

What is Structured Concurrency (preview) for?

  • Sorting threads
  • Replacing virtual threads
  • Treating a group of related subtasks as one unit with shared cancellation
  • Drawing thread diagrams

Answer: Treating a group of related subtasks as one unit with shared cancellation. StructuredTaskScope groups subtasks so they start, join, and cancel together — a cleaner model than scattered futures.

Continue this course

Frequently asked questions

What is a virtual thread and how is it different from a regular thread?

A classic platform thread is a thin wrapper over an operating-system thread, which is heavy — typically around a megabyte of stack — so a JVM can only handle a few thousand. A virtual thread is scheduled by the JVM itself and mounted onto a small pool of platform 'carrier' threads only while it is actually running. When a virtual thread blocks on I/O, the JVM unmounts it and frees the carrier for other work. The upshot: you can have millions of virtual threads, and you write plain blocking code instead of callbacks.

When should I use virtual threads instead of platform threads?

Use virtual threads for I/O-bound, high-concurrency workloads: handling thousands of simultaneous HTTP requests, database calls, or remote service calls where each task spends most of its time waiting. They let you write simple thread-per-request code that scales. For purely CPU-bound work (heavy computation that never blocks) virtual threads give no speedup — you are still limited by your CPU cores, so a fixed platform-thread pool sized to your cores is fine.

Do I still need thread pools with virtual threads?

Not in the old sense. Thread pools existed because platform threads were expensive to create, so you reused them. Virtual threads are cheap to create and discard, so you create one per task — typically via Executors.newVirtualThreadPerTaskExecutor(). You may still pool a limited resource the threads use, such as database connections, but you no longer pool the threads themselves.

What is 'pinning' and why does it matter?

A virtual thread is normally unmounted from its carrier when it blocks. But if it blocks while inside a synchronized block or a native method, it can become 'pinned' — it holds onto its carrier thread instead of releasing it. Heavy pinning under load reduces scalability because carriers get tied up. The fix is to prefer java.util.concurrent locks like ReentrantLock over synchronized in hot, blocking code paths.

What is structured concurrency?

Structured concurrency (a preview feature in Java 21, JEP 453) treats a group of related subtasks as a single unit of work. Using StructuredTaskScope you fork subtasks, join them together, and have shared rules for cancellation and error propagation — for example, if one subtask fails, the scope can cancel the others automatically. It pairs naturally with virtual threads and replaces scattered, hard-to-reason-about collections of Futures with a clean, nested structure.

Related lessons