Java has always provided multithreading capabilities via platform threads, which are typically thin wrappers over operating system (OS) threads. While powerful, they come with limitations. Because of this, the OpenJDK community has introduced a lightweight concurrency construct to Java via Project Loom. This initiative aims to introduce lightweight concurrency pervasively via new features, APIs, and optimizations across the whole JDK. The main feature of Project Loom is virtual threads which we'll dive into in this topic.
Virtual threads were introduced in Java 21 as a preview and stabilized in Java 22 via JEP 444.
The need for virtual threads
Before virtual threads were introduced, multithreading in Java was mostly done by creating platform threads using ExecutorService or extending the Thread class in java among other ways. Each platform thread is mapped to a single operating system thread, limiting the total number of threads we could create. Creating thousands of concurrent platform threads using Thread or ExecutorService can become expensive due to the limited number of OS threads and the memory each one consumes.
This poses a challenge for modern applications like servers or reactive APIs that must handle tens or hundreds of thousands of concurrent connections, most of which are I/O-bound and spend time waiting (for database responses or network input among many).
To address this, virtual threads were introduced as a lightweight, scalable threading model. Virtual threads behave like platform threads from the developer’s perspective, but are far more lightweight under the hood. It allows us to decouple Java threads from OS threads. Since virtual threads are managed by the JVM and not the operating system, they’re cheaper to create, and thousands or even millions can coexist in the same application.
Creating a virtual thread
Creating a virtual thread is as simple as using the Thread API with a factory method. Here’s how to launch a basic virtual thread:
Thread.startVirtualThread(() -> {
System.out.println("Running in a virtual thread!");
});This creates and starts a virtual thread that runs the task you provide. It's very similar to creating a platform thread — but under the hood, it's lightweight and doesn’t occupy an OS thread for the entire duration.
Alternatively, you can build the thread explicitly like this:
Thread virtualThread = Thread.ofVirtual().start(() -> {
System.out.println("Running in a virtual thread!");
});Just like traditional threads, virtual threads run concurrently and execute the task passed to them.
Using virtual threads with executors
Virtual threads shine when combined with executors, especially when running a large number of short-lived or blocking tasks.
Here’s how to use an executor that creates a new virtual thread for each task:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
executor.submit(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
return "Task completed by " + Thread.currentThread();
});
}
}This executor creates a fresh virtual thread per task, enabling enormous concurrency without overwhelming system resources.
Virtual threads vs. platform threads
While virtual threads behave like traditional threads, their implementation is radically different. They are scheduled by the Java Virtual Machine (JVM), not the OS. This means the JVM can pause and resume them efficiently, without consuming a native thread during blocking calls (like sleep(), Socket.read(), etc.). Whereas in traditional threading, if a thread performs a blocking operation (like reading from a file or waiting on a socket), the OS thread is blocked while it does nothing and just sits idle.
Feature | Platform thread | Virtual thread |
|---|---|---|
Backed by | OS thread | Java runtime (user-mode scheduling) |
Memory cost | Much higher than virtual thread | Minimal (stack grows on demand) |
Creation time | Relatively slow | Very fast |
Ideal use case | Long-running tasks | Short-lived or blocking tasks |
Blocking behavior | Ties up OS thread | Suspends cheaply without blocking OS thread |
When to use virtual threads
Taking into consideration all that we have discussed, virtual threads seem to be better than Platform threads, but this isn't always the case. Here are the scenarios where virtual threads are particularly effective:
Web servers handling thousands of concurrent HTTP requests;
Batch processing systems that perform many independent I/O operations;
Client-server models where each connection runs in its own thread;
Concurrent utilities like crawlers, job schedulers, or message brokers.
Now let's look at a small demonstration that launches 10,000 tasks in parallel using virtual threads:
public class ManyVirtualThreads {
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
try {
System.out.println("Hello " + Thread.currentThread());
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
var threads = new ArrayList<>();
for (int i = 0; i < 10000; i++) {
Thread t = Thread.ofVirtual().unstarted(task);
t.start();
threads.add(t);
}
for (Thread t : threads) {
t.join();
}
System.out.println("All tasks completed");
}
}Here, we use Thread.ofVirtual() to create a builder for virtual threads. Only after using .unstarted(task) does it return a new virtual thread instance. This instance however hasn't started yet, so to start it we can call start method separately. Each thread runs the same task that was defined earlier.
Finally, we iterate over each virtual thread we had created and use the join() method on each of them. It blocks the main underlying thread until each virtual thread t has completed. This ensures that the program doesn’t terminate prematurely before all threads finish their sleep. This is important because the main thread has no dependency on the virtual threads unless explicitly mentioned, and the JVM doesn’t keep the process alive for background virtual threads unless they're joined or registered via structured concurrency or another management mechanism. If we do not use join then the main underlying thread might finish and print All tasks completed immediately, even though many or all virtual threads are still sleeping. In some cases, the program may even terminate before all the virtual threads get a chance to run.
Try this with platform threads, and your system will likely run out of memory. With virtual threads, it finishes easily. As a rule of thumb, use virtual threads where the bottleneck is waiting, not computing.
Best practices for using virtual threads
Here are some key best practices when working with virtual threads:
Use per-task virtual threads — don’t pool them. Virtual threads are designed to be cheap to create. Instead of reusing them in pools, you should start a fresh virtual thread per task. Avoid reusing virtual threads or creating your own pool, which defeats their purpose.
Be cautious with thread-local variables. Virtual threads may migrate across carrier threads, meaning the physical OS thread they run on can change. If you use
ThreadLocal, the data does not automatically follow the virtual thread as it switches carriers. This can lead to bugs if thread-local state is assumed to persist across blocking calls. If you rely onThreadLocal, evaluate alternatives like using scoped variables (planned in future JEPs) or passing data explicitly.Avoid synchronization with monitors. Heavy use of
synchronizedblocks or methods can block the underlying carrier thread, reducing scalability. PreferReentrantLockor other non-blocking structures when appropriate.Mixing virtual and platform threads in the same executor is discouraged. Keep execution models consistent. Mixing threads with vastly different performance profiles in the same executor may lead to confusing behavior and performance issues.
Observing virtual threads
Understanding what your threads are doing, especially in production or during performance tuning is a critical part of building concurrent applications. With traditional platform threads, tools like thread dumps or profilers are typically enough to observe how your program is using threads. But when it comes to virtual threads, things change: their behavior, visibility, and lifespan are fundamentally different.
Virtual threads are created rapidly, often by the thousands, and many of them may live only for a brief moment. This makes them highly efficient, but also makes them more challenging to monitor using conventional techniques. Observability tools that assume long-lived platform threads can quickly become overwhelmed or simply miss virtual threads entirely if those threads start and finish too quickly to be captured.
Still, Java does offer ways to inspect and analyze what your virtual threads are doing. One of the most powerful built-in tools for this is the jcmd utility, which allows you to interact with a running Java process by sending diagnostic commands. Among many other things, jcmd lets you generate thread dumps. These dumps are snapshots of all the threads currently active in the JVM, including both platform threads and virtual threads.
Here’s a common and effective way to use it:
jcmd <pid> Thread.dump_to_file -format=json <file_name>In this command:
<pid>refers to the process ID of the Java application you want to inspect.Thread.dump_to_fileis the specific command to dump all threads.-format=jsonspecifies that the thread dump is saved in JSON format but is optional.filenamerefers to the output file name where the thread dump will be saved. You can also include the path to the file here.
You can obtain the pid of your Java program using ProcessHandle.current().pid(). While your program with virtual threads is running, execute the above command using the obtained pid and it will produce a detailed file which lists every live thread at the moment of capture, including their names, stack traces, and status (e.g., runnable, waiting, blocked). Virtual threads will appear in this dump, although their sheer number may be surprising if your application is handling many concurrent tasks. Here is what the result of the thread dump will look like when rendered in a JSON viewer (taken from official OpenJDK documentation JEP 444):
Let's look at a small program that you can run to see what a thread dump looks like. The following program uses ProcessBuilder to launch a separate process that executes the jcmd command to save thread dump data in JSON format in a file named ThreadDump.txt. You could try running the jcmd command manually in a terminal but this method is easier and produces reliable results:
public class Main {
public static void main(String[] args) throws IOException, InterruptedException {
// Identify process id
System.out.println("PID: " + ProcessHandle.current().pid());
System.out.println("Press Enter to continue...");
System.in.read();
// Create many virtual threads easily
var threads = new Thread[1_000_000];
for (int i = 0; i < 1_000_000; i++) {
threads[i] = Thread.ofVirtual().start(() -> {
try { Thread.sleep(1000); }
catch (InterruptedException e) { throw new RuntimeException(e); }
System.out.println(Thread.currentThread());
});
}
// Wait for all threads to complete
for (Thread t : threads) {
t.join();
}
System.out.println("\nAll virtual threads have terminated!");
}
}First we identify and print the process id of the Java program. The process id will allow us to create the thread dump for that specific program instance. After the user wishes to continue by pressing any key, the program creates a million virtual threads that sleep for a second each. Sleeping for a certain time makes sure that the virtual threads are alive for long enough to appear in the thread dump. Finally, all the threads are joined so that the main program thread doesn't end before all the virtual threads finish execution. While the program is running, the process ID can be used with the jcmd command to create the thread dump. Let's assume the program prints PID: 10056, so the command will look like this:
jcmd 10056 Thread.dump_to_file -format=json thread_dump.txtGive it a try and evaluate the results you get.
Real-world use case: server-style applications
Let’s say we want to write a server that handles each HTTP request in a separate thread. Virtual threads let you do this cleanly:
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
try {
var server = HttpServer.create(new InetSocketAddress(8080), 0);
server.createContext("/", exchange -> {
executor.submit(() -> {
try {
String response = "Hello from virtual thread!";
exchange.sendResponseHeaders(200, response.getBytes().length);
exchange.getResponseBody().write(response.getBytes());
exchange.close();
} catch (IOException e) {
e.printStackTrace();
}
});
});
server.start();
} catch (IOException e) {
throw new RuntimeException(e);
}Here, Executors.newVirtualThreadPerTaskExecutor() is used to create a new virtual thread for each request to the server. After creating the server, we define a handler for HTTP requests to root path / using server.createContext(). The exchange object represents the incoming HTTP request and response channel. For each request we can simply create new virtual threads, instead of using a platform thread which we would need to manage and reuse efficiently. So you don't need to worry about about managing thread pools when using virtual threads. This server will happily accept thousands of requests at once, each in its own virtual thread, with minimal memory usage.
Conclusion
Virtual threads make it easy to write scalable and maintainable code using simple, blocking logic, all while unlocking the ability to handle thousands or millions of tasks concurrently, marking a turning point in how Java handles concurrency. Overall, virtual threads can make your code more scalable and easier to reason about, especially for I/O-heavy workloads.