Backend Development 9 min read

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.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Boosting Spring Boot Performance: Virtual Threads vs Tomcat Pool vs WebFlux

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>&lt;dependency&gt;
  &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
  &lt;artifactId&gt;spring-boot-starter-webflux&lt;/artifactId&gt;
&lt;/dependency&gt;
</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>&lt;dependency&gt;
  &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
  &lt;artifactId&gt;spring-boot-starter-data-r2dbc&lt;/artifactId&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
  &lt;groupId&gt;com.github.jasync-sql&lt;/groupId&gt;
  &lt;artifactId&gt;jasync-r2dbc-mysql&lt;/artifactId&gt;
  &lt;version&gt;2.1.24&lt;/version&gt;
&lt;/dependency&gt;
</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.

backend developmentperformance testingSpring BootWebFluxVirtual ThreadsJava 21
Spring Full-Stack Practical Cases
Written by

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.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.