Learn Java

Lesson 62Phase 7: Concurrency

Virtual threads

Lightweight threads from Java 21: running thousands of blocking tasks at once without a pool, and when they help.

By Updated 40 min

  • virtual thread
  • platform thread
  • Thread.ofVirtual
  • newVirtualThreadPerTaskExecutor
  • blocking I/O
  • structured concurrency

Every thread you have started so far is backed by a thread of the operating system, which gives it a block of memory and keeps track of it. That makes threads expensive, and a program can only have a few thousand of them. Java 21 added virtual threads: threads so cheap that a program can start one for every task, a hundred thousand at once if it needs to. This lesson shows what they cost, how to start them, and the kind of work where they help.

By the end of this lesson
  • Say what a platform thread costs, and why a program cannot have a hundred thousand of them.
  • Explain why tasks that wait on I/O waste platform threads, and work out how long a pool takes to get through a batch of waiting tasks.
  • Describe a virtual thread: a cheap thread that the JVM runs on a carrier thread only while it has work to do.
  • Start virtual threads with Thread.ofVirtual(), Thread.startVirtualThread and Executors.newVirtualThreadPerTaskExecutor(), and make sure the program waits for them.
  • Tell the work virtual threads help with (many tasks waiting) from the work they do not help with (computing), and know that structured concurrency is still a preview.
From before
This lesson builds on the whole concurrency phase so far. From the introduction to threads you know how to start a thread with a Runnable, and the Thread.ofPlatform() builder. From synchronization you know join(), Thread.sleep and InterruptedException. From thread pools, executors and futures you know how to hand tasks to an ExecutorService with submit, and that a Callable is a task that returns a value. The executors on this page are closed with try-with-resources, which you met in reading files.
What runs here
The visualizer runs only the main thread, so every program on this page that starts a thread is read-along, with output copied from real runs of Java on an 8-core Mac. Times, thread numbers and the names in the output change from run to run and from machine to machine, and the text says which. The arithmetic about pools runs here as usual.

What a platform thread costs#

Every thread you have made so far, with new Thread, with Thread.ofPlatform() or inside a pool, is a platform thread. A platform thread is a thin wrapper around one thread of the operating system, and it keeps that operating system thread for its whole life. That makes each one costly in a few ways:

  • Memory. Each platform thread gets its own call stack, the memory that holds the method calls it is in the middle of and their local variables. The JVM sets the whole block aside when the thread starts: 2 MB on a Mac with an Apple chip, like the one used for this page, and 1 MB on a typical Linux PC. The operating system hands over real memory only for the parts the thread touches, but the whole block stays reserved.
  • Time. Each one is a request to the operating system. On the Mac used for this page, starting 2,000 platform threads took about 75 milliseconds.
  • A limit. The operating system caps how many threads one program may have.

Here is a program that tries to start 10,000 platform threads that each sleep for five seconds. It prints a line after every 500.

TooMany.java
read along
1public class Main {
2 public static void main(String[] args) {
3 for (int i = 1; i <= 10_000; i++) {
4 Thread.ofPlatform().start(() -> {
5 try {
6 Thread.sleep(5000);
7 } catch (InterruptedException e) {
8 return;
9 }
10 });
11 if (i % 500 == 0) {
12 System.out.println("started " + i + " platform threads");
13 }
14 }
15 System.out.println("all 10000 started");
16 }
17}
Output
started 500 platform threads
started 1000 platform threads
started 1500 platform threads
started 2000 platform threads
[3.132s][warning][os,thread] Failed to start thread "Unknown thread" - pthread_create failed (EAGAIN) for attributes: stacksize: 2048k, guardsize: 16k, detached.
[3.132s][warning][os,thread] Failed to start the native thread for java.lang.Thread "Thread-2019"
Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached
	at java.base/java.lang.Thread.start0(Native Method)
	at java.base/java.lang.Thread.start(Thread.java:1459)
	at java.base/java.lang.ThreadBuilders$PlatformThreadBuilder.start(ThreadBuilders.java:196)
	at Main.main(Main.java:4)

It never reached 10,000. The two lines that start with a time in square brackets are warnings the JVM printed when the operating system said no. The second one names the thread that could not start, Thread-2019, and threads are numbered from Thread-0, so 2,019 had started. On that Mac one program may have at most 2,048 threads, and the JVM was already using some for its own work. Java reported the refusal as an OutOfMemoryError. The same run gave the same count three times. Other systems have other limits, often higher on Linux, but every system has one.

The memory adds up too. Measured on the same Mac, a program holding 2,000 sleeping platform threads used about 137 MB of memory, against 21 MB for the same program with no extra threads. That is roughly 58 KB of real memory for each thread that does nothing but sleep, on top of the 2 MB block each one reserves.

Key idea

A platform thread is tied to one operating system thread for its whole life. Each one costs memory and a place under a fixed limit, so a program can have a few thousand of them, not a hundred thousand.

Waiting wastes a platform thread#

Most programs that need many threads are not busy computing. They are waiting. A web server reads a request, asks a database for some rows and waits, then calls a payment service and waits again. Reading from the network, a disk or a database is called I/O, short for input and output. A method that does not return until its data has arrived is a blocking call: the thread that made it can do nothing else until then.

Two meanings of blocked
People say a thread is “blocked on I/O” when it is stuck in a blocking call. That is a different idea from the BLOCKED state you met in synchronization, which only means waiting to take a lock.

A platform thread stuck in a blocking call uses no processor time, but it keeps everything it was given: its operating system thread, its stack and its place under the limit. So a server that gives each request a thread from a pool of 200 can have at most 200 requests in progress. Request number 201 waits in the pool's queue, even when the processor has nothing to do.

This program works out when each task starts, for a pool of 3 threads and 7 tasks that each wait 2 seconds. The tasks start in rounds: the first 3 take the 3 threads, the next 3 start when those finish, and so on. Step through it and watch round and start.

Timetable.java
1public class Main {
2 public static void main(String[] args) {
3 int tasks = 7;
4 int threads = 3;
5 int secondsEach = 2;
6
7 for (int task = 0; task < tasks; task++) {
8 int round = task / threads;
9 int start = round * secondsEach;
10 System.out.println("task " + task + " starts at " + start + "s");
11 }
12
13 int rounds = (tasks + threads - 1) / threads;
14 System.out.println("all done after " + rounds * secondsEach + "s");
15 }
16}
Prints
›task 0 starts at 0s
›task 1 starts at 0s
›task 2 starts at 0s
›task 3 starts at 2s
›task 4 starts at 2s
›task 5 starts at 2s
›task 6 starts at 4s
›all done after 6s

It prints a start time for each task, then all done after 6s. Tasks 3, 4 and 5 sat in the queue for 2 seconds and task 6 for 4 seconds, while the threads that could have served them were only waiting. The line (tasks + threads - 1) / threads is integer division that rounds up: 7 tasks need 3 rounds, though the last round holds only one task. Change threads to 7 and run it again. Every task starts at 0s, and everything is done after 2s.

Predict the outputRounds.java
1public class Main {
2 public static void main(String[] args) {
3 int tasks = 10;
4 int threads = 4;
5 int secondsEach = 1;
6
7 int lastTask = tasks - 1;
8 int start = (lastTask / threads) * secondsEach;
9 System.out.println("task " + lastTask + " starts at " + start + "s");
10
11 int rounds = (tasks + threads - 1) / threads;
12 System.out.println("all done after " + rounds * secondsEach + "s");
13 }
14}

A pool of 4 threads gets 10 tasks that each wait 1 second. Tasks are numbered from 0. What do the two lines say?

It prints task 9 starts at 2s and all done after 3s. Task 9 is in round 9 / 4, which is 2 in integer division, so it waits for 2 rounds of one second. Ten tasks need 3 rounds, because 4 threads can take only 4 tasks a round.

ExerciseEstimate.java
Finish secondsFor so it returns how long a batch of waiting tasks takes. The tasks start in rounds of threads tasks, and every round lasts secondsEach seconds. Leave main as it is: it checks a pool of 200 threads, one thread per task, and the timetable above.

The first line is the problem in one number. Ten thousand tasks that each wait one second take 50 seconds on a pool of 200 threads, and one second if every task has a thread of its own. You saw what happens when you try to give 10,000 tasks a platform thread each.

Key idea

With platform threads, the number of tasks that can wait at the same time is the number of threads you can afford. Tasks beyond that queue up, even while the processor sits idle.

What a virtual thread is#

A virtual thread is a Thread object that the JVM schedules itself, instead of leaving it to the operating system. It runs on an ordinary platform thread, but only while it has work to do. The platform thread it runs on is called its carrier.

When a virtual thread makes a blocking call, such as Thread.sleep or a read from the network, the JVM takes it off its carrier. Its stack, the method calls it is in the middle of, is saved in the heap like any other object, and the carrier is free to run another virtual thread. When the data arrives or the sleep ends, the virtual thread joins a queue, and the next free carrier picks it up and carries on from where it stopped. Putting a virtual thread on a carrier is called mounting it, and taking it off is unmounting.

Think of a restaurant. With platform threads, each table has its own waiter, who takes the order to the kitchen and then stands at the kitchen door until the food is ready. Two hundred waiters serve two hundred tables, and the restaurant cannot afford more waiters. With virtual threads, each order is a ticket. A few waiters carry tickets to the kitchen. While a dish cooks, its ticket is pinned to a board and the waiter goes to take the next order. When the dish is ready, whichever waiter is free picks the ticket up. The tickets are the virtual threads, the waiters are the carriers, and the board is the heap.

Hello.java
read along
1public class Main {
2 public static void main(String[] args) throws InterruptedException {
3 Thread worker = Thread.ofVirtual().start(() -> {
4 System.out.println("Hello from " + Thread.currentThread());
5 });
6 worker.join();
7
8 System.out.println("Is worker virtual? " + worker.isVirtual());
9 System.out.println("Is main virtual? " + Thread.currentThread().isVirtual());
10 }
11}
Output
Hello from VirtualThread[#30]/runnable@ForkJoinPool-1-worker-1
Is worker virtual? true
Is main virtual? false

Thread.ofVirtual() is the sibling of the Thread.ofPlatform() builder, and start starts the new thread at once. The printed thread has three parts. VirtualThread[#30] says what it is, with its id number. A virtual thread has no name unless you give it one, so nothing follows the id. runnable is its state: it is running. Everything after the @ is its carrier, ForkJoinPool-1-worker-1: worker 1 of the pool of carriers that the JVM keeps for virtual threads. isVirtual() tells the two kinds apart, and the main thread is a platform thread. When you run it, the id and the worker number may differ.

A virtual thread can come back on another carrier#

This program starts three named virtual threads. Each one prints itself, sleeps for half a second, and prints itself again.

Carriers.java
read along
1public class Main {
2 public static void main(String[] args) throws InterruptedException {
3 Runnable job = () -> {
4 System.out.println("before: " + Thread.currentThread());
5 try {
6 Thread.sleep(500);
7 } catch (InterruptedException e) {
8 return;
9 }
10 System.out.println("after: " + Thread.currentThread());
11 };
12
13 Thread[] tasks = new Thread[3];
14 for (int i = 0; i < tasks.length; i++) {
15 tasks[i] = Thread.ofVirtual().name("task-" + (i + 1)).start(job);
16 }
17 for (Thread t : tasks) {
18 t.join();
19 }
20 }
21}
Output
before: VirtualThread[#30,task-3]/runnable@ForkJoinPool-1-worker-3
before: VirtualThread[#29,task-2]/runnable@ForkJoinPool-1-worker-2
before: VirtualThread[#27,task-1]/runnable@ForkJoinPool-1-worker-1
after:  VirtualThread[#27,task-1]/runnable@ForkJoinPool-1-worker-4
after:  VirtualThread[#30,task-3]/runnable@ForkJoinPool-1-worker-2
after:  VirtualThread[#29,task-2]/runnable@ForkJoinPool-1-worker-3

Look at the worker after each @. task-1 went to sleep on worker 1 and woke up on worker 4. In this run all three came back on a different carrier from the one they started on; in another run, task-2 came back on the same one. The sleep unmounted each thread, and any free carrier could pick it up again. The job itself never notices: Thread.currentThread() always returns the virtual thread, never its carrier. The order of the lines changes from run to run as well.

By default the JVM uses as many carriers as the computer has cores. When 10,000 sleeping virtual threads ran on the 8-core Mac used for this page, all of them were carried by the same 8 platform threads, worker-1 to worker-8, in each of three runs.

A sleeping virtual thread is small: its saved stack and a few objects in the heap. On the same Mac, a program holding 100,000 sleeping virtual threads used between 139 and 155 MB of memory, about what 2,000 platform threads used. That is roughly 1.2 to 1.3 KB per virtual thread. A thread that sleeps in the middle of deep method calls has a bigger stack to save, so real tasks use more, but still far less than a platform thread.

Key idea

A virtual thread borrows a carrier only while it has work to do. When it waits, the JVM saves its stack in the heap and the carrier runs another virtual thread, so waiting costs a little heap memory instead of a whole operating system thread.

Quick check
A program on a 4-core laptop starts 5,000 virtual threads that each sleep for a second. By default, how many carrier threads does the JVM use to run them?

Three ways to start a virtual thread#

You have already used the first way. Here are all three, side by side:

  • Thread.ofVirtual().start(job) uses the builder. Add .name("...") before start to name the thread.
  • Thread.startVirtualThread(job) is a shortcut for Thread.ofVirtual().start(job).
  • Executors.newVirtualThreadPerTaskExecutor() is an ExecutorService that starts a new virtual thread for every task you submit.
ThreeWays.java
read along
1import java.util.concurrent.ExecutorService;
2import java.util.concurrent.Executors;
3
4public class Main {
5 public static void main(String[] args) throws InterruptedException {
6 Runnable greet = () -> System.out.println("hello from " + Thread.currentThread());
7
8 Thread first = Thread.ofVirtual().name("first").start(greet);
9 first.join();
10
11 Thread second = Thread.startVirtualThread(greet);
12 second.join();
13
14 try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
15 executor.submit(greet);
16 executor.submit(greet);
17 } // close() waits here for both tasks
18
19 System.out.println("all done");
20 }
21}
Output
hello from VirtualThread[#30,first]/runnable@ForkJoinPool-1-worker-1
hello from VirtualThread[#33]/runnable@ForkJoinPool-1-worker-1
hello from VirtualThread[#34]/runnable@ForkJoinPool-1-worker-1
hello from VirtualThread[#35]/runnable@ForkJoinPool-1-worker-2
all done

Both Thread methods return the started thread, so join()works as it always has. The executor is opened in a try-with-resources statement. When the block ends, Java calls the executor's close(), which stops it from taking new tasks and then waits until every task already submitted has finished. That is why all done always comes last. In two of four runs, the two submitted tasks printed in the other order, as two threads running at once may.

One virtual thread per task, never a pool
A pool exists to share a few expensive threads. Virtual threads are cheap, so there is nothing to share: start a new one for each task and let it end with the task. If you need to limit how many tasks do something at once, for example because a database accepts only 10 connections, limit that one thing instead of the threads. Java has a class for it, java.util.concurrent.Semaphore, which hands out a fixed number of permits: a task takes a permit before it calls the database and gives it back afterwards.

The trap: Java does not wait for virtual threads#

This program starts a virtual thread and lets main end without a join().

Forgot.java
read along
1public class Main {
2 public static void main(String[] args) {
3 Thread.startVirtualThread(() -> System.out.println("virtual thread: hello"));
4 System.out.println("main: done");
5 }
6}
Output
main: done
virtual thread: hello

Run it a few times and the second line is sometimes missing. In 50 runs with java Main.java on the Mac used for this page it was missing 6 times, and in 100 runs of the compiled program, 23 times. Virtual threads are always daemon threads, the kind Java does not wait for before it ends a program (you met them in the introduction to threads). Once main returns, nothing keeps the program alive, so it ends whether or not the virtual thread has printed yet. The same program with Thread.ofPlatform() printed both lines in 100 runs out of 100.

Always wait for your virtual threads
Call join() on each virtual thread you start, or submit the tasks to a virtual thread executor in try-with-resources, whose close() waits for you. Making the thread a non-daemon is no way out: on a virtual thread, setDaemon(false) throws an IllegalArgumentException.
Quick check
main calls Thread.startVirtualThread(job) and then returns, with no join(). The job prints one line. What happens?

Ten thousand tasks that wait#

Here is the experiment that shows what virtual threads are for. Ten thousand tasks each wait one second, with Thread.sleep standing in for a slow reply from a server, and the program times the whole batch.

TenThousand.java
read along
1import java.util.concurrent.ExecutorService;
2import java.util.concurrent.Executors;
3
4public class Main {
5 public static void main(String[] args) {
6 long start = System.currentTimeMillis();
7
8 try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
9 for (int i = 0; i < 10_000; i++) {
10 executor.submit(() -> {
11 Thread.sleep(1000); // stands in for waiting on a server
12 return "reply";
13 });
14 }
15 } // close() waits here until every task has finished
16
17 long took = System.currentTimeMillis() - start;
18 System.out.println("10000 tasks took " + took + " ms");
19 }
20}
Output
10000 tasks took 1057 ms

Each task is a lambda that sleeps and then returns a value, so it is a Callable, and a Callable may throw InterruptedException without a try and catch. All 10,000 virtual threads sleep at the same time, and the batch takes just over a second: between 1,046 and 1,081 ms in five runs. Your times will differ a little, but almost all of it is the one-second wait.

Now change one line so that the tasks share a pool of 200 platform threads: Executors.newFixedThreadPool(200). The output becomes 10000 tasks took 50242 ms, and 50,244 ms in a second run. That is the arithmetic from the timetable: 10,000 tasks in rounds of 200 is 50 rounds of one second each.

A third try gives every task its own platform thread with Executors.newCachedThreadPool(), which starts a new platform thread whenever none is free. On the Mac used for this page it crashed after starting 2,017 threads, with the same OutOfMemoryError as TooMany, and never printed a time.

ExecutorThreadsResult on the Mac used for this page
newVirtualThreadPerTaskExecutor()10,000 virtual threads on 8 carriersAbout 1.05 seconds
newFixedThreadPool(200)200 platform threadsAbout 50.2 seconds
newCachedThreadPool()One platform thread per taskCrashed after 2,017 threads
Ten thousand tasks that each wait one second.
Key idea

Virtual threads do not make a task faster: each task still waits its full second. They let all 10,000 tasks wait at the same time, so the batch finishes in one second instead of fifty.

When virtual threads do not help#

A virtual thread runs code no faster than a platform thread. It is the same Java code on the same processor; what changed is how cheaply a thread can wait. So the question to ask about a task is whether it spends its time waiting or computing. Here are 16 tasks that never wait. Each one counts the prime numbers below 3,000,000, which keeps a core busy the whole time.

BusyTasks.java
read along
1import java.util.concurrent.ExecutorService;
2import java.util.concurrent.Executors;
3
4public class Main {
5 public static void main(String[] args) {
6 long start = System.currentTimeMillis();
7
8 try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
9 for (int i = 0; i < 16; i++) {
10 executor.submit(() -> countPrimes(3_000_000));
11 }
12 }
13
14 long took = System.currentTimeMillis() - start;
15 System.out.println("16 busy tasks took " + took + " ms");
16 }
17
18 static int countPrimes(int limit) {
19 int count = 0;
20 for (int n = 2; n < limit; n++) {
21 boolean prime = true;
22 for (int d = 2; d * d <= n; d++) {
23 if (n % d == 0) {
24 prime = false;
25 break;
26 }
27 }
28 if (prime) {
29 count++;
30 }
31 }
32 return count;
33 }
34}
Output
16 busy tasks took 4204 ms

That run took 4,204 ms. Swapping the executor gave the times below. The laptop was busy with other programs while they ran, so they jump around from round to round:

ExecutorThreadsTime in each round
newVirtualThreadPerTaskExecutor()16 virtual threads on 8 carriers4,204, 4,460 and 3,757 ms
newFixedThreadPool(8)8 platform threads6,171, 3,308 and 2,341 ms
newFixedThreadPool(16)16 platform threads5,297, 5,148 and 3,308 ms
newFixedThreadPool(1)1 platform thread11,965 ms (one round)
Sixteen tasks that only compute, on an 8-core laptop.

Neither kind of thread came out reliably ahead. Every task needs a core for its whole run, the laptop has 8 cores, and the virtual threads run on 8 carriers, so at most 8 tasks compute at any moment however many threads you start. More threads only take turns on the same cores. The single thread was slowest because it used one core while the other seven sat idle.

Virtual threads help
Virtual threads do not help
Many tasks that spend most of their time waiting: web requests, database queries, calls to other services.
Tasks that keep the processor busy: resizing photos, counting primes, compressing files.
Thousands of tasks in progress at once. Oracle's guide says a program that never has about 10,000 at once is unlikely to gain much.
A handful of tasks. A few platform threads do that job just as well.
The gain is more tasks finished per second.
There is nothing to gain: the cores are the limit, not the threads.
Quick check
Which of these programs gains the most from virtual threads?

Pinning, and what changed in Java 24#

Older articles about virtual threads warn about pinning: a virtual thread that cannot be unmounted while it waits, so its carrier waits with it and runs nothing else. In Java 21 to 23 the usual cause was waiting inside a synchronized method or block, and the advice was to replace synchronized with a lock class. Java 24 removed that cause (JEP 491). A virtual thread can now unmount while it is inside a synchronized method or block, and while it waits to enter one. So on Java 25 you can use synchronized with virtual threads exactly as you learned it.

What still pins a virtual thread is rare in everyday code: running native code, such as a method written in C, and a few moments while a class is being loaded and set up. Pinning never makes a program wrong. It only means one carrier sits idle for as long as that wait lasts.

Structured concurrency, still a preview#

Starting tasks is easy now. Keeping track of them is harder. Suppose a page needs a customer's details and their orders, and you fetch both at once in two virtual threads. If one fetch fails, the other should be cancelled, and the error should reach the code that asked for both. Structured concurrency is an API for exactly that. It treats a group of tasks started together as one unit of work: they start inside one block of code, they have all finished before that block ends, and if one fails, the others are cancelled.

In Java 25 it is a preview for the fifth time (JEP 505), so you have to switch it on with --enable-preview, as modern Java syntax explains, and its classes can still change. They did change in Java 25, which replaced the constructors with factory methods, and Java 26 previews it a sixth time (JEP 525). This course will teach the code once it is final. Until then, an executor in try-with-resources gives you part of the shape: every task you submit inside the block has finished when the block ends.

Next: sharing safely
Every task on this page kept to its own data. When thousands of virtual threads update the same counter or the same map, you are back to the races from synchronization. Safe sharing: atomics, locks and concurrent collections covers the tools for that.

Try it

Answer each one in your head or on paper first, then open the answer.

1
In a sentence or two each, say what a platform thread is and what a virtual thread is.
2
A pool of 50 platform threads gets 1,000 tasks that each wait 2 seconds for a reply. Roughly how long does the batch take? And with one virtual thread per task?
3
A thread printed itself as VirtualThread[#41,report]/runnable@ForkJoinPool-1-worker-6. What do report and worker-6 tell you?
4
A program starts 100 virtual threads in a loop, and main ends right after the loop. Some of the threads' output is missing. Why, and what are two fixes?
5
A teammate wants a pool of 10 virtual threads, so that at most 10 tasks talk to the database at once. What would you suggest instead?
6
Why do virtual threads not speed up a program that only computes?

Practice

Work through these on your own. Each one builds on this page.

1
On your own computer, run TenThousand three ways: with newVirtualThreadPerTaskExecutor(), with newFixedThreadPool(100) and with newFixedThreadPool(1000). Before each run, write down the time you expect, using the arithmetic of rounds. Then compare.
2
Rewrite TwoDownloads from the introduction to threads so that each download is a task submitted to Executors.newVirtualThreadPerTaskExecutor() in try-with-resources, and main prints all downloads finished after the block. Run it several times and check that this line always comes last.
3
Start five virtual threads named download-1 to download-5. Each one prints Thread.currentThread() before and after a 300 millisecond sleep. Run the program several times and write down which carrier each thread used before and after its sleep. Does any thread come back on the same carrier?
4
In a short paragraph, explain to a friend who knows thread pools why a server built on virtual threads does not need a pool of 200 threads, and name one kind of program where virtual threads would not help. Use the words carrier and blocking.

Further reading#

Spotted a mistake, or a part that did not make sense? Report it by email