Skip to content
Codeloom
Java

A Practical Guide to Java Virtual Threads

Master Java virtual threads with hands-on examples. Learn thread creation, executor patterns, structured concurrency, pinning pitfalls, and migration strategies.

·6 min read · By Codeloom
Intermediate 12 min read

What you'll learn

  • How virtual threads differ from platform threads
  • Practical patterns for adopting virtual threads in existing codebases
  • How to avoid thread pinning and synchronized block issues

Prerequisites

  • Basic Java concurrency (Thread, Runnable)
  • Familiarity with ExecutorService
  • Java 21+

Java 21 finalized virtual threads as part of Project Loom, fundamentally changing how Java applications handle concurrency. Instead of mapping each thread to an expensive OS thread, virtual threads are managed by the JVM on a small pool of carrier threads. You can spawn millions of them without running out of memory.

This guide focuses on practical adoption: how to integrate virtual threads into real applications, common mistakes developers make during migration, and performance patterns that actually matter in production.

Why virtual threads change the concurrency model

Traditional Java servers allocate one platform thread per request. Each platform thread consumes roughly 1MB of stack memory and maps directly to an OS thread. A server with 200 threads can handle 200 concurrent requests, even if 195 of those threads are idle waiting on database queries.

Virtual threads break this limitation. They cost roughly 1KB when idle and are scheduled onto a small pool of platform threads by the JVM. When a virtual thread blocks on I/O, the JVM unmounts it from the carrier thread and schedules another virtual thread in its place.

// platform thread: expensive, limited
Thread platform = Thread.ofPlatform().start(() -> {
    // consumes ~1MB of OS stack
    fetchFromDatabase();
});

// virtual thread: cheap, abundant
Thread virtual = Thread.ofVirtual().start(() -> {
    // consumes ~1KB, scheduled on carrier pool
    fetchFromDatabase();
});

The key insight is that virtual threads make the thread-per-request model scalable again. You do not need reactive frameworks or callback chains to achieve high throughput.

Creating and managing virtual threads

The virtual thread executor

The most practical way to use virtual threads is through Executors.newVirtualThreadPerTaskExecutor(). Each submitted task gets its own virtual thread.

import java.util.concurrent.*;
import java.util.List;
import java.util.ArrayList;

public class VirtualThreadExecutorDemo {
    public static void main(String[] args) throws Exception {
        List<Future<String>> futures = new ArrayList<>();

        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 10_000; i++) {
                final int taskId = i;
                futures.add(executor.submit(() -> {
                    // simulate blocking I/O
                    Thread.sleep(1000);
                    return "Result from task " + taskId;
                }));
            }

            // all 10,000 tasks run concurrently
            for (Future<String> future : futures) {
                future.get(); // collect results
            }
        }
        // executor auto-closes when try-with-resources exits
    }
}

This launches 10,000 concurrent tasks. With platform threads, you would need 10,000 OS threads (roughly 10GB of memory). With virtual threads, this uses a fraction of that.

Thread factory for naming and configuration

ThreadFactory factory = Thread.ofVirtual()
    .name("worker-", 0) // worker-0, worker-1, worker-2, ...
    .factory();

ExecutorService executor = Executors.newThreadPerTaskExecutor(factory);
executor.submit(() -> {
    System.out.println(Thread.currentThread().getName()); // worker-0
});

Structured concurrency with ScopedValue

Java 21 introduced ScopedValue as a replacement for ThreadLocal in virtual thread contexts. Scoped values are immutable within a scope and automatically cleaned up.

import java.lang.ScopedValue;

public class ScopedValueDemo {
    private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

    public static void handleRequest(String requestId) {
        ScopedValue.runWhere(REQUEST_ID, requestId, () -> {
            System.out.println("Processing: " + REQUEST_ID.get());
            callServiceA();
            callServiceB();
        });
    }

    private static void callServiceA() {
        // REQUEST_ID is available here without passing it as a parameter
        System.out.println("Service A, request: " + REQUEST_ID.get());
    }

    private static void callServiceB() {
        System.out.println("Service B, request: " + REQUEST_ID.get());
    }
}

Avoiding thread pinning

Thread pinning is the most common performance pitfall with virtual threads. When a virtual thread enters a synchronized block or method while holding a monitor, the JVM cannot unmount it from the carrier thread. The carrier thread is pinned and unavailable for other virtual threads.

// BAD: synchronized pins the carrier thread
public class PinnedExample {
    private final Object lock = new Object();

    public void processRequest() {
        synchronized (lock) {
            // if this blocks on I/O, the carrier thread is pinned
            fetchFromDatabase(); // entire carrier thread is wasted
        }
    }
}

// GOOD: use ReentrantLock instead
import java.util.concurrent.locks.ReentrantLock;

public class UnpinnedExample {
    private final ReentrantLock lock = new ReentrantLock();

    public void processRequest() {
        lock.lock();
        try {
            // virtual thread can unmount during I/O
            fetchFromDatabase();
        } finally {
            lock.unlock();
        }
    }
}

To detect pinning at runtime, start the JVM with -Djdk.tracePinnedThreads=full. This prints a stack trace whenever a virtual thread is pinned.

Migrating existing code to virtual threads

Step 1: replace thread pools

The simplest migration is replacing fixed thread pools with virtual thread executors.

// before
ExecutorService executor = Executors.newFixedThreadPool(200);

// after
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

Step 2: audit synchronized blocks

Search your codebase for synchronized blocks that contain blocking I/O. Replace them with ReentrantLock.

# find synchronized blocks in your project
grep -rn "synchronized" src/main/java/

Step 3: replace ThreadLocal with ScopedValue

ThreadLocal works with virtual threads but can cause memory issues because virtual threads are cheap to create and you may spawn millions. Each ThreadLocal instance holds a reference per thread.

// before: ThreadLocal
private static final ThreadLocal<UserContext> CONTEXT = new ThreadLocal<>();

// after: ScopedValue (preview in Java 21)
private static final ScopedValue<UserContext> CONTEXT = ScopedValue.newInstance();

Step 4: update Spring Boot configuration

If you use Spring Boot 3.2+, enabling virtual threads is a single property:

# application.properties
spring.threads.virtual.enabled=true

This configures Tomcat to use virtual threads for request handling. Every incoming HTTP request runs on its own virtual thread.

When not to use virtual threads

Virtual threads are optimized for I/O-bound workloads. They do not help with CPU-bound tasks because the carrier thread pool is bounded to the number of available processors.

// virtual threads do NOT help here
// CPU-bound work keeps the carrier thread busy
executor.submit(() -> {
    // heavy computation: no I/O blocking, no opportunity to unmount
    BigInteger result = factorial(100_000);
});

// virtual threads excel here
// I/O-bound work lets the carrier serve other virtual threads
executor.submit(() -> {
    String data = httpClient.send(request, bodyHandler).body();
    String enriched = anotherService.call(data);
    database.save(enriched);
});

Monitoring virtual threads in production

Virtual threads do not appear in thread dumps the same way platform threads do. Use jcmd to capture virtual thread information:

# thread dump including virtual threads
jcmd <pid> Thread.dump_to_file -format=json output.json

# in Java code, check if current thread is virtual
boolean isVirtual = Thread.currentThread().isVirtual();

For metrics, track the number of active carrier threads and the throughput of your virtual thread executor. If carrier threads are consistently maxed out, you likely have a pinning issue.

Summary

Virtual threads make the thread-per-request model viable at scale. The migration path is straightforward: swap your thread pools, replace synchronized with ReentrantLock, and consider ScopedValue over ThreadLocal. Focus on I/O-bound workloads, watch for pinning, and monitor carrier thread utilization in production. For Spring Boot applications, a single configuration property is all you need.