Spring Web: Virtual Threads
Handling high concurrency in web applications often hits a bottleneck when interacting with blocking I/O, such as relational databases. Traditional thread-per-request models limit scalability because OS threads are expensive and quickly exhaust system resources.
Project Loom introduces Virtual Threads—lightweight threads that drastically reduce the overhead of concurrent operations. In this tutorial, we will explore how to enable virtual threads in Spring Boot to efficiently handle web requests and block on database queries without exhausting OS threads. We will verify the implementation using Testcontainers.
Why Virtual Threads?
Before virtual threads, developers often resorted to reactive programming (like WebFlux and R2DBC) to scale properly. While powerful, reactive paradigms introduce complex abstractions and steep learning curves. By utilizing virtual threads, we gain:
-
Simplified Code: Write standard, synchronous blocking code without the cognitive load of reactive chains.
-
High Throughput: Handle thousands of concurrent requests by suspending virtual threads during I/O operations.
-
Seamless Integration: Works out of the box with traditional
spring-webmvcandspring-data-jpa.
Dependencies
We include standard Spring Web, Spring Data JPA, alongside PostgreSQL and Testcontainers in build.gradle.kts:
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-webmvc")
runtimeOnly("org.postgresql:postgresql")
testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test")
testImplementation("org.springframework.boot:spring-boot-resttestclient")
testImplementation("org.springframework.boot:spring-boot-testcontainers")
testImplementation("org.testcontainers:testcontainers-junit-jupiter")
testImplementation("org.testcontainers:testcontainers-postgresql")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
Configuration
Configure the application to enable virtual threads in src/main/resources/application.properties:
spring.application.name=web-virtual-threads
spring.threads.virtual.enabled=true
Setting spring.threads.virtual.enabled=true instructs Tomcat (or your chosen embedded web server) to use an Executor backed by virtual threads for handling HTTP requests.
Implementation
User Model and Repository
Let’s define our simple domain model and repository:
package zin.rashidi.boot.web.virtualthreads.user;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// Constructors, Getters, Setters
}
package zin.rashidi.boot.web.virtualthreads.user;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, Long> {
}
Thread Info Controller
UserResource exposes a REST endpoint to query database records and return thread information, proving the request runs on a virtual thread despite making a blocking I/O call:
package zin.rashidi.boot.web.virtualthreads.user;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@Transactional(readOnly=true)
class UserResource {
private final UserRepository repository;
public UserResource(UserRepository repository) {
this.repository = repository;
}
@GetMapping("/thread-info")
public Map<String, Object> getThreadInfo() {
var count = repository.count(); // Simulated blocking DB IO
var thread = Thread.currentThread();
return Map.of(
"isVirtual", thread.isVirtual(),
"threadName", thread.getName(),
"userCount", count
);
}
}
Integration Testing with Testcontainers
We verify virtual thread execution and database connectivity against a real PostgreSQL instance.
Testcontainers Configuration
Using @ServiceConnection, we easily wire a PostgreSQL container:
package zin.rashidi.boot.web.virtualthreads;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.PostgreSQLContainer;
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfiguration {
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>("postgres:latest");
}
}
Verifying Virtual Thread Execution
package zin.rashidi.boot.web.virtualthreads.user;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.web.servlet.client.RestTestClient;
import zin.rashidi.boot.web.virtualthreads.TestcontainersConfiguration;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
import static org.springframework.test.context.jdbc.Sql.ExecutionPhase.BEFORE_TEST_CLASS;
@AutoConfigureRestTestClient
@Import(TestcontainersConfiguration.class)
@SpringBootTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop", webEnvironment = RANDOM_PORT)
@Sql(executionPhase = BEFORE_TEST_CLASS, statements = "INSERT INTO users (name) VALUES ('Rashidi')")
class UserResourceTests {
@Autowired
private RestTestClient restClient;
@Test
@DisplayName("Should process web request and database query on a Virtual Thread")
void virtualThreadEnabled() {
restClient.get().uri("/thread-info").exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.isVirtual").isEqualTo(true)
.jsonPath("$.userCount").isEqualTo(1);
}
}