DEV Community

Vishnu B
Vishnu B

Posted on Fully Autonomous

Java Virtual Threads: A Quick Walkthrough

Java 21 shipped one of the biggest changes to Java concurrency in years: virtual threads (JEP 444). They let you write plain, blocking-style code that scales to thousands of concurrent tasks, without reactive programming or careful thread-pool tuning.

In this walkthrough we will look at what virtual threads are, run three small programs you can copy and run yourself, and finish with how to switch them on in a Spring Boot app. Every example was compiled and run on JDK 21 (Temurin 21.0.13), and the outputs shown are real runs.

Core concepts

Platform threads vs virtual threads. A platform thread is a thin wrapper around an operating system thread, and it keeps that OS thread for its whole lifetime. OS threads are expensive, so an application can only have a limited number of them. A virtual thread is also a java.lang.Thread, but it is not tied to one OS thread. The JVM maps a large number of virtual threads onto a small number of platform threads, called carrier threads, in the same spirit as an OS mapping a large virtual address space onto limited RAM.

Mount, unmount, repeat. When code running in a virtual thread makes a blocking I/O call (a database query, an HTTP request, Thread.sleep), the Java runtime suspends the virtual thread and unmounts it from its carrier. The carrier is immediately free to run another virtual thread. When the I/O finishes, the virtual thread mounts again on whichever carrier is free and resumes. This animation shows the cycle:

How the JVM schedules virtual threads

Scale, not speed. Virtual threads are not faster threads; they do not run your code any faster than platform threads. They exist to provide scale (higher throughput), not speed (lower latency). They shine when tasks spend most of their time waiting on I/O, which is exactly what server applications do.

One caveat: pinning. A virtual thread cannot unmount while it is running inside a synchronized block or method, or inside a native call. That is called pinning. It does not break correctness, but it can hurt scalability if it happens often or for long stretches. The usual fix is to guard long I/O operations with java.util.concurrent.locks.ReentrantLock instead of synchronized. From Java 24, JEP 491 removes nearly all pinning caused by synchronized.

Walkthrough 1: Your first virtual thread

public class HelloVirtualThread {
    public static void main(String[] args) throws InterruptedException {
        // 1. Quickest way: create and start in one call
        Thread thread = Thread.startVirtualThread(() -> System.out.println("Hello from a virtual thread"));
        thread.join();

        // 2. Builder: gives you names and other thread properties
        Thread.Builder builder = Thread.ofVirtual().name("worker-", 0);
        Runnable task = () -> System.out.println("Running in " + Thread.currentThread());

        Thread t1 = builder.start(task);
        t1.join();
        Thread t2 = builder.start(task);
        t2.join();
    }
}
Enter fullscreen mode Exit fullscreen mode

Save it as HelloVirtualThread.java, then:

javac HelloVirtualThread.java
java HelloVirtualThread
Enter fullscreen mode Exit fullscreen mode

Output from my run:

Hello from a virtual thread
Running in VirtualThread[#16,worker-0]/runnable@ForkJoinPool-1-worker-2
Running in VirtualThread[#17,worker-1]/runnable@ForkJoinPool-1-worker-1
Enter fullscreen mode Exit fullscreen mode

Look at the thread names: VirtualThread[#16,worker-0] is the virtual thread, and the part after @ (ForkJoinPool-1-worker-2) is the carrier platform thread it happened to be mounted on. Same API as any thread, but the JVM is doing the carrier juggling for you.

Walkthrough 2: An executor that starts a virtual thread per task

For real code, use Executors.newVirtualThreadPerTaskExecutor(). Every submitted task gets its own fresh virtual thread:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class ExecutorDemo {
    public static void main(String[] args) throws Exception {
        // A new virtual thread is started for every submitted task
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            Future<String> future = executor.submit(() -> {
                Thread.sleep(100); // simulates a blocking I/O call
                return "Fetched order #42 on " + Thread.currentThread();
            });
            System.out.println(future.get());
            System.out.println("Task completed");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

Fetched order #42 on VirtualThread[#13]/runnable@ForkJoinPool-1-worker-1
Task completed
Enter fullscreen mode Exit fullscreen mode

Two things worth noticing. The ExecutorService is AutoCloseable, so try-with-resources shuts it down and waits for running tasks. And there is no pool size to tune: one task, one virtual thread.

Walkthrough 3: The scale test

Here is where it clicks. Take 2,000 tasks that each simulate 100 ms of blocking I/O with Thread.sleep, and run them two ways: a classic fixed pool of 8 platform threads, and a virtual thread per task.

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

public class ScaleDemo {
    static void run(String label, ExecutorService pool, int tasks) {
        Instant start = Instant.now();
        try (pool) { // close() shuts down and waits for all tasks
            IntStream.range(0, tasks).forEach(i -> pool.submit(() -> {
                Thread.sleep(100); // simulates 100 ms of blocking I/O
                return i;
            }));
        }
        System.out.printf("%s: %,d tasks finished in %,d ms%n",
                label, tasks, Duration.between(start, Instant.now()).toMillis());
    }

    public static void main(String[] args) {
        int tasks = 2_000;
        run("Fixed pool of 8 platform threads", Executors.newFixedThreadPool(8), tasks);
        run("Virtual thread per task          ", Executors.newVirtualThreadPerTaskExecutor(), tasks);
    }
}
Enter fullscreen mode Exit fullscreen mode

Real output from my run:

Fixed pool of 8 platform threads: 2,000 tasks finished in 25,062 ms
Virtual thread per task          : 2,000 tasks finished in 141 ms
Enter fullscreen mode Exit fullscreen mode

The platform pool can only work on 8 tasks at a time, so 2,000 tasks x 100 ms takes about 25 seconds. With virtual threads, all 2,000 tasks start at once; when they block on the sleep they unmount, and the whole batch finishes in about one sleep's worth of time. This animation uses the real measured times:

Fixed pool vs virtual threads: measured wall-clock time on simulated blocking I/O

Your numbers will vary by machine, but the shape of the result will not. Note that the tasks got throughput, not speed: each task still takes 100 ms of waiting.

Using virtual threads in Spring Boot

If your app is on Spring Boot and Java 21 or later, switching to virtual threads is one property:

spring.threads.virtual.enabled=true
Enter fullscreen mode Exit fullscreen mode

With this on, the embedded web server handles each request on a virtual thread, so your controllers can stay simple blocking code. Two things from the Spring Boot reference to keep in mind:

  • Spring recommends Java 24 or later for the best experience, partly because of the synchronized pinning improvements mentioned above.
  • Virtual threads are daemon threads, so they will not keep the JVM alive. If you rely on something like a @Scheduled bean to keep the app running, also set spring.main.keep-alive=true.
  • Once virtual threads are enabled, properties that configure thread pools no longer have an effect, because virtual threads are scheduled on a JVM-wide carrier pool.

When to use (and when not)

Good fit: I/O-bound work with lots of concurrent tasks: web requests, database calls, calling other services. This is most server-side Java.

Not a fit: CPU-bound work. Virtual threads do not create more CPU capacity; a task that burns CPU still occupies a carrier the whole time. For those workloads, sizing a platform pool to your cores is still the right tool.

Wrap-up

Virtual threads let you keep the simple, one-thread-per-request programming model and throw away the thread-pool sizing gymnastics for I/O-bound services. The API is just Thread and ExecutorService, the mental model is "one virtual thread per task", and on JDK 21+ you can try it today with the three programs above.

If you try the scale demo yourself, I would be curious what numbers you get on your machine.


Sources

Written with AI assistance; code examples were compiled and run on JDK 21 before publishing.

Top comments (0)