With the introduction of Virtual Threads in Java 21 as a preview feature (and stable in later versions), the way we think about concurrency in Java is changing dramatically.
What Are Virtual Threads?
In Java, when you want your program to do multiple things at the same time like handling lots of user requests, you typically use threads. But traditional threads (called platform threads) are expensive. Each one uses up a chunk of memory and is tied to a real thread in your computer's operating system (OS).
If you try to create thousands of threads (like in a busy server), the system starts to struggle. That's why developers had to get clever, using tools like asynchronous code, callbacks, or reactive libraries like Reactor or WebFlux. These solutions work, but they’re harder to read, write, and debug.
Virtual threads look and act like regular Java threads, but under the hood, they’re much lighter. Instead of being tied directly to OS threads, they’re managed by the JVM (Java Virtual Machine) itself. This means you can create millions of them without crashing your system.
This is what a basic user service might look like using the classic, readable style:
@GetMapping("/user/{id}")
public ResponseEntity<User> getUser(@PathVariable String id) {
User user = userService.findById(id); // blocking call
return ResponseEntity.ok(user);
}
This is simple. It reads top-down, is easy to understand, and you can use try/catch or other flow control as expected.
Below, we have the same code using Spring WebFlux and Project Reactor:
// Inside the userController class
@GetMapping("/user/{id}")
public Mono<ResponseEntity<User>> getUser(@PathVariable String id) {
return userService.findById(id)
.map(user -> ResponseEntity.ok(user))
.onErrorResume(e -> Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build()));
}
// Inside the userService class
public Mono<User> findById(String id) {
return webClient.get()
.uri("/users/" + id)
.retrieve()
.bodyToMono(User.class);
}
The previous example becomes harder because you’re no longer returning the result directly, you’re returning a Mono (a future-like wrapper). You need to chain callbacks (map, flatMap, onErrorResume, etc.) to handle logic and the Try/catch doesn’t work the same way, so error handling becomes more abstract.
What Problem Do Virtual Threads Solve?
Traditional Java threads can’t scale well when you need thousands of them.
Let’s say you’re building a web server that handles one request per thread. With just a few hundred users, you're fine. But as traffic grows, the number of threads balloons, memory usage spikes, and performance drops. It’s just not sustainable.
So developers switched to non-blocking, reactive code. This approach uses fewer threads and handles more work, but the code becomes harder to follow as we saw in the previous example.
When Should You Use Virtual Threads?
Virtual Threads shine when:
- You need to handle a high number of concurrent tasks (one thread per request in a web server).
- You want clean, readable synchronous style code without reactive complexity.
- You're dealing with tasks that spend most of their time waiting on I/O (like database calls, API requests, or file operations).
Do Virtual Threads Replace WebFlux?
Probably not entirely, but for many use cases, yes.
Reactive programming (like Spring WebFlux or Reactor) became popular because traditional threads couldn’t handle high loads. But reactive code is harder to write and understand. With virtual threads, you can write simple, blocking code and still scale to thousands of requests.
So if your app is mostly I/O-bound—like calling APIs, querying databases, or reading files, virtual threads can let you drop the complexity of reactive programming.
Still, if you need fine grained control, backpressure, or advanced streaming, reactive might still make sense.
Do Virtual Threads Eliminate Blocking?
No — virtual threads do not magically make blocking go away. They just make it cheaper and more scalable.
For example, if a virtual thread calls a blocking database operation or waits for a slow file read, that thread is still blocked. The difference is that the JVM can easily pause and resume virtual threads, so it can handle many more of them without exhausting OS resources. But the actual blocking, like waiting for a database response, still happens. To avoid blocking, you can use for example CompletableFuture
:
- Run tasks asynchronously without blocking the main thread.
- Chain operations (thenApply, thenCompose, etc.)
You're not blocking on the result — you react when it's ready:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
// Simulate long-running task
return callApiOrDb();
});
future.thenAccept(result -> {
System.out.println("Got result: " + result);
});
Conclusion
- Virtual threads are lightweight threads managed by the JVM, not the OS.
- They let you write blocking, synchronous code, without the overhead of traditional threads.
- Great for high concurrency, I/O-heavy applications.
- Makes complex reactive code unnecessary in many cases.
- A big step forward in making Java simpler and more powerful.
Top comments (0)