# Modern Java Features: Virtual Threads in Java 21

Let’s learn this new feature starting with a simple questionnaire that can offer answers to important questions that may come up as we dive into it.

**What is a Virtual Thread?**

Virtual threads are lightweight threads that are not tied to the OS or hardware but managed by the JVM directly. They are suitable for running tasks that spend most of the time blocked, often waiting for I/O operations to complete. However, they aren't intended for long-running CPU-intensive operations.

Here is a quick example of how you would create and run a virtual thread:

```java
Thread thread = Thread.ofVirtual().start(() -> System.out.println("Hello"));
thread.join();
```

The above example creates and starts a virtual thread that prints a message. It calls the join method to wait for the virtual thread to terminate. (This way we can see the printed message before the main thread terminates.)

As you can see virtual threads are an implementation of java.lang.Thread and conform to the same rules that specified `java.lang.Thread` since Java SE 1.0, developers don't need to learn new concepts to use them. Threads that are not virtual keep working as before but they are now called ***Platform Threads***. In contrast to their virtual counterpart, a *Platform Thread* is a thin wrapper around an operating system (OS) thread and it runs Java code on its underlying OS thread. Moreover, the platform thread is tied to the OS thread that it is wrapping for the entire lifetime.

Here is the basic example of a platform thread passing the Runnable as a lambda expression:

```java
Thread t = new Thread(() -> System.out.println("Hello"));
t.start(); // 🚀 starts the thread (calls `run()` internally)
```

**When should we use virtual Threads?**

Use virtual threads in high-throughput concurrent applications, especially those that execute a large number of concurrent tasks that spend much of their time waiting. Server applications are examples of high-throughput applications because they typically handle many client requests that perform blocking I/O operations such as fetching resources. In these types of servers, their high number gives virtual threads their power: they can run server applications written in the thread-per-request style more efficiently by allowing the server to process many more requests concurrently, leading to higher throughput and less waste of hardware.

**Are Virtual Threads faster than Platform Threads?**

Virtual threads are not faster threads; they do not run code any faster than platform threads. They exist to provide scale (higher throughput), not speed (lower latency).

**Since virtual threads are so promising for high-throughput, are they already being used by HTTP servers like Tomcat?**

By default, servers like **Tomcat** (and others like Jetty, and Undertow) do **not** use virtual threads yet but they still use **platform threads** in their thread pools by default — even in the latest versions but you **can configure** them to use virtual threads in newer Java versions. Since **Java 21 (LTS)**, virtual threads are **stable**, and now some frameworks and servers **support or plan to support** them — or you can **opt-in** manually.

You can see an example of how to do it for Spring on my [Blog Posts App](https://github.com/mdjc/blog-posts-app/commit/a26bbe869dc007cb68443e084655e289c573765b).

**What is the fluent API via** `Thread.Builder`**?**

It’s a **modern, clean way** to manage threads (especially, virtual ones). Let’s illustrate it with a quick example: in the code below we create and start two virtual threads using Thread.Builder:

```java
public class VirtualThreadsDemo {
  public static void main(String[] args) throws InterruptedException {
    Thread.Builder builder = Thread.ofVirtual().name("thread-", 0);
    Runnable task = () -> System.out.println("Thread ID: " + Thread.currentThread().threadId());

    // name "thread-0"
    Thread t1 = builder.start(task);
    t1.join();
    System.out.println(t1.getName() + " terminated");

    // name "thread-1"
    Thread t2 = builder.start(task); //notice you can reuse the builder to start another thread
    t2.join();
    System.out.println(t2.getName() + " terminated");
  }
}
```

This example prints output similar to the following:

```bash
Thread ID: 21
thread-0 terminated
Thread ID: 24
thread-1 terminated
```

Here’s what’s going on:

* `Thread.ofVirtual()` returns a **builder**.
    
* `.start(Runnable)` is a **convenient method** on the builder that:
    
    1. Creates a virtual thread
        
    2. Starts it immediately
        
    3. Returns the thread instance
        

It’s the same `Thread` class, just a **more modern API for virtual threads**:

* `Thread.Builder` is **reusable**
    
* Each `.start(task)` creates a **new thread**
    
* `.name("prefix-", startIndex)` gives you auto-numbered thread names
    

## **Virtual Threads vs. Platform Threads**

Recapping, the main difference is that a [virtual thread doesn’t rely](https://www.baeldung.com/java-virtual-thread-vs-thread) on the OS thread during its life cycle. **Virtual threads are decoupled from the hardware**, hence the word “virtual.” Another important difference is we can easily create millions of virtual threads in the same process, but that’s not the case with Platform Threads.

Now that we know more about these threads and have seen a few examples, let’s contrast Virtual Threads and Platform Threads side to side:

| Feature | Platform Threads | Virtual Threads |
| --- | --- | --- |
| **Introduced In** | Java 1.0 | Java 19 (preview), Java 21 (stable) |
| **Backed By** | OS threads | Managed by JVM (user-mode, not OS threads) |
| **Memory per Thread** | ~1MB stack by default | Much smaller; stack is on heap, grows as needed |
| **Max Threads (Typical JVM)** | Thousands (limited by OS resources) | Millions (limited by heap & CPU) |
| **Context Switching** | Expensive (done by OS) | Lightweight (done by JVM) |
| **Blocking Calls** | Ties up an OS thread | JVM parks the virtual thread, no OS block |
| **Scalability** | Limited | Extremely high |
| **Best For** | Long-lived tasks, CPU-intensive workloads, interactions with native code | High-concurrency, blocking I/O or short-lived tasks |
| **Works With Legacy Code** | Yes | Yes — same APIs (`Runnable`, `Callable`, etc.) |
| **Requires Learning Reactive** | No | No |
| **Debugging & Stack Traces** | Straightforward | Straightforward |
| **Thread Pools** | Essential to control resource use | Not recommended — use one per task |
| **APIs** | `new Thread(...)`, `Executors.newFixed...` | `Thread.ofVirtual()`, `Executors.newVirtual...` |

### Some basic guidelines for adopting Virtual Threads

The following guidelines are a starting point for this new paradigm of Virtual Threads (as it differs from what you would typically do with Platform Threads):

* **Write synchronous code with Blocking I/O APIs**: Blocking a platform thread is expensive because it holds onto the thread—a relatively scarce resource—while it is not doing much meaningful work. On the other hand, virtual threads can be plentiful, blocking them is cheap and encouraged. Therefore, you should write code in the straightforward synchronous style and use blocking I/O API (avoid using async).
    
* **Represent Every Concurrent Task as a Virtual Thread and Never Pool Virtual Threads**: Platform threads are scarce, and are therefore a precious resource. Precious resources need to be managed, and the most common way to manage platform threads is with thread pools. But virtual threads are plentiful, and so each should represent a task not a shared resource.
    
* **Use Semaphores to Limit Concurrency**: When using virtual threads, if you want to limit the concurrency of accessing some service, you should use a construct designed for that purpose: the [Semaphore](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/Semaphore.html) [class.](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/Semaphore.html)
    
* **Don't Cache Expensive Reusable Objects in Thread-Local Variables**: Avoid using ThreadLocal to cache expensive objects with virtual threads—they aren't reused, so caching increases memory use instead of saving it. Use shared, immutable alternatives like DateTimeFormatter, and prefer [ScopedValue](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/ScopedValue.html) for context data.
    

## Conclusion

Virtual threads are a lightweight and scalable way to handle concurrency in Java. They’re easy to adopt because they use the same `Thread` class we already know, and the new fluent API makes working with them clean and intuitive. If your application handles many blocking tasks, virtual threads can help you scale without changing your code to reactive or asynchronous styles.

Do you want to learn more? I recommend the following resources:

* [Baeldung: Java 21 New Features](https://www.baeldung.com/java-lts-21-new-features)
    
* [Oracle Docs for Java 21 - Virtual Threads](https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html)
    
* [Baeldung: Spring 6 and Virtual Threads](https://www.baeldung.com/spring-6-virtual-threads)
