Virtual Threads in Java: Simple Concurrency at Scale

Java virtual threads let you write plain blocking code and still serve hundreds of thousands of concurrent tasks. The JVM, not the operating system, schedules them. You keep the thread-per-request model, drop the reactive complexity, and stop sizing thread pools by guesswork. Virtual threads became final in Java 21 through JEP 444, and they are now a standard tool for I/O-bound services.

Quick Takeaways

  • Virtual threads are lightweight threads managed by the JVM. They cost roughly a few hundred bytes to a few KB at creation, versus about 1 MB of reserved stack for a platform thread.
  • They shine on I/O-bound workloads (HTTP calls, JDBC, file access). They give no speedup for CPU-bound work.
  • Never pool them. Create one per task with Executors.newVirtualThreadPerTaskExecutor().
  • Watch for pinning (synchronized blocks around blocking calls on older JDKs) and unbounded resource use downstream.
Feature Platform Thread Virtual Thread
Scheduler OS kernel JVM (ForkJoinPool carrier threads)
Memory per thread ~1 MB stack reserved Grows on heap, starts tiny
Practical limit Thousands Millions
Creation cost High Very low
Pooling Required Anti-pattern
Best for CPU-bound work I/O-bound work

What Are Java Virtual Threads?

A virtual thread is an instance of java.lang.Thread that is not tied to one OS thread. The JVM mounts it on a carrier thread (a platform thread from a ForkJoinPool) when it runs. When the virtual thread hits a blocking operation, the JVM unmounts it and frees the carrier for other work.

The result: blocking is cheap. A thread waiting on a socket no longer wastes an OS thread.

The Problem Virtual Threads Solve

Classic Java servers use one platform thread per request. Each platform thread maps 1:1 to an OS thread. OS threads are expensive, so servers cap pools at a few hundred or a few thousand. When requests block on I/O, throughput hits the pool ceiling long before CPU or network saturates.

Developers escaped this with asynchronous styles: CompletableFuture chains, reactive streams (Project Reactor, RxJava). These scale, but they fragment stack traces, complicate debugging, and break try/catch and ThreadLocal intuition.

Virtual threads restore the simple model. One task, one thread, plain sequential code.

Little’s Law and Why It Matters

Concurrency equals throughput times latency (L = λW). If each request spends 100 ms waiting on I/O and you want 10,000 requests per second, you need about 1,000 requests in flight at once. With 200 platform threads, you cap at about 2,000 requests per second. With virtual threads, the in-flight limit disappears, so throughput is bound by CPU, network, or downstream systems instead.

Syntax Breakdown: Creating Virtual Threads

Java offers several entry points. Each example below is isolated for clarity.

Option 1: Thread.startVirtualThread()

public class StartVirtual {
    public static void main(String[] args) throws InterruptedException {
        // Starts immediately on a virtual thread
        Thread vt = Thread.startVirtualThread(() ->
                System.out.println("Running on: " + Thread.currentThread()));
        vt.join(); // Wait for completion
    }
}

The output prints something like VirtualThread[#21]/runnable@ForkJoinPool-1-worker-1. It shows the virtual thread ID and the carrier it is mounted on.

Option 2: Thread.ofVirtual() Builder

public class BuilderExample {
    public static void main(String[] args) throws InterruptedException {
        // Named virtual thread, created unstarted
        Thread vt = Thread.ofVirtual()
                .name("worker-", 0) // worker-0, worker-1, ...
                .unstarted(() -> System.out.println(Thread.currentThread().getName()));
        vt.start();
        vt.join();
    }
}

Use the builder when you need names, an uncaught-exception handler, or an unstarted thread. Names matter for debugging and thread dumps.

Option 3: Executors.newVirtualThreadPerTaskExecutor()

import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;

public class ExecutorExample {
    public static void main(String[] args) {
        // try-with-resources: close() waits for all submitted tasks
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            IntStream.range(0, 10_000).forEach(i ->
                executor.submit(() -> {
                    Thread.sleep(Duration.ofSeconds(1)); // Blocking is cheap here
                    return i;
                }));
        } // Implicit close() blocks until all tasks finish
    }
}

This program launches 10,000 tasks that each sleep for one second. It finishes in roughly one second of wall time, not 10,000 seconds, and it does so without a 10,000-thread OS cost. ExecutorService implements AutoCloseable as of Java 19, so try-with-resources waits for completion.

How the Scheduler Works

Concept Detail
Carrier threads Platform threads in a dedicated ForkJoinPool
Default parallelism Number of available processors
Mounting VT runs on a carrier until it blocks
Unmounting Blocking call parks the VT; its stack is copied to the heap
Remounting When I/O completes, the VT is scheduled on any free carrier

You can tune the scheduler with system properties such as -Djdk.virtualThreadScheduler.parallelism=N and -Djdk.virtualThreadScheduler.maxPoolSize=N. Defaults suit most workloads.

Practical Implementation: A Concurrent HTTP Fan-Out

Real services call several backends per request. Here is a clean fan-out with virtual threads.

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class FanOut {

    private static final HttpClient CLIENT = HttpClient.newHttpClient();

    static String fetch(String url) throws Exception {
        HttpRequest request = HttpRequest.newBuilder(URI.create(url)).GET().build();
        // Blocking send(): the virtual thread unmounts while waiting for the network
        return CLIENT.send(request, HttpResponse.BodyHandlers.ofString()).body();
    }

    public static void main(String[] args) throws Exception {
        List<String> urls = List.of(
                "https://example.com/a",
                "https://example.com/b",
                "https://example.com/c");

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<String>> futures = new ArrayList<>();
            for (String url : urls) {
                futures.add(executor.submit(() -> fetch(url)));
            }
            for (Future<String> f : futures) {
                System.out.println(f.get().length() + " bytes");
            }
        }
    }
}

The code reads top to bottom. There are no callbacks and no thenCompose chains. A failure surfaces as an ExecutionException from Future.get() with a normal stack trace.

Structured Concurrency (Preview)

Structured concurrency treats a group of related tasks as one unit of work. In JDK 21 through later releases, StructuredTaskScope has been delivered as a preview API, so you must enable preview features and check the API shape for your JDK version, since it has been revised between releases.

// Requires --enable-preview. API shape varies by JDK release; verify against your version.
import java.util.concurrent.StructuredTaskScope;

public class StructuredExample {
    record Page(String user, String orders) {}

    static Page load() throws Exception {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            var user   = scope.fork(() -> "alice");      // Subtask 1
            var orders = scope.fork(() -> "3 orders");   // Subtask 2
            scope.join().throwIfFailed();                // Wait; propagate first failure
            return new Page(user.get(), orders.get());
        }
    }
}

If one subtask fails, the scope cancels the others. No orphaned threads leak. Treat this as the direction of travel, and use ExecutorService for production code until the API is final in your target JDK.

Edge Cases and Pitfalls

1. Pinning

A virtual thread is pinned when it cannot unmount from its carrier. Pinning happens when it blocks inside a synchronized block or while a native frame is on the stack. A pinned thread holds the carrier hostage and can starve the scheduler.

In JDK 21 through 23, blocking inside synchronized pins the carrier. JDK 24 (JEP 491) removed most synchronized-related pinning. If you are on JDK 21, prefer ReentrantLock.

import java.util.concurrent.locks.ReentrantLock;

public class SafeCounter {
    private final ReentrantLock lock = new ReentrantLock();
    private int value;

    public int incrementAndFetch() {
        lock.lock(); // Virtual-thread friendly: parking unmounts the thread
        try {
            return ++value;
        } finally {
            lock.unlock();
        }
    }
}

Diagnose pinning with -Djdk.tracePinnedThreads=full or the jdk.VirtualThreadPinned JFR event.

2. Do Not Pool Virtual Threads

Pools exist to share expensive resources. Virtual threads are cheap, so pooling them adds contention for no benefit. Create one per task.

3. Limit Concurrency with Semaphores, Not Pool Size

Millions of threads can overwhelm a database or an API. Bound the resource, not the thread count.

import java.util.concurrent.Semaphore;

public class BoundedDb {
    // Allow at most 50 concurrent DB calls regardless of thread count
    private static final Semaphore DB_PERMITS = new Semaphore(50);

    static String query(String sql) throws InterruptedException {
        DB_PERMITS.acquire(); // Virtual thread parks cheaply while waiting
        try {
            return runQuery(sql);
        } finally {
            DB_PERMITS.release();
        }
    }

    private static String runQuery(String sql) { return "row"; }
}

4. ThreadLocal Memory

Each virtual thread has its own ThreadLocal map. With millions of threads, large thread-local values multiply memory use. Prefer passing data explicitly or evaluate ScopedValue (a preview/evolving API) for immutable context sharing.

5. CPU-Bound Work

Virtual threads are not preemptively time-sliced on CPU. A tight loop holds its carrier. Keep CPU-heavy work on a bounded platform-thread pool or the common ForkJoinPool.

Virtual Threads vs. Alternatives

Approach Code Style Scalability (I/O) Debuggability Learning Curve
Platform threads (pool) Blocking Limited by pool Excellent Low
CompletableFuture Async chains High Fragmented traces Medium
Reactive (Reactor/RxJava) Declarative streams Very high Hard High
Virtual threads Blocking Very high Excellent Low
Kotlin coroutines Suspend functions Very high Good Medium

Reactive frameworks still win when you need backpressure, stream composition operators, or event-driven pipelines. For plain request/response services, virtual threads deliver comparable scalability with far simpler code.

Best Practices and Refactoring: Bad Code vs. Good Code

Anti-Pattern 1: Pooling Virtual Threads

// BAD: Fixed pool defeats the purpose of virtual threads
ExecutorService pool = Executors.newFixedThreadPool(200, Thread.ofVirtual().factory());
// GOOD: One virtual thread per task, no pool sizing
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

Anti-Pattern 2: Blocking Inside synchronized (JDK 21)

// BAD on JDK 21-23: Pins the carrier during the network call
public synchronized String load(String url) throws Exception {
    return httpClient.send(request(url), BodyHandlers.ofString()).body();
}
// GOOD: Lock only guards the state, not the blocking call
private final ReentrantLock lock = new ReentrantLock();

public String load(String url) throws Exception {
    String body = httpClient.send(request(url), BodyHandlers.ofString()).body();
    lock.lock();
    try {
        cache.put(url, body); // Short critical section
    } finally {
        lock.unlock();
    }
    return body;
}

Anti-Pattern 3: Reactive Wrapper Around Blocking Code

// BAD: Async ceremony hides simple blocking logic
CompletableFuture<User> f = CompletableFuture
        .supplyAsync(() -> userRepo.find(id), ioPool)
        .thenApplyAsync(u -> enrich(u), ioPool);
// GOOD: Straight-line code on a virtual thread
User user = userRepo.find(id);
User enriched = enrich(user);

Framework Support

Framework How to Enable
Spring Boot 3.2+ spring.threads.virtual.enabled=true
Helidon 4 Built on virtual threads by default
Quarkus @RunOnVirtualThread annotation
Tomcat / Jetty Configure a virtual-thread executor

Check your framework’s release notes for exact property names, since support has expanded across versions.

Migration Checklist

  1. Upgrade to JDK 21 or newer. JDK 24+ reduces synchronized pinning.
  2. Replace fixed thread pools for I/O tasks with newVirtualThreadPerTaskExecutor().
  3. Audit synchronized blocks that wrap blocking calls and swap in ReentrantLock on JDK 21-23.
  4. Add semaphores or connection limits for databases and downstream APIs.
  5. Review ThreadLocal usage for large objects.
  6. Load test and inspect pinning with JFR before production rollout.
  7. Update monitoring: thread dumps for virtual threads need jcmd <pid> Thread.dump_to_file -format=json <file>, since regular jstack does not list them by default.

Measuring the Difference

A simple benchmark compares blocking tasks on a fixed pool against virtual threads.

import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;

public class Benchmark {
    static void run(String label, java.util.concurrent.ExecutorService executor) {
        long start = System.nanoTime();
        try (executor) {
            IntStream.range(0, 10_000).forEach(i ->
                executor.submit(() -> {
                    Thread.sleep(Duration.ofMillis(500)); // Simulated I/O
                    return i;
                }));
        }
        System.out.printf("%s: %d ms%n", label, (System.nanoTime() - start) / 1_000_000);
    }

    public static void main(String[] args) {
        run("Fixed pool (200)", Executors.newFixedThreadPool(200));
        run("Virtual threads", Executors.newVirtualThreadPerTaskExecutor());
    }
}

Expect the fixed pool to take about 25 seconds (10,000 tasks ÷ 200 threads × 0.5 s) and the virtual-thread run to finish in about 0.5 to 1 second. Exact numbers vary by hardware, but the ratio is the point.

When Not to Use Virtual Threads

  • CPU-bound computation: more threads than cores add nothing.
  • Code relying on thread identity or heavy ThreadLocal caches: per-thread caches lose their value when threads are short-lived.
  • Native-heavy code (JNI) that blocks: native frames pin the carrier.
  • Streaming pipelines needing backpressure: reactive tools fit better.

FAQ

What is the difference between virtual threads and platform threads in Java?

A platform thread wraps an OS thread and is scheduled by the kernel. A virtual thread is scheduled by the JVM and mounted on a small pool of carrier platform threads. Virtual threads are far cheaper to create and block, so you can run millions of them concurrently.

Are Java virtual threads faster than platform threads?

Not for a single task. Virtual threads improve throughput for I/O-bound workloads by letting many blocked tasks wait without consuming OS threads. For CPU-bound work, they offer no speedup and can add small overhead.

Which Java version supports virtual threads?

Virtual threads were a preview feature in Java 19 and 20 and became a final, standard feature in Java 21 (JEP 444). Later releases, including Java 24, improve them by largely eliminating synchronized-related pinning.

Should I replace reactive programming with virtual threads?

For most request/response services, yes: virtual threads give similar scalability with simpler code and better stack traces. Keep reactive streams where you need backpressure or complex stream operators.

Hot this week

Robotics Predictions for 2027

Seven data-driven robotics predictions for 2027: VLA policies, humanoid actuators, ROS 2 real-time, safety filters, and code. Read the engineering roadmap.

Robots and Jobs: What the Evidence Says in 2026

Robots and jobs in 2026: IFR installation data, wage and employment estimates, and a payback model engineers can run. Read the evidence.

Robotics Careers in 2026: Roles, Skills, and Salaries

Explore robotics careers in 2026: top roles, must-have ROS 2 and controls skills, and U.S. salary ranges. Build your robotics career roadmap today.

Robot Sensors Explained: How Robots Perceive the World

Learn how LiDAR, IMU, encoders, and depth cameras work, with fusion math and ROS2 code. Build a reliable perception stack today.

20 Beginner Robot Projects Ranked by Difficulty

Build 20 beginner robot projects ranked from easiest to hardest, with wiring, code, and ROS2 steps. Pick your first build and start today.

Topics

Robotics Predictions for 2027

Seven data-driven robotics predictions for 2027: VLA policies, humanoid actuators, ROS 2 real-time, safety filters, and code. Read the engineering roadmap.

Robots and Jobs: What the Evidence Says in 2026

Robots and jobs in 2026: IFR installation data, wage and employment estimates, and a payback model engineers can run. Read the evidence.

Robotics Careers in 2026: Roles, Skills, and Salaries

Explore robotics careers in 2026: top roles, must-have ROS 2 and controls skills, and U.S. salary ranges. Build your robotics career roadmap today.

Robot Sensors Explained: How Robots Perceive the World

Learn how LiDAR, IMU, encoders, and depth cameras work, with fusion math and ROS2 code. Build a reliable perception stack today.

20 Beginner Robot Projects Ranked by Difficulty

Build 20 beginner robot projects ranked from easiest to hardest, with wiring, code, and ROS2 steps. Pick your first build and start today.

Robot Computers in 2026: Raspberry Pi 5, Jetson, and Industrial PCs Compared

Raspberry Pi 5 vs NVIDIA Jetson vs industrial PCs for ROS 2 robots. See specs, latency math and a decision matrix, then pick your compute.

AI in Robotics: Programmed Behavior vs Machine Learning (and When You Need Each)

Programmed control or machine learning? Compare PID, FSMs, and RL with ROS2 code, benchmarks, and a decision framework. Pick the right one.

What Is ROS 2? A Beginner’s Guide to the Robot Operating System in 2026

Learn what ROS 2 is, how nodes, topics, and DDS work, and build your first publisher and subscriber. Start your ROS 2 project today.

Related Articles

Popular Categories