← Back to Multithreading Course

Class 42: Virtual Threads in Java 21+

Part 7 — Asynchronous Programming and Modern Java

We have now explored several ways of working with multiple threads in Java.

We learned how to create threads, manage thread pools, use ExecutorService, work with CompletableFuture, and process collections using parallel streams.

Today, we will learn about another important feature introduced in modern Java: Virtual Threads.

Virtual threads became a standard feature in Java 21. They make it much easier to build applications that handle a large number of concurrent tasks.

1. First, Understand the Problem with Traditional Threads

Whenever we create a traditional Java thread, it is backed by an operating system thread.

These are commonly called platform threads.

Creating and managing operating system threads requires resources.

Imagine that we are building an application that handles thousands of customer requests.

Each request might involve:

During these operations, a thread may spend a lot of time waiting for a response.

If we create one traditional thread for every request, we may end up managing thousands of platform threads.

This can consume significant system resources.

Thread pools help us control the number of platform threads, but they also introduce another consideration: how many tasks can execute at the same time?

Virtual threads provide another approach.

2. What Are Virtual Threads?

A virtual thread is a lightweight thread managed by the Java runtime rather than being directly tied to one operating system thread for its entire life.

Java schedules virtual threads onto platform threads. These platform threads are often called carrier threads.

Let's understand this with a simple analogy.

Imagine that we have a large number of customers waiting to be served.

Instead of assigning one dedicated employee to every customer, we have a smaller group of employees who serve customers as needed.

When one customer is waiting for something, the employee can move on to another customer.

Similarly, Java can run many virtual threads using a much smaller number of platform threads.

A simplified view:

          Java Application
                 |
       +---------+---------+
       |         |         |
       v         v         v
   Virtual    Virtual    Virtual
   Thread 1   Thread 2   Thread 3
       |         |         |
       +---------+---------+
                 |
                 v
          Java Scheduler
                 |
       +---------+---------+
       |         |         |
       v         v         v
   Carrier    Carrier    Carrier
   Thread 1   Thread 2   Thread 3
                 |
                 v
       Operating System Threads

This diagram is simplified. The number of virtual threads can be much larger than the number of carrier threads.

3. Creating a Virtual Thread

Creating a virtual thread is very simple.

Java provides Thread.startVirtualThread() for this purpose.

public class VirtualThreadExample {

    public static void main(String[] args) {

        Thread thread = Thread.startVirtualThread(() -> {

            System.out.println(
                "Hello from a virtual thread!"
            );

        });

    }
}

Let's understand the important part:

Thread.startVirtualThread(() -> {

    System.out.println("Hello from a virtual thread!");

});

We provide a task using a lambda expression.

Java creates and starts a virtual thread to execute that task.

Notice that we do not need to create a traditional Thread object and configure it ourselves.

4. Creating Virtual Threads Using a Builder

Java also provides a virtual thread builder.

public class VirtualThreadBuilderExample {

    public static void main(String[] args) {

        Thread thread = Thread.ofVirtual()
                .name("my-virtual-thread")
                .start(() -> {

                    System.out.println(
                        "Thread Name: "
                        + Thread.currentThread().getName()
                    );

                });

    }
}

Let's understand the methods:

We can also create a virtual thread without starting it immediately.

Thread thread = Thread.ofVirtual()
        .name("my-thread")
        .unstarted(() -> {

            System.out.println("Task is running");

        });

thread.start();

Here, unstarted() creates the virtual thread, but it does not start execution.

We start it later by calling start().

5. Creating Multiple Virtual Threads

One of the biggest advantages of virtual threads is that we can create a very large number of them without creating an equally large number of operating system threads.

Let's create multiple virtual threads.

public class MultipleVirtualThreadsExample {

    public static void main(String[] args)
            throws InterruptedException {

        for (int i = 1; i <= 5; i++) {

            int taskNumber = i;

            Thread.startVirtualThread(() -> {

                System.out.println(
                    "Task " + taskNumber
                    + " running on "
                    + Thread.currentThread()
                            .getName()
                );

            });
        }

        Thread.sleep(1000);
    }
}

Here, we create five virtual threads.

Each thread executes its own task.

The short sleep in main() gives the tasks time to finish in this simple demonstration.

However, this is not the best way to coordinate tasks in a real application.

We will see a better approach in the next example.

6. Managing Virtual Threads with an ExecutorService

Remember how we used ExecutorService to manage traditional thread pools?

Java also provides an easy way to execute tasks using virtual threads through an ExecutorService.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class VirtualThreadExecutorExample {

    public static void main(String[] args) {

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

            for (int i = 1; i <= 5; i++) {

                int taskNumber = i;

                executor.submit(() -> {

                    System.out.println(
                        "Executing task " + taskNumber
                        + " on "
                        + Thread.currentThread().getName()
                    );

                });
            }

        }
    }
}

Let's understand the important method:

Executors.newVirtualThreadPerTaskExecutor()

It creates an ExecutorService that starts a new virtual thread for each submitted task.

Notice the difference from a fixed thread pool.

We also used try-with-resources.

When execution leaves the try block, the ExecutorService is closed. Its close() method initiates an orderly shutdown and waits for submitted tasks to finish.

This is a convenient way to ensure that our tasks complete before the program moves on.

7. Platform Threads vs. Virtual Threads

Let's compare the two types of threads.

Platform Threads Virtual Threads
Backed by operating system threads. Managed by the Java runtime and scheduled onto platform threads.
More expensive to create in large numbers. Much cheaper to create in large numbers.
Suitable for many kinds of CPU and blocking workloads. Especially useful for applications with many concurrent tasks that spend time waiting.
Limited by operating system resources. Can support very large numbers of concurrent tasks, subject to available resources.

Virtual threads are not a replacement for every traditional thread.

They are another tool that helps us choose the right approach for our application.

8. Virtual Threads Are Especially Useful for Blocking Operations

Let's understand where virtual threads can help us.

Imagine an application that receives thousands of requests from customers.

Each request might need to call a database or another service.

These operations often involve waiting for a response.

With traditional threads, a platform thread may remain occupied while the operation waits.

With virtual threads, Java can often unmount the virtual thread from its carrier while it waits.

The carrier thread can then execute another virtual thread.

When the waiting operation is ready to continue, the virtual thread can be scheduled again.

This makes virtual threads particularly useful for applications that handle many concurrent blocking tasks.

9. Important: Virtual Threads Do Not Make CPU Work Faster

This is a very important distinction.

Imagine that we have a task that performs a large mathematical calculation.

The task is continuously using the CPU and does not spend much time waiting.

Creating thousands of virtual threads will not automatically make this calculation faster.

The available CPU cores still determine how much CPU work can execute simultaneously.

Virtual threads are particularly helpful when tasks spend significant time waiting, rather than continuously performing CPU calculations.

For CPU-intensive work, using an appropriately sized platform thread pool is often a better choice.

10. Do Not Pool Virtual Threads

With traditional platform threads, we commonly use thread pools to reuse a limited number of threads.

Virtual threads are designed to be created for individual tasks and then finished.

Therefore, the usual recommendation is not to create a pool of reusable virtual threads.

Instead, use:

Executors.newVirtualThreadPerTaskExecutor()

If we need to limit access to a resource, such as a database connection or an external service, we should control access to that resource separately.

For example, a Semaphore can limit how many tasks access a resource at the same time.

11. A Note About Pinning

There is one additional concept worth knowing.

Sometimes, a virtual thread cannot unmount from its carrier thread while performing certain operations.

This situation is called pinning.

For example, in Java 21, blocking while holding a synchronized monitor can pin a virtual thread to its carrier.

This can reduce the scalability benefits of virtual threads in applications that frequently perform blocking operations inside synchronized sections.

This does not mean that synchronized should never be used with virtual threads.

It simply means that we should avoid unnecessarily holding monitors while performing blocking operations and test our application under realistic workloads.

12. Quick Revision

Test Your Understanding

Let's see how well you understood this class!

Question 1: In which Java version did virtual threads become a standard feature?

Question 2: What are carrier threads?

Question 3: Which method creates and starts a virtual thread directly?

Question 4: Which type of workload benefits particularly from virtual threads?

Question 5: What is the usual recommendation when managing virtual threads?

Class Summary

Let's quickly revise what we learned today.

  • Virtual threads are lightweight threads introduced as a standard feature in Java 21.
  • They are managed by the Java runtime and scheduled onto platform threads.
  • Platform threads that execute virtual threads are called carrier threads.
  • We can create virtual threads using Thread.startVirtualThread() or Thread.ofVirtual().
  • ExecutorService can manage virtual threads using newVirtualThreadPerTaskExecutor().
  • Virtual threads are particularly useful for applications with many concurrent blocking operations.
  • They do not increase the number of available CPU cores or automatically speed up CPU-intensive tasks.
  • Virtual threads should generally not be pooled.
  • Pinning can limit the scalability benefits of virtual threads in certain situations.

Remember: Virtual threads make it easier to handle a large number of concurrent tasks. They are especially valuable when those tasks spend time waiting for external operations.

Congratulations! You Have Completed Part 7

We have completed Part 7 — Asynchronous Programming and Modern Java.

We started with CompletableFuture, learned how to chain and combine asynchronous operations, explored exception handling, understood parallel streams and the Fork/Join Framework, and finally learned about virtual threads.

You now have a much broader understanding of the different tools Java provides for concurrent and asynchronous programming.

Coming Up: Part 8 — Reliability, Debugging and Real-World Applications

In the final part of this course, we will focus on problems that occur in real multithreaded applications.

We will understand deadlocks, starvation and livelocks, explore the Java Memory Model and happens-before relationships, and learn how to debug and test multithreaded applications.

Finally, we will bring everything together by building a real-world multithreaded Java application.

Let's move towards the final part of our journey!

0% Completed

You have completed 42 out of 46 classes.