# 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. - Published: 2026-03-15 - Updated: 2026-03-31 - Author: SharpSkill - Tags: spring boot, virtual threads, java 21, project loom, performance - Reading time: 16 min --- 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. > **Interview Preparation Tip** > > 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. ```java // VirtualThreadDemo.java // 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. ```yaml # 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 ``` ```java // WebConfig.java // 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. ```java // MountingDemo.java // 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. > **Technical Note** > > 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. ```java // IOBoundService.java // 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. ```java // AntiPatterns.java // 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. ```java // PinningDiagnostics.java // 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(); } } }); } } ``` ```bash # 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. ## 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. ```java // BenchmarkController.java // 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 getUser(@PathVariable Long id) { // Three sequential I/O calls UserInfo info = apiClient.fetchUserInfo(id); // ~50ms List orders = apiClient.fetchOrders(id); // ~80ms CreditScore score = apiClient.fetchCreditScore(id); // ~100ms return ResponseEntity.ok(new UserData(info, orders, score)); } } ``` ```text # 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 reduction ``` Virtual 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. ```yaml # 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 ``` ```java // ConnectionPoolMonitor.java // 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. ```java // VirtualThreadMetrics.java // 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(); } } } ``` ```yaml # 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. ```java // CompatibilityCheck.java // 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. ```java // DualExecutorConfig.java // 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 getUser(@PathVariable Long id) { return CompletableFuture.supplyAsync(() -> { // Business code unchanged return userService.findById(id); }, executor); } } ``` ```yaml # application.yml # Feature flag for migration features: virtual-threads: enabled: true endpoints: - /api/v2/** - /api/reports/** # Disable for quick rollback # features.virtual-threads.enabled: false ``` > **Caution** > > Do 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. ```java // VirtualThreadLoadTest.java // 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 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. ```java // StructuredConcurrencyExample.java // 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 profileTask = scope.fork(() -> userService.getProfile(userId)); Subtask> notifTask = scope.fork(() -> notificationService.getRecent(userId)); Subtask 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. ```java // ComparisonService.java // 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 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 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. ```java // ThreadLocalVsScopedValue.java // Comparing context approaches public class ThreadLocalVsScopedValue { // CLASSIC APPROACH: ThreadLocal // Works but expensive with millions of VT private static final ThreadLocal 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 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. ## 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 `synchronized` to `ReentrantLock` **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. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/en/blog/spring-boot/spring-boot-virtual-threads-interview