Java 21 Virtual Threads Explained

Java 21 introduced Virtual Threads, one of the most impactful JVM features in recent years. Virtual threads fundamentally change how we think about concurrency and scalability in Java.

Before diving into code, let’s first understand why virtual threads exist.


Platform Threads vs Virtual Threads

Platform Threads

Platform threads are what Java has traditionally used.

  • A platform thread maps 1:1 to an OS thread
  • When a platform thread is created, an OS thread is also created
  • That OS thread is occupied for the entire lifetime of the Java thread
  • Number of platform threads = number of OS threads

Because OS threads are expensive, we must carefully manage them using thread pools.


Virtual Threads

Virtual threads work very differently.

  • A virtual thread is not tied to a single OS thread
  • When a virtual thread performs a blocking I/O operation, the JVM:
    • Suspends the virtual thread
    • Reuses the OS thread to run other virtual threads
  • Once the blocking operation completes, the virtual thread is resumed

This behavior is fully managed by the JVM, not the operating system.

👉 A good analogy:
Virtual threads are to threads what virtual memory is to RAM.


Lightweight by Design

Virtual threads are intentionally designed to be:

  • Lightweight
  • Cheap to create
  • Optimized for I/O-heavy workloads

They are not meant to run long, CPU-intensive computations.


Virtual Threads Are About Scale, Not Speed

A very important point to understand:

Virtual threads are not faster threads.

They do not improve raw performance.

Instead, they provide:

  • Massive scalability
  • High throughput
  • Simpler concurrency models (no callback hell, no reactive complexity)

Creating a Virtual Thread

Creating a virtual thread is straightforward.

Thread.Builder builder = Thread.ofVirtual().name("Virtual Thread");

Runnable runnable = () -> {
    System.out.println("Running runnable thread");
};

Thread t = builder.start(runnable);

System.out.println("Thread name: " + t.getName());
t.join();

No special configuration. No custom schedulers. The JVM handles everything.


Virtual Threads with ExecutorService

Java 21 introduced a new executor factory method:

Executors.newVirtualThreadPerTaskExecutor()


Example:

try (ExecutorService myExecutor =
         Executors.newVirtualThreadPerTaskExecutor()) {

    Future<?> future = myExecutor.submit(() ->
        System.out.println("Executor Service thread running")
    );

    future.get();
    System.out.println("Main thread running");
}

Key idea:

  • Each submitted task gets its own virtual thread
  • No pooling required

Why Virtual Threads Are Powerful

  • Virtual threads are implemented by the JVM, not the OS
  • We can create millions of virtual threads
  • Thread creation is no longer a scalability bottleneck

This is the real power of virtual threads.


Should We Create Virtual Thread Pools?

Short answer: No.

Why thread pools exist:

  • Platform threads are resource-intensive
  • OS threads must be reused carefully

Virtual threads change this equation.

  • Virtual threads are plentiful
  • Creating a pool for them defeats their purpose

That’s why:

Executors.newVirtualThreadPerTaskExecutor()

creates a new virtual thread for every task.


When Do Virtual Threads Actually Help?

According to Oracle’s documentation:

If your application is not creating at least thousands (10,000+) virtual threads,
you are probably not benefiting from virtual threads.

They shine in:

  • High-concurrency
  • I/O-bound systems (APIs, DB calls, messaging)

Limiting Concurrency with Virtual Threads

Even though virtual threads are cheap, external systems are not.

To limit concurrent access (DB, APIs, downstream services), use a Semaphore.

Semaphore semaphore = new Semaphore(10);

semaphore.acquire();
try {
    // call virtual-thread-based service
} finally {
    semaphore.release();
}

This gives you:

  • Unlimited virtual threads
  • Controlled concurrency

Best of both worlds.


ThreadLocals and Virtual Threads

Yes, virtual threads support ThreadLocal variables.

If you are already storing request context or metadata in ThreadLocal, it will continue to work.

However, for safer and more efficient context propagation, consider using:

  • Scoped Values (introduced in newer Java versions)

They integrate much better with modern concurrency models.


Final Thoughts

Virtual threads simplify concurrency in Java dramatically:

  • Write blocking code
  • Scale like non-blocking systems
  • No reactive complexity
  • No thread pool tuning nightmares

In many ways, virtual threads bring Java back to what it does best:
simple, readable, and scalable code.

Leave a comment