跪拜 Guibai
← Back to the summary

Swapping SpringBoot's Thread Pool to Virtual Threads Delivers a 200x Speedup on IO Workloads

What Are Virtual Threads

Virtual threads are a feature added starting from Java 19, similar to Goroutines in Golang. It's a practical and easy-to-use feature that other languages have provided for a long time, and as a Java developer, I've been eagerly awaiting it.

The Difference Between Virtual Threads and Regular Threads

"Virtual" threads, as the name implies, are "fake." They don't directly schedule operating system threads; instead, the JVM provides an additional layer of thread interface abstraction, scheduled by regular threads. This means one regular operating system thread can schedule tens of thousands of virtual threads.

Virtual threads consume far, far fewer resources than regular threads. With enough memory, we can even create millions of virtual threads, which was impossible before Java 19.

Actually, if you've used Akka, you'll find the two are very similar. The difference is that with Akka, the application handles the scheduling, while with virtual threads, the JVM handles it, making usage simpler and more convenient.

Using Virtual Threads in SpringBoot

Below, we'll use virtual threads in SpringBoot, replacing the default asynchronous thread pool and HTTP processing thread pool with virtual threads. Then we'll compare the performance difference between virtual threads and regular threads. You'll find the difference is like switching from a horse-drawn carriage to a high-speed train — they're not from the same era.

Configuration

First, the Java version we're using is java-20.0.2-oracle, and the SpringBoot version is 3.1.2.

Using virtual threads in SpringBoot is simple. Just add the following configuration:

/**
 * The configuration is for later testing. spring.virtual-thread=true uses virtual threads,
 * false uses the default regular threads.
 */
@Configuration
@ConditionalOnProperty(prefix = "spring", name = "virtual-thread", havingValue = "true")
public class ThreadConfig {

    @Bean
    public AsyncTaskExecutor applicationTaskExecutor() {
        return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
    }

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

@Async Performance Comparison

We'll write an asynchronous service that sleeps for 50ms, simulating IO operations like MySQL or Redis:

@Service
public class AsyncService {

    /**
     * 
     * @param countDownLatch for testing
     */
    @Async
    public void doSomething(CountDownLatch countDownLatch) throws InterruptedException {
        Thread.sleep(50);
        countDownLatch.countDown();
    }
}

Finally, the test class. It's very simple: loop and call this method 100,000 times, calculating the total time for all method executions to complete:

@Test
public void testAsync() throws InterruptedException {
    long start = System.currentTimeMillis();
    int n = 100000;
    CountDownLatch countDownLatch = new CountDownLatch(n);
    for (int i = 0; i < n; i++) {
        asyncService.doSomething(countDownLatch);
    }
    countDownLatch.await();
    long end = System.currentTimeMillis();
    System.out.println("Time consumed: " + (end - start) + "ms");
}

Regular thread time consumed: around 678 seconds, over 10 minutes.

Virtual thread time consumed: 3.9 seconds!!

Friends, that's nearly a 200x performance gap!!

HTTP Request Performance Comparison

Let's look at the HTTP request comparison. We'll write a simple GET request that does nothing but sleep for 50ms, simulating IO operations:

@RequestMapping("/get")
public Object get() throws Exception {
    Thread.sleep(50);
    return "ok";
}

Then we'll use JMeter to hit the endpoint with 500 concurrent threads, running 10,000 times, and see the results:

「Regular Threads:」

You can see the minimum time is 50ms, which makes sense since the endpoint sleeps for 50ms. But whether it's the median, 90th, 95th, or 99th percentile, they all exceed 150ms. This is because system threads are a very expensive resource. The default maximum number of connections in Tomcat within SpringBoot should be 200. After the connection pool's threads are exhausted, those 200 threads are just sitting there waiting for 50ms to end, while the remaining requests can only wait and cannot perform other operations. Now let's look at the virtual thread performance:

「Virtual Thread Time Consumed:」

You can see that even the maximum time consumed stays below 100ms, meaning thread waiting time is significantly reduced. Virtual threads make better use of system resources.

Summary

From the performance comparison above, virtual threads have a clear advantage in performance. But it's important to note that in our tests above, we made the threads wait for 50ms. What scenario does this simulate?

That's right, an IO-intensive scenario, where threads spend most of their time waiting for IO. This is where virtual threads can show their advantage. If it's a CPU-intensive scenario, the effect might not be significant. However, most of our current applications are IO-intensive, such as typical web applications where a lot of time is spent waiting for network IO (DB, cache, HTTP, etc.). The effect of using virtual threads is still very noticeable.