virtual threads javavirtual threads java
HappyCoders Glasses

Virtual Threads in Java Deep Dive with Examples

Sven Woltmann
Sven Woltmann
Last update: June 12, 2026

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 Spring and Jakarta EE?
  • 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());
               });
}Code language: Java (java)

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.

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);
}Code language: Java (java)

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);
    }
}Code language: Java (java)

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");
}Code language: Java (java)

ExecutorService has been auto-closeable since Java 19, i.e. it can be surrounded with a try-with-resources block. At the end of the block, ExecutorService.close() is called, which in turn calls shutdown() and awaitTermination() – and possibly shutdownNow() should the thread be interrupted during awaitTermination().

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)Code language: Java (java)

with:

Executors.newVirtualThreadPerTaskExecutor()Code language: Java (java)

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 executor service 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
});Code language: Java (java)

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
});Code language: Java (java)

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)Code language: Java (java)

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.

Outlook: Jakarta EE 12

With Jakarta EE 12, the @RunOnVirtualThread annotation is set to become part of the standard. The release is delayed, though: originally planned for mid-2026, the platform team is now – according to Ivar Grimstad (Jakarta EE Developer Advocate) – targeting the Jakarta EE Core Profile for Q4 2026 and the Web Profile and Platform for Q1/Q2 2027.

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);
}Code language: Java (java)

You don't have to change a single character in the method body. Quarkus has supported @RunOnVirtualThread since version 2.10 – that is, since June 2022.

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);
}Code language: Java (java)

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=trueCode language: Properties (properties)

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.

Manual Configuration (Before Spring Boot 3.2)

Before Spring Boot 3.2 – or if you want finer control over the behavior – you configure virtual threads via two beans:

@Bean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)
public AsyncTaskExecutor asyncTaskExecutor() {
    return new TaskExecutorAdapter(
        Executors.newVirtualThreadPerTaskExecutor());
}

@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
    return protocolHandler -> {
        protocolHandler.setExecutor(
            Executors.newVirtualThreadPerTaskExecutor());
    };
}

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.

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 26):

  • File I/O

Here, a blocked virtual thread will also block the carrier thread. To compensate, the JVM temporarily increases the number of carrier threads – up to a maximum of 256, which you can adjust via the VM option jdk.virtualThreadScheduler.maxPoolSize. A solution (based on io_uring, among other things) is in the works.

Behavior in Earlier Java Versions

Up to and including Java 23, Object.wait() also did not unmount a virtual thread from its carrier thread, so the carrier blocked as well. As of Java 24 (JEP 491), this is fixed – Object.wait() now releases the carrier thread.

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 26, pinning only occurs when the call stack contains calls to native code – because we can't control what happens inside native code. Inside a synchronized block, on the other hand, a virtual thread no longer pins.

Behavior in Earlier Java Versions

Up to Java 23, a synchronized block also pinned the virtual thread. There were two reasons for this:

Reason 1: Pointers to memory addresses on the stack. With so-called “legacy stack locking” (the default locking mechanism up to and including Java 22), the so-called mark word in the object header pointed to a separate lock data structure on the stack. When the stack is parked on the heap during unmounting and moved back onto the stack during mounting, it could end up at a different memory address – which would invalidate these pointers. As of Java 23, the new “lightweight locking” became the default locking mechanism, which works without pointers to the stack.

Reason 2: Tracking the platform thread. When using synchronized, the JVM tracked which platform thread currently held which object monitor. If a virtual thread entered a synchronized block, blocked, and was unmounted from the carrier thread, and another virtual thread was then mounted onto that carrier thread, that other virtual thread could have entered the synchronized block as well. JEP 491 changed this mechanism in Java 24: since then, a virtual thread can hold and release monitors independently of its carrier thread.

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.

Behavior in Earlier Java Versions

Up to Java 23, you could track down pinning with the VM option -Djdk.tracePinnedThreads=full/short, which printed a full or shortened stack trace, and you could replace a synchronized block around a blocking operation with a ReentrantLock to avoid pinning. Neither is necessary anymore as of Java 24: synchronized no longer pins, and the -Djdk.tracePinnedThreads option was removed with JEP 491.

3. No Deadlock Detection in Thread Dumps

As of Java 25, the new thread dumps (via jcmd <pid> Thread.dump_to_file, see the next section) also contain information about locks held by virtual threads or that they are blocked on – for example the object monitor a thread is waiting to enter.

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.

Behavior in Earlier Java Versions

Up to Java 24, thread dumps contained no data at all about locks held by virtual threads or that they are blocked on. Only as of Java 25 is this lock information available in the thread dump.

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 (accordingly, the thread dump may not be internally consistent) 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.

When Should You Use Virtual Threads?

You should use virtual threads if you have many tasks to be processed concurrently, which 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 you still have questions, please ask them via the comment function.