Skip to content

Java Virtual Threads (JEP 444): How They Work, Examples, Pitfalls

Hundreds of hair-thin glowing optical fibers run loosely parallel across the whole image from left to right against violet, none of them merging

Virtual threads are one of the most important innovations in Java in a long time. They were developed in Project Loom and have been included in the JDK since Java 19 as a preview feature and since Java 21 as a final version (JEP 444).

In this article, you will learn:

  • Why do we need virtual threads?
  • What are virtual threads, and how do they work?
  • How do you use virtual threads?
  • How do you create virtual threads, and how many virtual threads can be started?
  • How do you use virtual threads in Jakarta EE, Quarkus, and Spring?
  • What are the advantages of virtual threads?
  • What are virtual threads not, and what are their limitations?

Let’s start with the challenge that led to the development of virtual threads.

Why Do We Need Virtual Threads?

Anyone who has ever maintained a backend application under heavy load knows that threads are often the bottleneck. For every incoming request, a thread is needed to process the request. One Java thread corresponds to one operating system thread, and these are resource-hungry:

  • An OS thread reserves 1 MB for the stack and commits 32 or 64 KB of it upfront, depending on the operating system.
  • It takes about 1 ms to start an OS thread.
  • Context switches take place in kernel space and are quite CPU-intensive.

You should not start more than a few thousand; otherwise, you risk the stability of the entire system.

However, a few thousand are not always enough – especially if it takes longer to process a request because of the need to wait for blocking data structures, such as queues, locks, or external services like databases, microservices, or cloud APIs.

For example, if a request takes two seconds and we limit the thread pool to 1,000 threads, then a maximum of 500 requests per second could be answered. However, the CPU would be far from fully utilized since it would spend most of its time waiting for responses from the external services, even if several threads are served per CPU core.

So far, we have only been able to overcome this problem with asynchronous programming – for example, with CompletableFuture or reactive frameworks like RxJava and Project Reactor.

However, anyone who has had to maintain code like the following knows that asynchronous code is many times more complex than sequential code – and absolutely no fun.

public CompletionStage<Response> getProduct(String productId) {
  return productService
      .getProductAsync(productId)
      .thenCompose(
        product -> {
          if (product.isEmpty()) {
            return CompletableFuture.completedFuture(
                Response.status(Status.NOT_FOUND).build());
          }

          return warehouseService
              .isAvailableAsync(productId)
              .thenCompose(
                  available ->
                      available
                          ? CompletableFuture.completedFuture(0)
                          : supplierService.getDeliveryTimeAsync(
                              product.get().supplier(), productId))
              .thenApply(
                  daysUntilShippable ->
                      Response.ok(
                          new ProductPageResponse(
                              product.get(), daysUntilShippable))
                          .build());
          });
}

Not only is this code hard to read and maintain, but it is also extremely difficult to debug. For example, it would make no sense to set a breakpoint here because the code only defines the asynchronous flow but does not execute it. The business code will be executed in a separate thread pool at a later time.

In addition, the database drivers and drivers for other external services must also support the asynchronous, non-blocking model.

What Are Virtual Threads?

Virtual threads solve the problem in a way that again allows us to write easily readable and maintainable code. Virtual threads feel like normal threads from a Java code perspective, but they are not mapped 1:1 to operating system threads.

Instead, there is a pool of so-called carrier threads onto which a virtual thread is temporarily mapped (“mounted”). As soon as the virtual thread encounters a blocking operation, the virtual thread is removed (“unmounted”) from the carrier thread, and the carrier thread can execute another virtual thread (a new one or a previously blocked one).

The following figure depicts this M:N mapping from virtual threads to carrier threads and thus to operating system threads:

Mapping from virtual threads to carrier threads to operating system threads
Mapping from virtual threads to carrier threads to operating system threads

The carrier thread pool is a ForkJoinPool – that is, a pool where each thread has its own queue and “steals” tasks from other threads’ queues should its own queue be empty. Its size is set by default to Runtime.getRuntime().availableProcessors() and can be adjusted with the VM option jdk.virtualThreadScheduler.parallelism. Since Java 24, you can also observe the scheduler via jdk.management.VirtualThreadSchedulerMXBean – for example, how many virtual threads are currently mounted or waiting for a carrier thread – and change its parallelism at runtime.

Over the course of time, the CPU activity of three tasks, for example, each executing code four times and blocking three times for a relatively long period in between, could be mapped to a single carrier thread as follows:

Mapping three virtual threads to one carrier thread
Mapping three virtual threads to one carrier thread

Blocking operations thus no longer block the executing carrier thread, and we can process a large number of requests concurrently using a small pool of carrier threads.

We could then implement the example use case from above quite simply like this:

public ProductPageResponse getProduct(String productId) {
  Product product = productService.getProduct(productId)
      .orElseThrow(NotFoundException::new);

  boolean available = warehouseService.isAvailable(productId);

  int shipsInDays =
    available ? 0 : supplierService.getDeliveryTime(product.supplier(), productId);

  return new ProductPageResponse(product, shipsInDays);
}

This code is not only easier to write and read but also – like any sequential code – to debug by conventional means.

If your code already looks like this – i.e., you never switched to asynchronous programming – then I have good news: you can continue to use your code unchanged with virtual threads.

Virtual Threads – Example

We can also demonstrate the power of virtual threads without a backend framework. To do this, we simulate a scenario similar to the one described above: we start 1,000 tasks, each of which waits one second (to simulate access to an external API) and then returns a result (a random number in the example).

First, we implement the task:

public class Task implements Callable<Integer> {

  private final int number;

  public Task(int number) {
    this.number = number;
  }

  @Override
  public Integer call() {
    System.out.printf("Thread %s - Task %d waiting...%n",
        Thread.currentThread().getName(), number);

    try {
      Thread.sleep(1000);
    } catch (InterruptedException e) {
      System.out.printf("Thread %s - Task %d canceled.%n",
          Thread.currentThread().getName(), number);
      return -1;
    }

    System.out.printf("Thread %s - Task %d finished.%n",
        Thread.currentThread().getName(), number);
    return ThreadLocalRandom.current().nextInt(100);
  }
}

Now we measure how long it takes a pool of 100 platform threads (which is how non-virtual threads are referred to) to process all 1,000 tasks:

try (ExecutorService executor = Executors.newFixedThreadPool(100)) {
  List<Task> tasks = new ArrayList<>();
  for (int i = 0; i < 1_000; i++) {
    tasks.add(new Task(i));
  }

  long time = System.currentTimeMillis();

  List<Future<Integer>> futures = executor.invokeAll(tasks);

  long sum = 0;
  for (Future<Integer> future : futures) {
    sum += future.get();
  }

  time = System.currentTimeMillis() - time;

  System.out.println("sum = " + sum + "; time = " + time + " ms");
}

The program runs for a little over 10 seconds. That was to be expected:

1,000 tasks divided by 100 threads = 10 tasks per thread

Each platform thread had to process ten tasks sequentially, each lasting about one second.

Next, we test the whole thing with virtual threads. Therefore, we only need to replace the statement

Executors.newFixedThreadPool(100)

with:

Executors.newVirtualThreadPerTaskExecutor()

This executor does not use a thread pool but creates a new virtual thread for each task.

After that, the program no longer needs 10 seconds but only just over one second. It can hardly be faster because every task waits one second.

Impressive: even 10,000 tasks can be processed by our little program in just over a second.

Only at 100,000 tasks does the throughput drop noticeably: my laptop needs about four seconds for this – which is still blazingly fast compared to the thread pool, which would need almost 17 minutes.

How Do You Create Virtual Threads?

We have already learned about one way to create virtual threads: An ExecutorService that we create with Executors.newVirtualThreadPerTaskExecutor() creates one new virtual thread per task.

Using Thread.startVirtualThread() or Thread.ofVirtual().start(), we can also explicitly start virtual threads:

Thread.startVirtualThread(() -> {
  // code to run in thread
});

Thread.ofVirtual().start(() -> {
  // code to run in thread
});

With the second variant, Thread.ofVirtual() returns a Thread.Builder.OfVirtual whose start() method starts a virtual thread. The alternative method Thread.ofPlatform() returns a Thread.Builder.OfPlatform, which you can use to start a platform thread.

Both are subinterfaces of Thread.Builder. This lets you write flexible code that decides only at runtime whether it should run in a virtual or a platform thread:

Thread.Builder threadBuilder = createThreadBuilder();
threadBuilder.start(() -> {
  // code to run in thread
});

By the way, you can find out if code is running in a virtual thread with Thread.currentThread().isVirtual().

How Many Virtual Threads Can Be Started?

In this GitHub repository you can find several demo programs that demonstrate the capabilities of virtual threads.

With the class HowManyVirtualThreadsDoingSomething you can test how many virtual threads you can run on your system. The application starts more and more threads and performs Thread.sleep() operations in these threads in an infinite loop to simulate waiting for a response from a database or an external API. Try to give the program as much heap memory as possible with the VM option -Xmx.

On my 64 GB machine, 20,000,000 virtual threads could be started without any problems – and with a little patience, even 30,000,000. From then on, the garbage collector tried to perform full GCs non-stop – because the stack of virtual threads is “parked” on the heap, in so-called StackChunk objects, as soon as a virtual thread blocks. Shortly after, the application terminated with an OutOfMemoryError.

With the class HowManyPlatformThreadsDoingSomething you can also test how many platform threads your system supports. But be warned: Most of the time the program ends with an OutOfMemoryError at some point (between 80,000 and 90,000 threads for me) – but it can also crash your computer.

How Do You Use Virtual Threads with Jakarta EE?

In a Jakarta EE application, you don’t create threads yourself – you leave that to the container. Since Jakarta EE 11 (Jakarta Concurrency 3.1), you can request that a managed executor or a managed thread factory use virtual threads. To do so, you set the attribute virtual = true, for example on a @ManagedExecutorDefinition:

@ManagedExecutorDefinition(
    name = "java:app/concurrent/virtualExecutor",
    virtual = true)

The same attribute is available on @ManagedScheduledExecutorDefinition and @ManagedThreadFactoryDefinition. You then inject the executor defined this way and run your tasks through it. By default, virtual is set to false – so the container keeps creating platform threads until you explicitly enable virtual threads.

In standard Jakarta EE (as of Jakarta EE 11), however, you can’t place a single REST endpoint directly on a virtual thread via an annotation. That’s exactly what the Quarkus/SmallRye ecosystem offers with the @RunOnVirtualThread annotation – more on that in the next section.

How Do You Use Virtual Threads with Quarkus?

Quarkus offers the @RunOnVirtualThread annotation. It comes from SmallRye Common (io.smallrye.common.annotation.RunOnVirtualThread) and is – as of today – not part of the Jakarta EE specification. With it, you place a single endpoint on a virtual thread:

@GET
@Path("/product/{productId}")
@RunOnVirtualThread
public ProductPageResponse getProduct(@PathParam("productId") String productId) {
  Product product = productService.getProduct(productId)
      .orElseThrow(NotFoundException::new);

  boolean available = warehouseService.isAvailable(productId);

  int shipsInDays =
    available ? 0 : supplierService.getDeliveryTime(product.supplier(), productId);

  return new ProductPageResponse(product, shipsInDays);
}

You don’t have to change a single character in the method body. A first, experimental version of @RunOnVirtualThread was already part of Quarkus 2.10 in June 2022; Quarkus has officially promoted the annotation since Java 21.

In this GitHub repository you will find a sample Quarkus application with the controller shown above – one with platform threads, one with virtual threads and also an asynchronous variant with CompletableFuture. The README explains how to start the application and how to invoke the three controllers.

How Do You Use Virtual Threads with Spring?

In Spring, the controller would look like this:

@GetMapping("/stage1-seq/product/{productId}")
public ProductPageResponse getProduct(@PathVariable("productId") String productId) {
  Product product = productService
      .getProduct(productId)
      .orElseThrow(() -> new ResponseStatusException(NOT_FOUND));

  boolean available = warehouseService.isAvailable(productId);

  int shipsInDays =
      available ? 0 : supplierService.getDeliveryTime(product.supplier(), productId);

  return new ProductPageResponse(product, shipsInDays);
}

Since Spring Boot 3.2, a single property in application.properties is enough to run all request handlers on virtual threads:

spring.threads.virtual.enabled=true

Spring Boot then configures, among other things, the web server (Tomcat/Jetty) and the task executor so that each request is handled in its own virtual thread.

Note: this makes all controllers run on virtual threads. For most use cases that’s fine – but not for CPU-bound tasks, which should still run on platform threads.

In this GitHub repository you can find a sample Spring application with the controller shown above. The README explains how to start the application and how to switch the controller from platform threads to virtual threads.

Advantages of Virtual Threads

Virtual threads offer impressive advantages:

First, they are inexpensive:

  • They can be created much faster than platform threads: it takes about 1 ms to create a platform thread, and less than 1 µs to create a virtual thread.
  • They require less memory: a platform thread reserves 1 MB for the stack and commits 32 to 64 KB upfront, depending on the operating system. A virtual thread starts with about one KB. However, this is true only for flat call stacks. A call stack the size of half a megabyte requires that half megabyte in both thread variants.
  • Blocking virtual threads is cheap because a blocked virtual thread does not block an OS thread. However, it’s not free as its stack needs to be copied to the heap.
  • Context switches are fast because they are performed in user space, not kernel space, and numerous optimizations have been made in the JVM to make them faster.

Second, we can use virtual threads in a familiar way:

  • Only minimal changes have been made to the Thread and ExecutorService APIs.
  • Instead of writing asynchronous code with callbacks, we can write code in the traditional blocking thread-per-request style.
  • We can debug, observe, and profile virtual threads with existing tools.

What Are Virtual Threads Not?

Virtual threads don’t only have advantages, of course. Let’s first look at what virtual threads are not, and what we cannot or should not do with them:

  • Virtual threads are not faster threads – they cannot execute more CPU instructions than a platform thread in the same amount of time. If a task does not block, it will – due to the overhead of mounting/unmounting – run even slower on a virtual thread than on an existing platform thread from an ExecutorService.
  • They are not preemptive: while a virtual thread is executing a CPU-intensive task, it is not unmounted from the carrier thread. So if you have 20 carrier threads and 20 virtual threads that occupy the CPU without blocking, no other virtual thread will be executed.
  • They do not provide a higher level of abstraction than platform threads. You need to be aware of all the subtle things that you also need to be aware of when using regular threads. That is, if a virtual thread accesses shared data, you have to take care of visibility issues, you have to synchronize atomic operations, and so on.

What Limitations Do Virtual Threads Have?

You should be aware of the following limitations. The picture has eased considerably since their introduction in Java 21, though – some earlier limitations have since been lifted. The following matrix shows, for the five operations this chapter is about, whether a waiting virtual thread blocks its carrier thread – and from which Java version on it no longer does:

Matrix: whether a waiting virtual thread blocks its carrier thread, by operation and Java version – synchronized and Object.wait() yes up to Java 23, no since Java 24; file I/O yes in all versions, compensated by an additional carrier thread up to Java 22; waiting for a class initialization yes up to Java 25, mostly no since Java 26; native code on the call stack yes in all versions
Where a virtual thread still blocks its carrier thread – from Java 21 to Java 27

1. Unsupported Blocking Operations

The vast majority of blocking operations in the JDK have been rewritten to support virtual threads. One relevant exception remains (as of Java 27):

  • File I/O

Here, a waiting virtual thread also blocks its carrier thread. Up to Java 22, the JVM temporarily increased the number of carrier threads in this case – up to a maximum of 256, which you can adjust via the VM option jdk.virtualThreadScheduler.maxPoolSize. Since Java 23, it no longer does so for buffered file I/O (FileInputStream, FileOutputStream, RandomAccessFile, FileChannel) because measurements showed no benefit.

An additional carrier thread is only provided for FileDescriptor.sync(), for force() on memory-mapped files, for direct I/O, for writes to files opened in synchronous mode (rws, rwd, SYNC, DSYNC), for DNS lookups via InetAddress, for System.in, System.out, and System.err, and for the input and output of processes.

There is no JEP for this change; it is tracked in the bug tracker under JDK-8329593.

2. Pinning

Pinning means that a blocking operation that would normally unmount a virtual thread from its carrier thread does not do so, because the virtual thread has been “pinned” to its carrier thread – meaning it isn’t allowed to switch carrier threads.

As of Java 27, pinning only occurs when the call stack contains native frames – because we can’t control what happens inside native code. This affects not only your own JNI calls and the Foreign Function & Memory API but also two places inside the JDK itself: loading a class while resolving a symbolic reference, and blocking inside a class initializer. Inside a synchronized block, on the other hand, a virtual thread no longer pins.

A third case has been largely defused since Java 26: waiting for a class that another thread is currently initializing. Up to Java 25, the virtual thread remained pinned to its carrier thread while waiting – in the worst case until a deadlock, when all carrier threads were waiting for the same class and the initializing thread, in turn, depended on one of those virtual threads. Since Java 26, the virtual thread is unmounted from its carrier thread on the most common paths (invokestatic, new, getstatic, and putstatic in the interpreter).

There is no JEP for this change; it is tracked in the bug tracker under JDK-8369238.

Detecting Pinning

You can detect remaining pinning (e.g. caused by native code) via the JDK Flight Recorder event jdk.VirtualThreadPinned, for example in a JFR recording. The event is recorded when a virtual thread blocks while it is pinned. Since Java 24, it names the reason for the pinning (pinnedReason), the blocking operation, and the carrier thread. In the JFR profiles shipped with the JDK, it is only recorded from a duration of 20 ms – you will only see shorter pinning if you lower the threshold (threshold).

Thread Dumps with Virtual Threads

The conventional thread dumps printed via jcmd <pid> Thread.print do not contain virtual threads. The reason for that is that this command stops the VM to create a snapshot of the running threads. This is feasible for a few hundred or even a few thousand threads, but not for millions of them.

Therefore, a new kind of thread dump has been implemented that does not stop the VM (each thread is captured consistently on its own, but there is no common point in time for all threads) but does include virtual threads in return. This new thread dump can be created with one of these two commands:

  • jcmd <pid> Thread.dump_to_file -format=plain <file>
  • jcmd <pid> Thread.dump_to_file -format=json <file>

The first command generates a thread dump similar to the traditional one, with thread names, IDs and stack traces. The second command generates a file in JSON format that also contains information about thread containers, parent containers, and owner threads.

Lock Information and Deadlocks

As of Java 25, these thread dumps also contain information about locks: the state of each thread, the object monitor a thread is waiting to enter in a synchronized block, and the monitors it currently holds. As of Java 26, the locks from java.util.concurrent are included as well: for a thread waiting on a ReentrantLock, the dump now also shows which thread holds the lock.

There is no JEP for these two changes; they are tracked in the bug tracker under JDK-8356870 and JDK-8365057.

What these thread dumps still don’t show are deadlocks between virtual threads or between a virtual and a platform thread. There is no automatic deadlock detection here as there is with classic thread dumps – you have to track down deadlocks yourself based on the lock information.

When Should You Use Virtual Threads?

You should use virtual threads if you have many tasks to process concurrently that primarily contain blocking operations.

This is true for most server applications. However, if your server application handles CPU-intensive tasks, you should use platform threads for them.

What Else Should You Keep in Mind?

Here are a few tips on using and migrating to virtual threads:

  • Even though many articles about virtual threads would have us believe otherwise, they do not inherently use less memory than a platform thread. This is only the case if the call stack is shallow. With deep call stacks, both types of threads consume the same amount of memory.
  • Virtual threads do not need to be pooled. A pool is used to share expensive resources. Virtual threads, on the other hand, are so cheap that it is better to create one when you need it and let it terminate when you no longer need it.
  • If you need to limit access to a resource, such as how many threads can access a database or API at the same time, use a Semaphore instead of a thread pool.
  • Much of the virtual thread code is written in Java. Accordingly, you must warm up the JVM before running performance tests so that all bytecode is compiled and optimized before the measurement begins.

Summary

Virtual threads deliver what they promise: they allow us to write readable and maintainable sequential code that does not block operating system threads when waiting for locks, blocking data structures, or responses from the file system or external services.

Virtual threads can be created in the order of millions.

Common backend frameworks such as Spring and Quarkus can already handle virtual threads. Nevertheless, you should test applications thoroughly when you flip the switch to virtual threads. Make sure that you do not, for example, execute CPU-intensive computing tasks on them, that they are not pooled by the framework, and that no ThreadLocals are stored in them (see also Scoped Values).

I hope you’re as excited as I am and can’t wait to use virtual threads in your projects!

If this article has helped you, I would greatly appreciate a positive review on my ProvenExpert profile. Your feedback helps me improve my content and motivates me to write new informative articles.

👉 Leave a review

This subject in your own code?

You have read the article – in the training your team works with it. Over one day we go through the subjects on your own projects instead of constructed examples.

Hands-on, easy to understand, and directly applicable to your day-to-day project work. Instead of theory, I teach principles that help you write code that is better, more maintainable, and more performant in the long run.

Virtual Threads & Structured ConcurrencySee all trainings

Become a Better Java Developer

My free newsletter keeps you ahead. Modern Java: new versions & features, performance, and JVM insights – once a month.

Search