Skip to main content

Improving throughput and latency using Java Virtual Threads in Spring

·773 words·4 mins
Table of Contents
Spring - This article is part of a series.
Part : This Article

One of the major changes that Java 21 brought about is Virtual threads. There is so much hype around it, but let’s see a real-world example with some metrics. Spring introduced the ability to create GraalVM native images that use Spring Boot and Java 21’s virtual threads(Project Loom).

We will look at virtual threads in detail and the many features that come with them in another article. We will focus on a simple project where we can enable the features of Java Virtual Threads and see the difference in performance gains.

Why virtual threads are faster than normal threads?
#

Normal threads in Java are tied to OS threads. So, there is a limitation to the actual number of threads it can create. Also, time is lost waiting for blocking I/O Calls. Normal Threads are called Platform Threads. They are an expensive resource that needs to be managed well.

Now, when it comes to virtual threads, they are lightweight constructs. Multiple virtual threads can be tied to a single platform thread which in turn gets tied to an OS thread.

The simple idea behind Virtual Threads

The Spring Project used for benchmarking
#

The project used in this article can be found here. It has a simple fetch API that returns the Thirukkural based on the ID sent.

Thirukkural API

Now, by default the http server in Tomcat can run many threads in parallel and this will not let us test out this feature easily. So let’s throttle it to 10 max threads by adding the below property in the application.properties file. This will allow Tomcat to only use 10 threads at max.

server.tomcat.threads.max=10

We will mimic a blocking IO call using sleep. We will also log the current thread it’s using.

Thread.sleep(1000);
log.info("Running on " + Thread.currentThread());

Benchmarking using hey
#

We will benchmark this using hey tool.

hey -n 200 -c 30 http://localhost:8080/thirukural/1

Summary:
 Total:        36.1759 secs
 Slowest:      8.0463 secs
 Fastest:      2.0080 secs
 Average:      5.6840 secs
 Requests/sec: 4.9757


Response time histogram:
 2.008 [1]     |
 2.612 [10]    |■■■
 3.216 [0]     |
 3.819 [0]     |
 4.423 [11]    |■■■
 5.027 [0]     |
 5.631 [0]     |
 6.235 [156]   |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
 6.839 [0]     |
 7.442 [0]     |
 8.046 [2]     |■


Latency distribution:
 10% in 4.0305 secs
 25% in 6.0212 secs
 50% in 6.0263 secs
 75% in 6.0340 secs
 90% in 6.0376 secs
 95% in 6.0387 secs
 99% in 8.0463 secs

Details (average, fastest, slowest):
 DNS+dialup:   0.0007 secs, 2.0080 secs, 8.0463 secs
 DNS-lookup:   0.0005 secs, 0.0000 secs, 0.0041 secs
 req write:    0.0000 secs, 0.0000 secs, 0.0003 secs
 resp wait:    5.6832 secs, 2.0079 secs, 8.0419 secs
 resp read:    0.0001 secs, 0.0000 secs, 0.0005 secs

Status code distribution:
 [200] 180 responses

A few log entries are listed below to see that these requests are using Platform Threads.

2024-06-30T22:07:29.659+05:30  INFO 2120 --- [thriukural] [nio-8080-exec-7] p.v.thriukural.web.ThirukuralController  : Running on Thread[#45,http-nio-8080-exec-7,5,main]
2024-06-30T22:07:29.659+05:30  INFO 2120 --- [thriukural] [io-8080-exec-10] p.v.thriukural.web.ThirukuralController  : Running on Thread[#48,http-nio-8080-exec-10,5,main]
2024-06-30T22:07:29.659+05:30  INFO 2120 --- [thriukural] [nio-8080-exec-2] p.v.thriukural.web.ThirukuralController  : Running on Thread[#40,http-nio-8080-exec-2,5,main]

Now, to enable the application to use virtual threads, we just add the below property in application.properties.

spring.threads.virtual.enabled=true

Let’s run and see the same benchmark results again.

hey -n 200 -c 30 http://localhost:8080/thirukural/1

Summary:
 Total:        12.2478 secs
 Slowest:      2.1747 secs
 Fastest:      2.0086 secs
 Average:      2.0410 secs
 Requests/sec: 14.6965


Response time histogram:
 2.009 [1]     |
 2.025 [149]   |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
 2.042 [0]     |
 2.058 [0]     |
 2.075 [0]     |
 2.092 [0]     |
 2.108 [0]     |
 2.125 [0]     |
 2.141 [0]     |
 2.158 [0]     |
 2.175 [30]    |■■■■■■■■


Latency distribution:
 10% in 2.0115 secs
 25% in 2.0129 secs
 50% in 2.0158 secs
 75% in 2.0188 secs
 90% in 2.1724 secs
 95% in 2.1739 secs
 99% in 2.1747 secs

Details (average, fastest, slowest):
 DNS+dialup:   0.0015 secs, 2.0086 secs, 2.1747 secs
 DNS-lookup:   0.0014 secs, 0.0000 secs, 0.0093 secs
 req write:    0.0000 secs, 0.0000 secs, 0.0002 secs
 resp wait:    2.0392 secs, 2.0085 secs, 2.1656 secs
 resp read:    0.0003 secs, 0.0000 secs, 0.0026 secs

Status code distribution:
 [200] 180 responses

A few log entries to see them running in virtual threads.

2024-06-30T22:12:38.485+05:30  INFO 17700 --- [thriukural] [mcat-handler-48] p.v.thriukural.web.ThirukuralController  : Running on VirtualThread[#103,tomcat-handler-48]/runnable@ForkJoinPool-1-worker-6
2024-06-30T22:12:38.485+05:30  INFO 17700 --- [thriukural] [mcat-handler-66] p.v.thriukural.web.ThirukuralController  : Running on VirtualThread[#121,tomcat-handler-66]/runnable@ForkJoinPool-1-worker-12
2024-06-30T22:12:38.485+05:30  INFO 17700 --- [thriukural] [mcat-handler-69] p.v.thriukural.web.ThirukuralController  : Running on VirtualThread[#124,tomcat-handler-69]/runnable@ForkJoinPool-1-worker-7

Benchmarking results
#

As evident from the results, we see an increased throughput and better latency as well once virtual threads are enabled.

Parameter     Without Virtual Threads With Virtual Threads
Requests/sec 4.9757                   14.6965              
99% Latency   8.0463 sec               2.1747 sec          

Summary
#

A simple parameter change has enabled us with a lot of improved performance and throughput. We will further explore in future articles some of the considerations that need to be taken before using virtual threads.

Spring - This article is part of a series.
Part : This Article