Virtual Threads & Concurrency

A classic platform thread maps to an OS thread: ~1 MB of stack, expensive to create, so they were pooled and rationed.

var t = new Thread(() -> IO.println("in parallel"));
t.start();
t.join();

Virtual threads

Managed by the JVM, not the OS. They cost almost nothing, and when one blocks it releases its carrier thread.

Thread.startVirtualThread(() -> {
    Thread.sleep(Duration.ofSeconds(1));
    IO.println("done");
});

The programming model stays the simplest one there is: write blocking code.

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (var i = 0; i < 10_000; i++) {
        var n = i;
        executor.submit(() -> { Thread.sleep(Duration.ofMillis(100)); return n; });
    }
}   // close() waits for all tasks
Note

Virtual threads help waiting work (HTTP, database, files). For computing work use a pool sized to your CPU cores.

Executors and futures

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {

    Future<Integer> future = executor.submit(() -> expensive());
    var result = future.get();          // blocks until ready
}

var tasks = List.<Callable<String>>of(
    () -> fetch("https://example.test/a"),
    () -> fetch("https://example.test/b"));

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (var f : executor.invokeAll(tasks)) IO.println(f.get());
}

Structured concurrency (preview in 25)

Subtasks that belong together should live and die together: if one fails, the others are cancelled.

// run with: java --enable-preview --source 25 File.java
import java.util.concurrent.StructuredTaskScope;

record Profile(String details, String orders) {}

Profile loadProfile(String id) throws Exception {
    try (var scope = StructuredTaskScope.open()) {

        var details = scope.fork(() -> loadDetails(id));
        var orders  = scope.fork(() -> loadOrders(id));

        scope.join();      // waits for both, cancels on failure

        return new Profile(details.get(), orders.get());
    }
}

No orphaned threads, no forgotten cleanup, readable stack traces.

Scoped values (Java 25)

ScopedValue replaces ThreadLocal for passing context (user, request id, tenant) down the call stack. Immutable and cleaned up automatically.

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

void handleRequest(String user) {
    ScopedValue.where(USER, user).run(() -> {
        checkPermissions();
        writeAuditLog();      // sees USER without a parameter
    });
}

void writeAuditLog() {
    IO.println("action by " + USER.get());
}

Shared state is the hard part

var counter = new int[1];

try (var e = Executors.newVirtualThreadPerTaskExecutor()) {
    for (var i = 0; i < 1000; i++) e.submit(() -> counter[0]++);   // broken: not atomic
}
IO.println(counter[0]);   // almost never 1000

Fixes:

var counter = new AtomicInteger();          // 1. atomic types
counter.incrementAndGet();

var map = new ConcurrentHashMap<String, Integer>();   // 2. concurrent collections
map.merge("a", 1, Integer::sum);

synchronized (lock) { balance++; }           // 3. locking

var total = list.parallelStream().mapToInt(this::score).sum();   // 4. share nothing
Tip

Immutable objects (records!) are thread-safe by construction. Less shared mutable state means less synchronisation — and fewer bugs that only show up in production.

Example: many HTTP calls at once

void main() throws Exception {
    var urls = List.of("https://example.com", "https://example.org", "https://example.net");
    var start = System.nanoTime();

    try (var client = HttpClient.newHttpClient();
         var executor = Executors.newVirtualThreadPerTaskExecutor()) {

        var results = executor.invokeAll(urls.stream()
            .map(url -> (Callable<String>) () -> {
                var response = client.send(
                    HttpRequest.newBuilder(URI.create(url)).build(),
                    HttpResponse.BodyHandlers.ofString());
                return "%s → %d".formatted(url, response.statusCode());
            })
            .toList());

        for (var r : results) IO.println(r.get());
    }

    IO.println("took %d ms".formatted((System.nanoTime() - start) / 1_000_000));
}

Also worth knowing

Tool Purpose
CompletableFuture chaining async steps
CountDownLatch wait for n tasks
Semaphore limit concurrent access
BlockingQueue producer/consumer
ReentrantLock more flexible than synchronized

★ Exercises

  1. Start 100,000 virtual threads that sleep 100 ms. How long does it take? Try the same with platform threads.
  2. Reproduce the broken counter and fix it three different ways.
  3. Fetch ten pages in parallel, printing status and body size.
  4. Limit concurrent requests to three with a Semaphore.
  5. Use ScopedValue to add a request id to every log line without passing it around.
  6. Try StructuredTaskScope with --enable-preview: two tasks, one throws. What happens to the other?