Encodera

Thanks for reading!

Donate to the site — we work hard for you.

Contents

Virtual threads

An ordinary Java thread is an operating system thread. It's heavy: it takes about a megabyte of memory, and a machine can't cope with tens of thousands of them. Virtual threads (Java 21) are lightweight threads managed by the JVM itself. You can create millions of them, and the code looks the same as with ordinary threads.

The first virtual thread

A virtual thread is created with the Thread.ofVirtual() builder. Everything else is familiar: start, join:

void main() throws InterruptedException {
    Thread thread = Thread.ofVirtual().start(() -> {
        IO.println("Virtual: " + Thread.currentThread().isVirtual());
    });
 
    thread.join();
    IO.println("Main thread is virtual: " + Thread.currentThread().isVirtual());
}

Output:

Virtual: true
Main thread is virtual: false

Even shorter is Thread.startVirtualThread(task): it creates a virtual thread and starts it right away.

How it works

Ordinary threads are called platform threads: each is tied to an operating system thread. Virtual threads run on top of a small set of platform carrier threads. When a virtual thread waits — sleeps, reads a file, waits for a network reply — the JVM takes it off its carrier, and the carrier runs another virtual thread. Waiting no longer occupies an expensive OS thread.

A thread per task

Virtual threads need no pool: they're cheap, so each task gets a new thread. Executors.newVirtualThreadPerTaskExecutor() does exactly that. Let's start 10,000 tasks, each "waiting for a reply" for a second:

void main() {
    Instant start = Instant.now();
 
    try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
        for (int i = 0; i < 10_000; i++) {
            executor.submit(() -> {
                Thread.sleep(Duration.ofSeconds(1));
                return null;
            });
        }
    }
 
    Duration elapsed = Duration.between(start, Instant.now());
    IO.println("Took about " + elapsed.toSeconds() + " s");
}

Output (the time depends on the computer):

Took about 1 s

All 10,000 waits happened at the same time, and the program finished in about a second. With a pool of 100 platform threads the same work would take 100 seconds. The lambda returns null so that it counts as a Callable: then Thread.sleep can be called inside without try-catch.

Task results

Virtual threads work with the same Future and invokeAll as ordinary pools. The order of the results matches the order of the tasks:

String fetchPrice(String product) throws InterruptedException {
    Thread.sleep(Duration.ofMillis(300));
    return product + ": $" + product.length() * 3;
}
 
void main() throws Exception {
    List<String> products = List.of("tea", "coffee", "cocoa");
 
    try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
        List<Future<String>> futures = new ArrayList<>();
        for (String p : products) {
            futures.add(executor.submit(() -> fetchPrice(p)));
        }
        for (Future<String> f : futures) {
            IO.println(f.get());
        }
    }
}

Output:

tea: $9
coffee: $18
cocoa: $15

When they help

Virtual threads speed up programs that wait a lot: web servers, API clients, database work. For tasks that load the processor — video compression, heavy computation — there's no gain: you don't get more CPU cores. Such tasks still use a pool sized to the number of cores. And to pass shared data such as the current user to a task, virtual threads use scoped values instead of ThreadLocal.

Common mistake

Don't put virtual threads into a fixed-size pool to limit the load — that defeats their purpose. If, say, no more than three tasks may access a database at once, use a Semaphore — a counter of permits:

void main() {
    Semaphore permits = new Semaphore(3);
    AtomicInteger active = new AtomicInteger();
    AtomicInteger maxActive = new AtomicInteger();
 
    try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
        for (int i = 0; i < 20; i++) {
            executor.submit(() -> {
                permits.acquire();
                try {
                    maxActive.accumulateAndGet(active.incrementAndGet(), Math::max);
                    Thread.sleep(50);
                    active.decrementAndGet();
                } finally {
                    permits.release();
                }
                return null;
            });
        }
    }
 
    IO.println("No more than 3 ran at once: " + (maxActive.get() <= 3));
}

Output:

No more than 3 ran at once: true

acquire() takes a permit or waits until one is free, and release() gives it back. Twenty tasks were created, yet no more than three reach the "database" at once. AtomicInteger is a thread-safe counter, covered in the synchronization lesson.

Thanks for reading!

Donate to the site — we work hard for you.