Boosting Spring Boot Performance: Virtual Threads vs Tomcat Pool vs WebFlux
This article compares Spring Boot 3.2's virtual‑thread support with traditional Tomcat thread pools and reactive WebFlux, showing configuration steps, code examples, JMeter load‑test results, and database access benchmarks to illustrate throughput, CPU and memory impacts.
1. Introduction
Spring Boot 3.2 supports Java 21 virtual threads. To enable them you must run on JDK 21 and set spring.threads.virtual.enabled=true . After enabling, Tomcat and Jetty process requests on virtual threads, and the applicationTaskExecutor bean becomes a SimpleAsyncTaskExecutor that uses virtual threads for @Async , Spring MVC async handling, and Spring WebFlux blocking support.
2. Performance Comparison
2.1 Traditional Tomcat Thread‑Pool
Configure the Tomcat thread pool (default max 200 threads). Example configuration:
<code>server:
tomcat:
threads:
min-spare: 500
max: 1000
</code>JMeter test with 500 threads, 200 loops (100 000 requests) yields a throughput of 4696 . Memory and CPU usage are shown in the following images:
2.2 Using Virtual Threads
Enable virtual threads with the same YAML snippet:
<code>spring:
threads:
virtual:
enabled: true
</code>After startup, JMeter test (same load) gives a throughput of 4677 , comparable to the blocking servlet approach but with higher memory consumption. Virtual threads are scheduled by a ForkJoinPool working‑steal scheduler that runs in FIFO mode; its parallelism defaults to the number of available processors but can be tuned via jdk.virtualThreadScheduler.parallelism and jdk.virtualThreadScheduler.maxPoolSize .
Example JVM parameters:
<code>-Djdk.virtualThreadScheduler.parallelism=100 -Djdk.virtualThreadScheduler.maxPoolSize=100</code>Adjusted settings did not improve throughput and increased thread count.
2.3 Reactive WebFlux
WebFlux runs on a non‑blocking event‑loop model. Add the starter dependency:
<code><dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
</code>Sample controller using Mono.delayElement to simulate latency:
<code>@RestController
@RequestMapping("/task/reactor")
public class ReactorController {
@GetMapping("")
public Object index() throws Exception {
// simulate work with delayElement
return Mono.just("task - reactor...").delayElement(Duration.ofMillis(100));
}
}
</code>JMeter results show a throughput of 4659 , similar to the other two approaches, but WebFlux consumes less memory and achieves the smallest maximum response time and standard deviation, indicating more stable latency.
3. Database‑Backed Tests
All three approaches were exercised against a MySQL table containing 6 million rows.
3.1 Traditional Tomcat Thread‑Pool with JPA
<code>@Entity
@Table(name = "t_user")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer uid;
private String name;
}
</code> <code>public interface UserRepository extends JpaRepository<User, Integer> {}
</code> <code>@RestController
@RequestMapping("/users")
public class UserController {
@Resource
private UserRepository ur;
@GetMapping("/count")
public User count() {
return ur.findById(5800000).orElse(null);
}
}
</code>Throughput result (image) is shown below.
3.2 Virtual Threads with JPA
Same JPA setup, but the application runs with virtual threads enabled. The throughput image shows a similar value to the blocking servlet test.
3.3 Reactive WebFlux with R2DBC
Add R2DBC dependencies:
<code><dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-r2dbc</artifactId>
</dependency>
<dependency>
<groupId>com.github.jasync-sql</groupId>
<artifactId>jasync-r2dbc-mysql</artifactId>
<version>2.1.24</version>
</dependency>
</code>R2DBC configuration:
<code>spring:
r2dbc:
url: r2dbc:mysql://localhost:3306/batch?serverZoneId=GMT%2B8&sslMode=DISABLED
username: root
password: xxxooo
pool:
initialSize: 100
maxSize: 100
max-acquire-time: 30s
max-idle-time: 30m
</code>Entity and repository definitions differ slightly from JPA:
<code>@Table("t_user")
public class User {
@Id
private Integer uid;
private String name;
}
</code> <code>public interface UserR2DBCRepository extends ReactiveCrudRepository<User, Integer> {}
</code> <code>@RestController
@RequestMapping("/r2dbc")
public class UserR2DBCController {
@Resource
private UserR2DBCRepository ur;
@GetMapping("/users")
public Mono<User> count() {
return ur.findById(5800000);
}
}
</code>The WebFlux‑R2DBC test achieves the highest throughput and the lowest latency among the three methods, as shown in the result image:
Overall, the reactive WebFlux approach outperforms both virtual‑thread and traditional Tomcat thread‑pool implementations in this benchmark.
End of article.
Spring Full-Stack Practical Cases
Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.