Skip to main content
Java advanced Lesson 36 of 58

Virtual Threads in Java

Master Project Loom's virtual threads (Java 21) — lightweight concurrency, structured concurrency, and migrating from platform threads.

The Problem with Platform Threads

Traditional Java servers create one platform thread per request. Platform threads map 1:1 to OS threads, which are expensive:

  • Each OS thread consumes ~1 MB of stack memory
  • Context switching between OS threads has overhead
  • A typical server tops out at ~10,000 concurrent threads
// Classic thread-per-request — does not scale beyond ~10k concurrent requests
ExecutorService pool = Executors.newFixedThreadPool(200);

pool.submit(() -> {
    // This thread BLOCKS while waiting for the DB response
    // The OS thread is parked, wasting memory, for the entire wait
    var result = database.query("SELECT ...");
    processResult(result);
});

Virtual threads solve this: when a virtual thread blocks on I/O, the JVM unmounts it from its carrier thread. The carrier thread is free to run other virtual threads. When the I/O completes, the virtual thread is remounted on any available carrier.

Creating Virtual Threads (Java 21)

// 1. Thread.ofVirtual() — same API as platform threads
Thread vt = Thread.ofVirtual()
    .name("my-virtual-thread")
    .start(() -> System.out.println("Running in: " + Thread.currentThread()));

vt.join();

// 2. Thread.startVirtualThread() — shortcut
Thread vt2 = Thread.startVirtualThread(() -> {
    System.out.println("Virtual: " + Thread.currentThread().isVirtual()); // true
});

// 3. Executor backed by virtual threads — the recommended way for servers
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 100_000; i++) {
        executor.submit(() -> {
            // Each task gets its own virtual thread — 100k threads, no problem
            Thread.sleep(Duration.ofMillis(100));
            return "done";
        });
    }
} // executor.close() waits for all tasks, then shuts down

Throughput Demo — Virtual vs Platform Threads

import java.time.*;
import java.util.concurrent.*;

public class ThroughputDemo {

    static void simulateRequest() throws InterruptedException {
        Thread.sleep(100); // simulate DB/HTTP wait
    }

    public static void main(String[] args) throws Exception {
        int tasks = 10_000;

        // Platform thread pool — limited parallelism
        long platformMs = time(() -> {
            try (var exec = Executors.newFixedThreadPool(200)) {
                var futures = new java.util.ArrayList<Future<?>>();
                for (int i = 0; i < tasks; i++) {
                    futures.add(exec.submit(() -> { simulateRequest(); return null; }));
                }
                for (var f : futures) f.get();
            }
        });

        // Virtual thread executor — one virtual thread per task
        long virtualMs = time(() -> {
            try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
                var futures = new java.util.ArrayList<Future<?>>();
                for (int i = 0; i < tasks; i++) {
                    futures.add(exec.submit(() -> { simulateRequest(); return null; }));
                }
                for (var f : futures) f.get();
            }
        });

        System.out.println("Platform threads (200 pool): " + platformMs + " ms");
        // ~5000 ms (10000 tasks / 200 threads * 100ms each)
        System.out.println("Virtual threads:             " + virtualMs + " ms");
        // ~110 ms (all 10000 run concurrently)
    }

    static long time(ThrowingRunnable r) throws Exception {
        long start = System.currentTimeMillis();
        r.run();
        return System.currentTimeMillis() - start;
    }

    @FunctionalInterface interface ThrowingRunnable { void run() throws Exception; }
}

Virtual Threads with HTTP Clients

import java.net.http.*;
import java.net.URI;
import java.util.*;
import java.util.concurrent.*;

public class ParallelHttpDemo {

    public static void main(String[] args) throws Exception {
        var client = HttpClient.newBuilder()
            .executor(Executors.newVirtualThreadPerTaskExecutor())
            .build();

        List<String> urls = List.of(
            "https://httpbin.org/delay/1",
            "https://httpbin.org/delay/1",
            "https://httpbin.org/delay/1"
        );

        // All three requests run concurrently on virtual threads
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            var futures = urls.stream()
                .map(url -> executor.submit(() ->
                    client.send(
                        HttpRequest.newBuilder(URI.create(url)).build(),
                        HttpResponse.BodyHandlers.ofString()
                    ).statusCode()
                ))
                .toList();

            for (var f : futures) {
                System.out.println("Status: " + f.get());
            }
        }
        // All three complete in ~1 second, not 3
    }
}

Avoiding Pinning

Virtual threads are “pinned” to their carrier thread when inside a synchronized block. Use ReentrantLock instead:

import java.util.concurrent.locks.*;

public class PinningDemo {

    // BAD — synchronized pins the virtual thread to the carrier
    private final Object lock = new Object();
    private int counter = 0;

    public void incrementBad() {
        synchronized (lock) {      // virtual thread pinned here
            counter++;
            doSlowIo();            // carrier thread blocked while waiting!
        }
    }

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

    public void incrementGood() throws InterruptedException {
        reentrantLock.lock();
        try {
            counter++;
            doSlowIo(); // virtual thread unmounts here, carrier is free
        } finally {
            reentrantLock.unlock();
        }
    }

    private void doSlowIo() {
        try { Thread.sleep(10); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }
}

Structured Concurrency (Java 21+)

StructuredTaskScope ensures child tasks cannot outlive their enclosing scope. If any child fails, the others are cancelled:

import java.util.concurrent.*;
import java.util.concurrent.StructuredTaskScope.*;

record UserProfile(long id, String name) {}
record UserOrders(long userId, List<String> orders) {}
record UserPage(UserProfile profile, UserOrders orders) {}

public class StructuredConcurrencyDemo {

    static UserProfile fetchProfile(long userId) throws InterruptedException {
        Thread.sleep(50); // simulate API call
        return new UserProfile(userId, "Alice");
    }

    static UserOrders fetchOrders(long userId) throws InterruptedException {
        Thread.sleep(80); // simulate DB query
        return new UserOrders(userId, List.of("ORD-1", "ORD-2"));
    }

    public static UserPage loadUserPage(long userId)
            throws InterruptedException, ExecutionException {

        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            // Fork both tasks — they run concurrently on virtual threads
            Subtask<UserProfile> profileTask = scope.fork(() -> fetchProfile(userId));
            Subtask<UserOrders>  ordersTask  = scope.fork(() -> fetchOrders(userId));

            scope.join();           // wait for both
            scope.throwIfFailed();  // propagate any exception

            // Both succeeded — combine results
            return new UserPage(profileTask.get(), ordersTask.get());
        }
        // scope close() cancels any still-running tasks
    }

    public static void main(String[] args) throws Exception {
        long start = System.currentTimeMillis();
        UserPage page = loadUserPage(42);
        long ms = System.currentTimeMillis() - start;

        System.out.println(page.profile().name());         // Alice
        System.out.println(page.orders().orders());        // [ORD-1, ORD-2]
        System.out.println("Time: " + ms + " ms");         // ~80 ms, not 130 ms
    }
}

ShutdownOnSuccess — First Result Wins

// Return the result of whichever source responds first
static String fastestSource(List<String> urls) throws Exception {
    try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
        for (String url : urls) {
            scope.fork(() -> fetchUrl(url));
        }
        scope.join();
        return scope.result(); // result of the first successful subtask
    }
}

Thread-Local Variables and Virtual Threads

Traditional ThreadLocal works with virtual threads but can lead to memory leaks if virtual threads are short-lived and ThreadLocals hold large objects. Prefer scoped values (Java 21):

import java.util.concurrent.*;

// ScopedValue — immutable, inheritable, no cleanup needed
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

static void handleRequest(String requestId) throws Exception {
    ScopedValue.where(REQUEST_ID, requestId).run(() -> {
        // REQUEST_ID.get() returns requestId within this scope
        processStep1();
        processStep2();
    });
    // REQUEST_ID is unbound after run() exits
}

static void processStep1() {
    System.out.println("Step 1, request: " + REQUEST_ID.get());
}

Spring Boot Integration

Spring Boot 3.2+ has first-class virtual thread support:

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

This single property switches the embedded Tomcat/Jetty to use a virtual thread per request — no code changes needed.

@RestController
public class UserController {

    @GetMapping("/users/{id}")
    public UserResponse getUser(@PathVariable long id) {
        // This runs on a virtual thread automatically
        // Blocking calls (JPA, RestTemplate, JDBC) are fine here
        return userService.findById(id);
    }
}

Frequently Asked Questions

What is the difference between a virtual thread and a platform thread?
A platform thread is a thin wrapper around an OS thread — expensive to create (1MB+ stack), limited to thousands. A virtual thread is managed by the JVM, mounted on carrier threads from a small pool — cheap to create (kilobytes of stack), you can have millions. The API is identical: both implement java.lang.Thread.
Do virtual threads make my code faster?
They improve throughput for I/O-bound workloads, not CPU-bound ones. If your threads spend most of their time waiting (database queries, HTTP calls, file reads), virtual threads let you handle far more concurrent requests with the same hardware. For CPU-bound work, use parallel streams or ForkJoinPool instead.
What is pinning in virtual threads?
A virtual thread is 'pinned' to its carrier thread when it holds a monitor lock (synchronized block/method) or calls native code. While pinned it cannot be unmounted, which can exhaust carrier threads. Migrate synchronized blocks to ReentrantLock to avoid pinning.
What is structured concurrency?
Structured concurrency (Java 21 preview, 23 finalized) ensures that subtasks spawned within a scope cannot outlive that scope. If any subtask fails, the others are cancelled. This prevents thread leaks and makes concurrent code easier to reason about — errors propagate cleanly.