Skip to content
Happy Programming Guide
Start learning
Java

Java Virtual Threads: How to Use Them (Java 21+)

Virtual threads let you write plain blocking Java and still handle thousands of concurrent tasks. How they work, how to use them, and the pinning trap.

Servers mounted in a data centre rack

Virtual threads, final since Java 21, are threads that are cheap enough to create by the million. You write ordinary blocking code — call a database, wait for HTTP, sleep — and the runtime parks the virtual thread instead of tying up an operating-system thread. The result is the scalability of async code with none of its structure.

Platform threads vs virtual threads#

A traditional Java thread — now called a platform thread — wraps an operating-system thread. Each one reserves a stack of around a megabyte and the OS schedules it. A few thousand is the practical limit before memory and context-switching costs bite.

A virtual thread is a Java object managed by the JVM. Its stack lives on the heap and grows as needed, usually a few kilobytes. When it blocks on I/O, the JVM unmounts it from the small pool of platform threads doing the real work and mounts something else. Millions can exist at once.

JAVA
// This runs 100,000 concurrent tasks without trouble
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 100_000; i++) {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return 1;
        });
    }
}   // waits for all tasks, then closes

Try that with a fixed pool of platform threads and it either queues for ages or runs out of memory.

Starting one#

JAVA
// 1. One-off
Thread t = Thread.startVirtualThread(() -> doWork());

// 2. With a name, unstarted
Thread t2 = Thread.ofVirtual().name("worker-1").unstarted(() -> doWork());
t2.start();

// 3. An executor - the one you will use most
try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<String> f = pool.submit(() -> fetch("https://example.com"));
    System.out.println(f.get());
}

The executor is the everyday form. It creates a new virtual thread per task — there is no pool to size, because pooling something that costs almost nothing to create is pointless.

What changes in your code#

Almost nothing, which is the point. Blocking calls stay blocking:

JAVA
String fetchUser(long id) throws IOException, InterruptedException {
    HttpResponse<String> response = client.send(
        HttpRequest.newBuilder(URI.create("https://api.example.com/users/" + id)).build(),
        HttpResponse.BodyHandlers.ofString());
    return response.body();                 // blocks - and that is fine
}

Run that on a virtual thread and the block costs a mounted platform thread for only as long as it takes to hand off the I/O. Synchronous code, asynchronous scalability, stack traces that still make sense.

Running many tasks and collecting results#

JAVA
List<Long> ids = List.of(1L, 2L, 3L, 4L, 5L);

try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<String>> futures = ids.stream()
        .map(id -> pool.submit(() -> fetchUser(id)))
        .toList();

    for (Future<String> f : futures) {
        System.out.println(f.get());
    }
}

Structured concurrency, still in preview in Java 25, tidies this pattern further and cancels the rest when one fails. Until it is final, the executor-and-futures approach above is the stable way.

Pinning#

There is one situation where a blocked virtual thread cannot be unmounted, and so occupies a platform thread for the whole wait. That is called pinning, and before Java 24 the main cause was blocking inside a synchronized block or method:

JAVA
synchronized void slow() {
    Thread.sleep(1000);       // pinned on Java 21-23: the carrier thread is stuck too
}

Java 24 fixed this for synchronized. On 21, the workaround is a ReentrantLock, which virtual threads handle correctly:

JAVA
private final ReentrantLock lock = new ReentrantLock();

void slow() {
    lock.lock();
    try { Thread.sleep(1000); } finally { lock.unlock(); }
}

Native code and some file-system operations can still pin. Run with -Djdk.tracePinnedThreads=full on 21 to see where it happens; on newer versions the JFR event jdk.VirtualThreadPinned records it.

Thread-local values#

ThreadLocal works on virtual threads, but a million threads each holding a copy of something is a memory problem. Scoped values, final in Java 25, are the replacement: an immutable value bound for the duration of a call and visible to everything it calls.

JAVA
static final ScopedValue<String> USER = ScopedValue.newInstance();

ScopedValue.where(USER, "alice").run(() -> handleRequest());

void handleRequest() {
    System.out.println(USER.get());     // "alice"
}

Where virtual threads do not help#

  • CPU-bound work. If a task computes rather than waits, it needs a core. A virtual thread does not add cores. Use a platform-thread pool sized to the CPU count.
  • Tasks that must not be unmounted, such as code relying on thread identity for native resources.
  • Very few concurrent tasks. Ten connections do not need this; it changes nothing at that scale.

Virtual threads are for the high-concurrency, mostly-waiting workload: web servers, API gateways, anything that spends its time on the network or the database.

Frameworks#

Spring Boot 3.2 and later enables virtual threads for request handling with one property:

Output
spring.threads.virtual.enabled=true

Helidon, Quarkus, Jetty and Tomcat all have equivalent switches. For most applications, that single line is the entire migration, followed by measuring.

Questions people ask#

Are virtual threads faster than platform threads?

Not per task. A single virtual thread runs at the same speed. The gain is throughput: far more tasks can be in flight at once for the same memory.

Do I need to rewrite code to use them?

Usually not. Blocking code works as-is. The changes are where threads are created and, on Java 21, replacing synchronized blocks that wrap I/O.

Which Java version do I need?

21 or later for the final feature. 24 or later avoids the synchronized pinning problem. 25 is the current LTS and the sensible target.

Do they replace reactive frameworks?

For many applications, yes: the reason to go reactive was scalability, and virtual threads provide it with simpler code. Reactive still suits streaming and back-pressure-heavy designs.

Where to go next#

What's new in Java 25 LTS: the features worth adoptingRead next

Keep reading

Java

Java Basics for Beginners

Java’s structure, types, control flow and the class-and-main-method boilerplate — explained so the ceremony makes sense rather than getting in the way.

3 min read

Keep going — pick your next guide

The fastest way to improve is to read one guide, then build the thing it describes. Start with the basics, or jump straight to a project.

Ask a question or share what worked

Your email address will not be published. Required fields are marked *