Spring Boot 3.4 Virtual Threads: Interview Questions and Performance Benchmarks
Master Java 21 Virtual Threads with Spring Boot 3.4: 15 interview questions, performance benchmarks, and migration patterns to ace your technical interviews.

Virtual Threads represent one of Java 21's most significant advancements, and Spring Boot 3.4 integrates them natively. This Project Loom feature transforms how concurrency is handled in backend applications. Technical interviews now assess understanding of internal mechanisms, appropriate use cases, and common pitfalls to avoid.
Interviewers distinguish candidates who understand Virtual Threads from those who use them blindly. Knowing when NOT to use them is just as important as understanding their benefits.
Virtual Threads Fundamentals
Question 1: What is a Virtual Thread and how does it differ from a Platform Thread?
A Virtual Thread is a lightweight thread managed by the JVM rather than the operating system. Unlike Platform Threads (traditional threads), Virtual Threads don't map directly to OS threads. The JVM can create millions of them with minimal memory footprint.
// Comparing thread creation approaches
public class VirtualThreadDemo {
public void demonstrateDifference() {
// Platform Thread: ~1MB stack per thread
// Practical limit: a few thousand on a standard JVM
Thread platformThread = new Thread(() -> {
performBlockingOperation();
});
// Virtual Thread: ~a few KB per thread
// Can create millions without issues
Thread virtualThread = Thread.ofVirtual().start(() -> {
performBlockingOperation();
});
}
// A Virtual Thread "mounts" onto a Platform Thread (carrier)
// During I/O blocking, it releases the carrier for other Virtual Threads
private void performBlockingOperation() {
try {
Thread.sleep(1000); // Virtual Thread detaches from carrier here
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}The fundamental difference lies in blocking behavior. When a Platform Thread blocks (I/O, sleep), it remains attached to the OS thread. A Virtual Thread "unmounts" from the carrier thread, allowing other Virtual Threads to use that carrier.
Question 2: How to enable Virtual Threads in Spring Boot 3.4?
Spring Boot 3.4 simplifies Virtual Threads activation to a single configuration property. The entire framework adapts automatically: Tomcat, REST controllers, and blocking calls immediately benefit from this optimization.
# application.yml
# Global Virtual Threads activation
spring:
threads:
virtual:
enabled: true
# Optional Tomcat pool configuration
server:
tomcat:
threads:
max: 200 # Less critical with Virtual Threads
min-spare: 10// Programmatic activation if needed
@Configuration
public class WebConfig {
@Bean
public TomcatProtocolHandlerCustomizer<?> virtualThreadCustomizer() {
// Each HTTP request runs on a Virtual Thread
return protocolHandler -> {
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
};
}
}Activation changes Tomcat's behavior: instead of a fixed thread pool, each request gets its own Virtual Thread. This approach eliminates the traditional thread pool bottleneck.
Question 3: Explain the concept of "mounting" and "unmounting"
Mounting refers to attaching a Virtual Thread to a carrier thread (Platform Thread). Unmounting occurs during blocking operations, freeing the carrier for other Virtual Threads. This mechanism enables optimal CPU resource utilization.
// Illustrating the mounting/unmounting cycle
public class MountingDemo {
public void demonstrateMounting() {
Thread.ofVirtual().start(() -> {
// MOUNTED: Virtual Thread is using a carrier thread
System.out.println("Carrier: " + getCurrentCarrier());
// UNMOUNTING: releases carrier during blocking
performDatabaseQuery(); // Blocking JDBC call
// REMOUNTED: may be on a different carrier
System.out.println("New carrier: " + getCurrentCarrier());
});
}
// Blocking operations trigger unmounting automatically
private void performDatabaseQuery() {
// JDBC connection, file read, network call...
// Virtual Thread detaches during I/O wait
}
private String getCurrentCarrier() {
// Gets the current carrier thread name
return Thread.currentThread().toString();
}
}This mechanism is transparent to developers. Code is written in classic imperative style, but the JVM automatically optimizes carrier usage. A pool of a few carriers can serve millions of Virtual Threads.
The number of carrier threads typically matches the CPU core count. The JVM adjusts this pool dynamically via the ForkJoinPool.
Use Cases and Anti-patterns
Question 4: When do Virtual Threads provide performance gains?
Virtual Threads excel for I/O-bound workloads: external REST calls, database queries, file reads. These operations spend most time waiting, during which the Virtual Thread releases its carrier.
// Ideal case for Virtual Threads
@Service
public class IOBoundService {
private final RestClient restClient;
private final UserRepository userRepository;
// Each call involves network and database waiting
// Virtual Threads shine here
public UserProfile enrichUserProfile(Long userId) {
// DB call - VT detaches during SQL query
User user = userRepository.findById(userId).orElseThrow();
// External REST call - VT detaches during HTTP wait
ExternalData externalData = restClient
.get()
.uri("/api/external/{id}", userId)
.retrieve()
.body(ExternalData.class);
// Data aggregation
return new UserProfile(user, externalData);
}
}The gain comes from handling more concurrent requests. With 200 Platform Threads and 100ms requests, maximum throughput is 2,000 req/s. With Virtual Threads, this can reach 50,000+ req/s on the same machine.
Question 5: What anti-patterns should be avoided with Virtual Threads?
Virtual Threads are unsuitable for CPU-bound workloads or cases involving "pinning." Pinning occurs when a Virtual Thread remains attached to its carrier despite blocking, negating virtualization benefits.
// Examples of cases to avoid
@Service
public class AntiPatterns {
// ANTI-PATTERN 1: CPU-intensive computation
// Virtual Threads provide no benefit here
public BigInteger computeFactorial(int n) {
// 100% CPU, no I/O, no unmounting possible
BigInteger result = BigInteger.ONE;
for (int i = 2; i <= n; i++) {
result = result.multiply(BigInteger.valueOf(i));
}
return result; // Carrier is monopolized throughout
}
// ANTI-PATTERN 2: Synchronized causes pinning
private final Object lock = new Object();
public void pinnedOperation() {
synchronized (lock) { // PINNING: VT stays on carrier
performDatabaseQuery(); // Unmounting doesn't happen!
}
}
// SOLUTION: Use ReentrantLock
private final ReentrantLock reentrantLock = new ReentrantLock();
public void unpinnedOperation() {
reentrantLock.lock();
try {
performDatabaseQuery(); // Unmounting possible
} finally {
reentrantLock.unlock();
}
}
}Pinning transforms a Virtual Thread into a Platform Thread from a resource perspective. Main causes are synchronized blocks and native JNI calls. Migrating to ReentrantLock solves the first case.
Question 6: How to detect pinning in an application?
The JVM offers diagnostic options to identify pinning cases. This information is essential when migrating to Virtual Threads, as pinning can degrade performance instead of improving it.
// Configuration and pinning detection
public class PinningDiagnostics {
// JVM option to log pinning
// -Djdk.tracePinnedThreads=full (detailed)
// -Djdk.tracePinnedThreads=short (summary)
// Example code causing pinning
public void demonstratePinning() {
Thread.ofVirtual().start(() -> {
synchronized (this) {
// This log will appear with tracePinnedThreads enabled
try {
Thread.sleep(100); // Pinned during sleep!
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});
}
}# Typical output with -Djdk.tracePinnedThreads=full
Thread[#23,VirtualThread[#1]/runnable@ForkJoinPool-1-worker-1,5,CarrierThreads]
java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:183)
java.base/java.lang.VirtualThread.parkOnCarrierThread(VirtualThread.java:661)
com.example.PinningDiagnostics.demonstratePinning(PinningDiagnostics.java:15)Analyzing pinning logs reveals hotspots to fix. An application with frequent pinning doesn't fully leverage Virtual Threads and may even be slower than with Platform Threads.
Ready to ace your Spring Boot interviews?
Practice with our interactive simulators, flashcards, and technical tests.
Performance Benchmarks
Question 7: What performance gains can be expected with Virtual Threads?
Benchmarks show significant improvements for typical I/O-bound applications. Gains depend on the I/O time / CPU time ratio and the required concurrency level.
// Endpoint for measuring performance
@RestController
@RequestMapping("/api/benchmark")
public class BenchmarkController {
private final ExternalApiClient apiClient;
// Simulation of a typical endpoint with external calls
@GetMapping("/user/{id}")
public ResponseEntity<UserData> getUser(@PathVariable Long id) {
// Three sequential I/O calls
UserInfo info = apiClient.fetchUserInfo(id); // ~50ms
List<Order> orders = apiClient.fetchOrders(id); // ~80ms
CreditScore score = apiClient.fetchCreditScore(id); // ~100ms
return ResponseEntity.ok(new UserData(info, orders, score));
}
}# Benchmark results - 10,000 concurrent requests
# Configuration: 8 cores, 16GB RAM, simulated latency 230ms/request
Platform Threads (pool 200):
- Throughput: 850 req/s
- P99 latency: 1250ms
- Heap memory: 2.1GB
Virtual Threads:
- Throughput: 4200 req/s
- P99 latency: 280ms
- Heap memory: 850MB
Gain: 5x throughput, 4.5x P99 latency reductionVirtual Threads also reduce memory consumption because each thread allocates only a few KB instead of ~1MB for a Platform Thread.
Question 8: How to configure connection pools with Virtual Threads?
Connection pools (HikariCP, Lettuce) become the new bottleneck with Virtual Threads. A pool of 10 connections limits to 10 simultaneous DB queries, even with millions of Virtual Threads.
# application.yml
# HikariCP configuration optimized for Virtual Threads
spring:
datasource:
hikari:
# More connections since Virtual Threads allow more concurrency
maximum-pool-size: 50
minimum-idle: 10
# Shorter timeout due to more concurrent requests
connection-timeout: 5000
# Fast validation
validation-timeout: 3000
# Redis with Lettuce - already async-friendly
data:
redis:
lettuce:
pool:
max-active: 50
max-idle: 20// Pool monitoring to avoid contention
@Component
public class ConnectionPoolMonitor {
private final HikariDataSource dataSource;
@Scheduled(fixedRate = 10000)
public void logPoolStats() {
HikariPoolMXBean pool = dataSource.getHikariPoolMXBean();
log.info("Pool stats - Active: {}, Idle: {}, Waiting: {}",
pool.getActiveConnections(),
pool.getIdleConnections(),
pool.getThreadsAwaitingConnection());
// Alert if too many threads waiting
if (pool.getThreadsAwaitingConnection() > 100) {
log.warn("Connection pool contention detected!");
}
}
}Pool sizing depends on database capacity, not Virtual Thread count. A standard PostgreSQL database supports ~100-200 active connections.
Question 9: How to measure Virtual Threads impact with Micrometer?
Micrometer and Spring Boot Actuator provide essential metrics to evaluate Virtual Threads effectiveness. These metrics validate gains and identify potential issues.
// Custom metrics for Virtual Threads
@Component
public class VirtualThreadMetrics {
private final MeterRegistry registry;
private final AtomicLong activeVirtualThreads = new AtomicLong(0);
@PostConstruct
public void registerMetrics() {
// Active Virtual Threads counter
Gauge.builder("virtual.threads.active", activeVirtualThreads, AtomicLong::get)
.description("Number of active virtual threads")
.register(registry);
// JVM metrics for carriers
Gauge.builder("virtual.threads.carriers", this::getCarrierCount)
.description("Number of carrier threads")
.register(registry);
}
private double getCarrierCount() {
// Gets the ForkJoinPool carrier count
return ForkJoinPool.commonPool().getPoolSize();
}
// Interceptor to trace requests
public void trackVirtualThread(Runnable task) {
activeVirtualThreads.incrementAndGet();
try {
task.run();
} finally {
activeVirtualThreads.decrementAndGet();
}
}
}# application.yml
# Metrics exposure
management:
endpoints:
web:
exposure:
include: health,metrics,prometheus
metrics:
tags:
application: ${spring.application.name}Metrics analysis reveals the Virtual Threads / Carriers ratio and identifies connection pool contention periods.
Migration and Compatibility
Question 10: Which libraries are compatible with Virtual Threads?
Compatibility depends on synchronized block usage and native calls. The Spring ecosystem is largely compatible, but some libraries require specific versions.
// Checking dependency compatibility
@Configuration
public class CompatibilityCheck {
// Compatible libraries (recommended versions)
// - Spring Boot 3.2+ (native support)
// - HikariCP 5.1+ (ReentrantLock instead of synchronized)
// - Lettuce 6.3+ (non-blocking I/O)
// - Jackson 2.16+ (no synchronized)
// Libraries requiring attention
// - JDBC drivers: check version
// - Some legacy HTTP clients
@Bean
public CommandLineRunner checkCompatibility() {
return args -> {
// Log versions for audit
log.info("Java version: {}", System.getProperty("java.version"));
log.info("Virtual threads available: {}",
Thread.ofVirtual() != null);
// Support verification
if (Runtime.version().feature() < 21) {
throw new IllegalStateException(
"Java 21+ required for Virtual Threads");
}
};
}
}Most modern frameworks have migrated to ReentrantLock. For legacy dependencies, load testing with -Djdk.tracePinnedThreads=short reveals pinning issues.
Question 11: How to progressively migrate to Virtual Threads?
A progressive migration validates gains and identifies issues without risking production. The recommended strategy isolates Virtual Thread endpoints from traditional ones.
// Configuration for progressive migration
@Configuration
public class DualExecutorConfig {
// Traditional executor for legacy endpoints
@Bean("platformExecutor")
public ExecutorService platformExecutor() {
return Executors.newFixedThreadPool(200);
}
// Virtual Threads executor for new endpoints
@Bean("virtualExecutor")
public ExecutorService virtualExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
}
@RestController
@RequestMapping("/api/v2")
public class MigratedController {
@Qualifier("virtualExecutor")
private final ExecutorService executor;
// Endpoint migrated to Virtual Threads
@GetMapping("/users/{id}")
public CompletableFuture<User> getUser(@PathVariable Long id) {
return CompletableFuture.supplyAsync(() -> {
// Business code unchanged
return userService.findById(id);
}, executor);
}
}# application.yml
# Feature flag for migration
features:
virtual-threads:
enabled: true
endpoints:
- /api/v2/**
- /api/reports/**
# Disable for quick rollback
# features.virtual-threads.enabled: falseDo not enable Virtual Threads globally before testing all dependencies. Pinning can significantly degrade performance.
Question 12: What tests should be performed before production deployment?
Virtual Threads validation requires load tests, pinning tests, and compatibility tests. These tests must simulate realistic production conditions.
// Load test with JUnit and Virtual Threads
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class VirtualThreadLoadTest {
@LocalServerPort
private int port;
@Test
void shouldHandleHighConcurrency() throws Exception {
int concurrentRequests = 5000;
CountDownLatch latch = new CountDownLatch(concurrentRequests);
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger errorCount = new AtomicInteger(0);
HttpClient client = HttpClient.newHttpClient();
// Launch 5000 simultaneous requests
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < concurrentRequests; i++) {
executor.submit(() -> {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + port + "/api/test"))
.build();
HttpResponse<String> response = client.send(
request, BodyHandlers.ofString());
if (response.statusCode() == 200) {
successCount.incrementAndGet();
} else {
errorCount.incrementAndGet();
}
} catch (Exception e) {
errorCount.incrementAndGet();
} finally {
latch.countDown();
}
});
}
}
latch.await(60, TimeUnit.SECONDS);
// Performance assertions
assertThat(successCount.get()).isGreaterThan(4900); // >98% success
assertThat(errorCount.get()).isLessThan(100);
}
}Tests should cover contention scenarios (saturated connection pool), timeouts, and sustained load over several minutes.
Advanced Questions
Question 13: How do Virtual Threads interact with Structured Concurrency?
Structured Concurrency (JEP 453) complements Virtual Threads by ensuring concurrent tasks share the same lifecycle. This approach simplifies error handling and cancellation.
// Combining Virtual Threads + Structured Concurrency
public class StructuredConcurrencyExample {
public UserDashboard fetchDashboard(Long userId) throws Exception {
// StructuredTaskScope ensures all tasks complete together
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Three parallel tasks on Virtual Threads
Subtask<UserProfile> profileTask = scope.fork(() ->
userService.getProfile(userId));
Subtask<List<Notification>> notifTask = scope.fork(() ->
notificationService.getRecent(userId));
Subtask<AccountBalance> balanceTask = scope.fork(() ->
accountService.getBalance(userId));
// Wait for all tasks or fail on first error
scope.join();
scope.throwIfFailed();
// All tasks succeeded - safe aggregation
return new UserDashboard(
profileTask.get(),
notifTask.get(),
balanceTask.get()
);
}
// If one task fails, others are automatically cancelled
}
}Structured Concurrency prevents thread leaks and simplifies debugging because the call stack reflects the logical code structure.
Question 14: What is the difference between Virtual Threads and reactive programming?
Both approaches solve the same problem (I/O efficiency) but with different programming models. Virtual Threads allow classic imperative code, while reactive requires rewriting as streams.
// Same logic: imperative vs reactive
@Service
public class ComparisonService {
// VIRTUAL THREADS APPROACH: classic imperative code
// Easy to read, debug, and maintain
public UserData getUserDataImperative(Long id) {
User user = userRepository.findById(id).orElseThrow();
List<Order> orders = orderRepository.findByUserId(id);
PaymentInfo payment = paymentService.getInfo(id);
return new UserData(user, orders, payment);
}
// REACTIVE APPROACH: streams and operators
// More complex but native backpressure
public Mono<UserData> getUserDataReactive(Long id) {
return userRepository.findById(id)
.zipWith(orderRepository.findByUserId(id).collectList())
.zipWith(paymentService.getInfo(id))
.map(tuple -> new UserData(
tuple.getT1().getT1(),
tuple.getT1().getT2(),
tuple.getT2()
));
}
}| Criteria | Virtual Threads | Reactive | |----------|-----------------|----------| | Learning curve | Low | High | | Debugging | Classic stack traces | Complex | | Backpressure | Manual | Native | | Ecosystem | Growing | Mature | | Legacy migration | Simple | Rewrite |
Virtual Threads are recommended for new applications and migrations. Reactive remains relevant for cases requiring sophisticated backpressure.
Question 15: How to handle ThreadLocal with Virtual Threads?
ThreadLocal works with Virtual Threads but consumes memory for each instance. Scoped Values (JEP 446) offer a more efficient alternative for context sharing.
// Comparing context approaches
public class ThreadLocalVsScopedValue {
// CLASSIC APPROACH: ThreadLocal
// Works but expensive with millions of VT
private static final ThreadLocal<RequestContext> requestContext =
new ThreadLocal<>();
public void processWithThreadLocal(Request request) {
requestContext.set(new RequestContext(request.getTraceId()));
try {
// Context accessible everywhere in the thread
processRequest();
} finally {
requestContext.remove(); // Important to prevent leaks
}
}
// MODERN APPROACH: ScopedValue (Java 21+)
// More efficient, immutable, explicit scope
private static final ScopedValue<RequestContext> CONTEXT =
ScopedValue.newInstance();
public void processWithScopedValue(Request request) {
ScopedValue.where(CONTEXT, new RequestContext(request.getTraceId()))
.run(() -> {
// Context accessible within this scope
processRequest();
// No cleanup needed - automatic at scope end
});
}
private void processRequest() {
// Context access
String traceId = CONTEXT.isBound()
? CONTEXT.get().getTraceId()
: "unknown";
log.info("Processing with trace: {}", traceId);
}
}ScopedValues are recommended for new development. For legacy code using ThreadLocal, a progressive migration is advised.
Ready to ace your Spring Boot interviews?
Practice with our interactive simulators, flashcards, and technical tests.
Conclusion
Virtual Threads transform Java backend development by enabling simple, high-performance code. Key takeaways:
Fundamentals:
- ✅ Enable with
spring.threads.virtual.enabled=true - ✅ Ideal for I/O-bound workloads (REST, DB, files)
- ✅ Avoid for CPU-intensive computations
Performance:
- ✅ Size connection pools appropriately (new bottleneck)
- ✅ Monitor pinning with
-Djdk.tracePinnedThreads - ✅ Migrate
synchronizedtoReentrantLock
Migration:
- ✅ Test progressively by endpoint groups
- ✅ Validate dependency compatibility
- ✅ Use Structured Concurrency for parallel operations
Mastering Virtual Threads distinguishes candidates who understand modern application performance challenges. These concepts are now essential in Spring Boot technical interviews.
Start practicing!
Test your knowledge with our interview simulators and technical tests.
Tags
Share
Related articles

Spring Modulith: Modular Monolith Architecture Explained
Learn Spring Modulith to build modular monoliths in Java. Architecture, modules, async events and testing with Spring Boot 3 code examples.

Spring Batch 5 Interview: Partitioning, Chunks and Fault Tolerance
Ace your Spring Batch 5 interviews: 15 essential questions on partitioning, chunk-oriented processing, and fault tolerance with Java 21 code examples.

Testcontainers Spring Boot: Painless Integration Testing
Complete guide to configuring Testcontainers with Spring Boot 3.4. PostgreSQL, Redis, Kafka in Docker containers for reliable, reproducible integration tests.