What are Virtual Threads? (And How to Use Them in Spring Boot)


Have you heard about virtual threads? Introduced as a final feature in Java 21, they have the potential to massively improve your application’s performance, especially for I/O-bound tasks.

Compared to traditional platform threads, virtual threads offer a lightweight and scalable approach to concurrency.

In fact, the performance gains can be dramatic. In our own tests, some requests responded twice as fast after switching to virtual threads.

This post will explore what virtual threads are, how they differ from the traditional model, and how you can significantly speed up your Spring Boot application by changing just one line of code to enable them.

To appreciate the benefits of virtual threads, it’s essential to first understand how traditional threads work and where their limitations lie.

The Problem with Traditional “Platform” Threads

A traditional thread in Java is essentially a thin wrapper around an operating system (OS) level thread. This means every time you create a Thread in your code, the Java Virtual Machine (JVM) requests the underlying OS to create a real thread.

Creating an OS thread is a relatively expensive operation because:

  • Memory Allocation: The OS must allocate a significant block of memory on the stack for the thread.
  • Context Switching: The OS kernel manages scheduling and context switching between different threads, which introduces overhead.

This model works fine for a small number of threads. However, modern web applications often need to handle a high degree of concurrency. In the traditional “one-thread-per-request” model used by web servers, each incoming HTTP request is handled by a dedicated thread. Under heavy load, the cost of creating and managing thousands of OS threads becomes a major bottleneck.

one request takes up one thread

To mitigate this, Java applications have long relied on thread pools—a fixed number of pre-created OS threads that are shared and recycled to handle incoming tasks. While this helps, it doesn’t solve the core problem; you are still limited by the size of the pool.

How Virtual Threads Work

Virtual threads introduce a new model. They are lightweight threads managed entirely by the JVM, not the OS. This fundamental difference makes them far more efficient and scalable.

Here’s how it works:

  1. When you create a virtual thread, it does not create a new OS thread.
  2. The JVM maintains a small pool of OS threads, referred to as carrier threads.
  3. The JVM “mounts” a virtual thread onto a carrier thread to run its code. When the virtual thread performs a blocking I/O operation (like a database query or an API call), the JVM can “unmount” it, freeing up the carrier thread to run a different virtual thread.

jvm creating many virtual threads that use carrier threads

This scheduling mechanism allows a small number of OS carrier threads to handle millions of virtual threads, eliminating the overhead and limitations of the platform thread model.

Enabling Virtual Threads in Spring Boot

Let’s walk through enabling virtual threads in a simple Spring Boot application and benchmark the performance difference.

Prerequisites

  • Java 21+: Virtual threads are a feature of JDK 21 and later.
  • Spring Boot 3.2+: Official support for virtual threads was introduced in this version.

The Demo Application

We’ve created a simple REST application with a single GET endpoint at /io-task. This endpoint simulates a blocking I/O operation by sleeping for two seconds before returning a message. In a real-world application, this delay would represent a database call or a request to another microservice.

Here is the complete application code:

// src/main/java/com/sohamkamani/virtual_threads_demo/VirtualThreadsDemoApplication.java
package com.sohamkamani.virtual_threads_demo;

import java.util.concurrent.TimeUnit;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
@RestController
public class VirtualThreadsDemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(VirtualThreadsDemoApplication.class, args);
    }

    @GetMapping("/io-task")
    public String ioTask() throws InterruptedException {
        // Simulate an I/O-bound task with a 2-second delay
        TimeUnit.SECONDS.sleep(2);
        return "Task completed on thread: " + Thread.currentThread().toString();
    }
}

The Configuration Change

Enabling virtual threads in Spring Boot is astonishingly simple. All you need to do is add the following property to your src/main/resources/application.properties file:

# src/main/resources/application.properties
spring.threads.virtual.enabled=true

With this setting, Spring Boot will automatically configure its internal task executor to create a new virtual thread for every incoming HTTP request, instead of using the default platform thread pool.

Performance Benchmarks

To measure the impact of this change, we’ll use the Apache Bench (ab) tool to simulate a high-concurrency load. We’ll send a total of 3,000 requests with a concurrency level of 250 (meaning up to 250 requests are active at any given time).

Benchmark 1: Without Virtual Threads (Default Platform Threads)

First, we run the test with spring.threads.virtual.enabled=true commented out.

Command:

ab -n 3000 -c 250 http://localhost:8080/io-task

Results:

Time taken for tests:   32.239 seconds
Requests per second:    93.06 [#/sec] (mean)

Percentage of the requests served within a certain time (ms)
  50%   2018
  66%   2023
  75%   2046
  80%   3970
  90%   3988
  95%   3995
  98%   4000
  99%   4029
 100%   4035 (longest request)

The application processed about 93 requests per second, taking 32.2 seconds in total. More importantly, look at the percentile breakdown. While the median (50%) request time was about 2 seconds (as expected), the slowest 20% of requests took nearly 4 seconds. This indicates the server was running out of threads in its pool and requests were getting queued up.

Benchmark 2: With Virtual Threads Enabled

Now, we uncomment spring.threads.virtual.enabled=true, restart the application, and run the exact same benchmark.

Results:

Time taken for tests:   26.399 seconds
Requests per second:    113.64 [#/sec] (mean)

Percentage of the requests served within a certain time (ms)
  50%   2024
  66%   2027
  75%   2030
  80%   2032
  90%   2036
  95%   2038
  98%   2045
  99%   2055
 100%   2096 (longest request)

The results are significantly better. The total time dropped to just 26.4 seconds, and the throughput increased to 113 requests per second.

The most telling metric is the percentile data. The response time remains consistently close to 2 seconds across the board, even at the 99th percentile. This is because the application could effortlessly create a new virtual thread for every concurrent request, handling the load without creating a bottleneck.

performance comparison

Important Considerations & Pitfalls

So, should you just enable virtual threads everywhere? Not always. There are some critical pitfalls to be aware of.

Thread Pinning and the synchronized Keyword

The biggest issue is thread pinning. This happens when a virtual thread performs an operation that forces it to “pin” to its OS carrier thread, preventing the carrier from being used by any other virtual thread until the operation completes.

The most common cause of pinning is the synchronized keyword. If a virtual thread enters a synchronized block or method, it becomes pinned to its carrier thread. If many virtual threads do this, you can quickly exhaust the carrier thread pool, negating all the performance benefits.

Before switching to virtual threads, you must audit your codebase and its dependencies for uses of synchronized. The recommended replacement is to use java.util.concurrent.locks.ReentrantLock, which is aware of virtual threads and does not cause pinning.

While this behavior has been improved in recent JDK versions, it’s still a crucial factor to consider for optimal performance.

Conclusion

For many I/O-bound applications, Java’s virtual threads offer a paradigm shift in handling concurrency. As demonstrated, you can achieve substantial performance gains in a Spring Boot application with a simple, one-line configuration change. By allowing the application to handle a massive number of concurrent requests without the overhead of traditional OS threads, virtual threads pave the way for more scalable and resilient systems.

Just remember to be mindful of the potential for thread pinning with synchronized blocks. With that consideration in mind, you can easily leverage virtual threads to drastically speed up your application.

Like what I write? Let me know your email, and I'll send you more posts like this. No spam, I promise!