Modern Concurrency with Java Virtual Threads (Project Loom)
For years, writing highly concurrent applications in Java meant choosing between two imperfect options.
You could use traditional platform threads and carefully tune thread pools to avoid exhausting system resources, or you could embrace reactive programming with frameworks like Reactor or RxJava and trade simplicity for scalability.
Neither approach was particularly enjoyable.
Managing thread pools, debugging asynchronous callback chains, or tracking down mysterious RejectedExecutionExceptions became part of everyday Java development.
Meanwhile, languages like Go demonstrated that writing scalable blocking I/O code didn't have to be complicated.
With Project Loom, Java finally introduces a different approach.
Virtual Threads allow developers to write simple, synchronous, blocking code while still achieving the scalability previously associated with asynchronous frameworks.
What Are Virtual Threads?
Virtual Threads are lightweight threads managed entirely by the JVM instead of being mapped one-to-one with operating system threads.
Unlike traditional platform threads, virtual threads are extremely cheap to create and suspend.
Instead of dedicating an operating system thread to every task, the JVM schedules many virtual threads onto a much smaller pool of carrier (platform) threads.
Whenever a virtual thread blocks on a supported operation—such as socket I/O or Thread.sleep()—the JVM temporarily unmounts it from its carrier thread.
That carrier thread immediately becomes available to execute another virtual thread.
As a result:
- Blocking becomes inexpensive.
- Thread-per-request programming becomes practical again.
- Complex asynchronous programming is often unnecessary.
- Applications can scale to very large numbers of concurrent tasks.
Platform Threads vs Virtual Threads
Traditional Platform Thread
Runnable task = () -> {
System.out.println(Thread.currentThread());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
for (int i = 0; i < 10; i++) {
new Thread(task).start();
}
Every task creates a new operating system thread.
While this works for small workloads, thousands of concurrent requests quickly become expensive in terms of memory and scheduling overhead.
Virtual Thread
Runnable task = () -> {
System.out.println(Thread.currentThread());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
for (int i = 0; i < 10; i++) {
Thread.startVirtualThread(task);
}
The code looks almost identical.
The difference is that each task now runs inside a virtual thread managed by the JVM rather than directly by the operating system.
Building an HTTP Server
Let's build a minimal HTTP server using Java's built-in HttpServer.
Instead of configuring a fixed thread pool, we'll use a virtual-thread-per-task executor.
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.util.concurrent.Executors;
public class VirtualThreadServer {
public static void main(String[] args) throws IOException {
HttpServer server = HttpServer.create(
new InetSocketAddress(8080),
0
);
server.createContext("/", new HelloHandler());
server.setExecutor(
Executors.newVirtualThreadPerTaskExecutor()
);
server.start();
System.out.println("Listening on http://localhost:8080");
}
static class HelloHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange)
throws IOException {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
String response = "Hello from a Virtual Thread!";
exchange.sendResponseHeaders(
200,
response.length()
);
try (OutputStream out =
exchange.getResponseBody()) {
out.write(response.getBytes());
}
}
}
}
Every incoming request executes inside its own virtual thread.
This allows the server to handle many concurrent blocking requests without manually tuning thread pools.
Structured Concurrency
Virtual Threads become even more powerful when combined with Structured Concurrency.
Instead of launching detached background tasks, related concurrent operations are grouped together inside a scope.
This improves cancellation, error propagation, and overall readability.
import java.util.concurrent.StructuredTaskScope;
public class StructuredExample {
public static void main(String[] args)
throws Exception {
try (var scope =
new StructuredTaskScope.ShutdownOnFailure()) {
var user =
scope.fork(StructuredExample::fetchUser);
var permissions =
scope.fork(StructuredExample::fetchPermissions);
scope.join();
scope.throwIfFailed();
System.out.println(
user.get() + " : " +
permissions.get()
);
}
}
static String fetchUser()
throws InterruptedException {
Thread.sleep(100);
return "Alice";
}
static String fetchPermissions()
throws InterruptedException {
Thread.sleep(100);
return "Admin";
}
}
Note:
StructuredTaskScopewas introduced as a preview API. Check the Java version you're targeting, as its status has evolved across releases.
Performance Characteristics
Virtual Threads do not make individual operations faster.
Instead, they dramatically improve scalability for applications that spend significant time waiting on I/O.
Typical examples include:
- REST APIs
- Database-driven services
- HTTP clients
- Messaging systems
- Microservices
Instead of limiting concurrency to the size of a thread pool, applications can create a virtual thread for each request while maintaining a simple synchronous programming model.
Things to Watch Out For
Virtual Threads are not a silver bullet.
There are still situations where developers should be careful.
ThreadLocal
Heavy reliance on ThreadLocal can become expensive with millions of virtual threads.
Consider using ScopedValue where appropriate.
Native Blocking
Some native operations cannot unmount virtual threads.
Examples include certain filesystem operations and native libraries.
These operations may still occupy carrier threads.
CPU-Bound Work
Virtual Threads primarily solve blocking I/O scalability.
They do not make CPU-intensive algorithms faster.
Parallel computation should still rely on appropriate executors or parallel processing strategies.
Library Compatibility
Most modern Java libraries work well with Virtual Threads.
However, older libraries that pin threads or rely on native blocking may not fully benefit from Project Loom.
Always verify compatibility before migrating production systems.
When Should You Use Virtual Threads?
Virtual Threads are an excellent choice for:
- REST APIs
- Microservices
- Database-backed applications
- HTTP clients
- Message consumers
- Any application dominated by blocking I/O
They are generally not intended to replace specialized solutions for:
- High-performance numerical computing
- GPU workloads
- CPU-bound parallel algorithms
Key Takeaways
- Virtual Threads dramatically reduce the cost of blocking.
- The familiar thread-per-request model becomes scalable again.
- Existing synchronous Java code often requires minimal changes.
- Reactive programming is no longer the only option for highly concurrent I/O applications.
- Structured Concurrency complements Virtual Threads by making concurrent code easier to organize and reason about.
- Virtual Threads improve scalability rather than raw computational speed.
Conclusion
Project Loom represents one of the most significant changes to Java's concurrency model since the introduction of the java.util.concurrent package.
Instead of forcing developers to avoid blocking, Java now embraces a simpler programming model while allowing the JVM to manage concurrency efficiently behind the scenes.
For many applications, this means writing straightforward, synchronous code that remains highly scalable—without the complexity of reactive pipelines or carefully tuned thread pools.
Virtual Threads won't replace every concurrency technique, but for I/O-heavy applications, they fundamentally change how modern Java systems can be designed.