Virtual Threads

Lightweight threads from Project Loom. Java 21+.

At a Glance

Creating Virtual Threads

// Start a virtual thread directly
Thread vt = Thread.startVirtualThread(() -> doWork());

// Builder API
Thread vt = Thread.ofVirtual()
    .name("my-vthread")
    .start(() -> doWork());

// Factory — useful for executors
ThreadFactory factory = Thread.ofVirtual()
    .name("worker-", 0)   // worker-0, worker-1, ...
    .factory();

Thread t = factory.newThread(() -> doWork());
t.start();

Virtual Thread Executor

The most common way to use virtual threads — one virtual thread per task.

// New virtual thread for every submitted task
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    // Submit many concurrent tasks — each gets its own virtual thread
    List<Future<String>> futures = new ArrayList<>();
    for (int i = 0; i < 10_000; i++) {
        futures.add(executor.submit(() -> fetchData()));
    }

    for (Future<String> f : futures) {
        System.out.println(f.get());
    }
}  // executor shuts down here, waits for all tasks

Virtual vs Platform Threads

Platform ThreadsVirtual Threads
Backed by OS thread (1:1) JVM-scheduled onto carrier threads (M:N)
Memory ~1 MB stack per thread ~few KB (grows as needed)
Creation cost Expensive (OS syscall) Cheap (JVM object)
Max count Thousands Millions
Blocking I/O Blocks OS thread Unmounts from carrier, carrier freed for other work
CPU-bound Good — one OS thread per core No benefit — still limited by carrier threads
Pooling Required (expensive to create) Do not pool — create a new one per task
synchronized Fine Causes pinning — prefer ReentrantLock
ThreadLocal Common pattern Works but expensive at scale — prefer scoped values

Pinning

When a virtual thread cannot unmount from its carrier.

// BAD — synchronized pins the virtual thread to its carrier
synchronized (lock) {
    connection.query(sql);  // blocking I/O while pinned
}

// GOOD — ReentrantLock does not pin
private final ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    connection.query(sql);  // virtual thread can unmount during I/O
} finally {
    lock.unlock();
}

// Detect pinning at runtime
// JVM flag: -Djdk.tracePinnedThreads=short
// or:       -Djdk.tracePinnedThreads=full

Scoped Values (Preview)

Lightweight alternative to ThreadLocal for virtual threads.

// Declare a scoped value
private static final ScopedValue<String> USER = ScopedValue.newInstance();

// Bind a value for the duration of a Runnable
ScopedValue.runWhere(USER, "alice", () -> {
    handleRequest();  // USER.get() returns "alice" here
});

// Read it deeper in the call stack
void handleRequest() {
    String user = USER.get();  // "alice"
    // ...
}

// Rebinding (nested scope)
ScopedValue.runWhere(USER, "bob", () -> {
    // USER.get() is "bob" in this scope
});
// USER.get() is "alice" again here
ThreadLocalScopedValue
MutabilityMutable (get/set anytime)Immutable once bound
LifecycleUntil removed or thread diesScoped to a runWhere block
InheritanceInheritableThreadLocalInherited by child threads in StructuredTaskScope
MemoryPer-thread map (can leak)Stack-like (auto cleanup)
Virtual threadsWorks but expensive at scaleDesigned for millions of threads

Structured Concurrency (Preview)

Treat concurrent subtasks as a unit — if one fails, cancel the rest.

// ShutdownOnFailure — cancel all if any subtask fails
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<String> user  = scope.fork(() -> fetchUser(id));
    Subtask<String> order = scope.fork(() -> fetchOrder(id));

    scope.join();            // wait for all
    scope.throwIfFailed();   // propagate first failure

    // Both succeeded
    return new Response(user.get(), order.get());
}

// ShutdownOnSuccess — return first successful result, cancel the rest
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
    scope.fork(() -> fetchFromPrimary());
    scope.fork(() -> fetchFromReplica());

    scope.join();
    return scope.result();  // first successful result
}

Common Patterns

HTTP Server (thread-per-request)

// Each request gets its own virtual thread — no pooling needed
var server = HttpServer.create(new InetSocketAddress(8080), 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.createContext("/", exchange -> {
    // blocking I/O is fine — virtual thread unmounts during wait
    String data = fetchFromDb();
    byte[] response = data.getBytes();
    exchange.sendResponseHeaders(200, response.length);
    exchange.getResponseBody().write(response);
    exchange.close();
});
server.start();

Fan-out I/O

// Fetch many URLs concurrently
List<String> urls = List.of("url1", "url2", "url3", /* ... thousands */);

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<String>> futures = urls.stream()
        .map(url -> executor.submit(() -> httpGet(url)))
        .toList();

    List<String> results = new ArrayList<>();
    for (Future<String> f : futures) {
        results.add(f.get());
    }
}