# Spring Boot 3.4 Virtual Threads : questions d'entretien et benchmarks de performance > Maîtrisez les Virtual Threads Java 21 avec Spring Boot 3.4 : 15 questions d'entretien, benchmarks de performance et patterns de migration pour réussir vos entretiens techniques. - Published: 2026-03-15 - Updated: 2026-03-31 - Author: SharpSkill - Tags: spring boot, virtual threads, java 21, project loom, performance - Reading time: 16 min --- Les Virtual Threads représentent l'une des évolutions majeures de Java 21, et Spring Boot 3.4 les intègre nativement. Cette fonctionnalité issue du Project Loom transforme la gestion de la concurrence dans les applications backend. Les entretiens techniques évaluent désormais la compréhension des mécanismes internes, des cas d'usage appropriés et des pièges à éviter. > **Conseil de préparation** > > Les recruteurs distinguent les candidats qui comprennent les Virtual Threads de ceux qui les utilisent aveuglément. Savoir expliquer quand NE PAS les utiliser est aussi important que de connaître leurs avantages. ## Fondamentaux des Virtual Threads ### Question 1 : Qu'est-ce qu'un Virtual Thread et en quoi diffère-t-il d'un Platform Thread ? Un Virtual Thread est un thread léger géré par la JVM plutôt que par le système d'exploitation. Contrairement aux Platform Threads (threads classiques), les Virtual Threads ne mappent pas directement sur un thread OS. La JVM peut en créer des millions avec une empreinte mémoire minimale. ```java // VirtualThreadDemo.java // Comparaison de la création de threads public class VirtualThreadDemo { public void demonstrateDifference() { // Platform Thread : ~1MB de stack par thread // Limite pratique : quelques milliers sur une JVM standard Thread platformThread = new Thread(() -> { performBlockingOperation(); }); // Virtual Thread : ~quelques KB par thread // Peut en créer des millions sans problème Thread virtualThread = Thread.ofVirtual().start(() -> { performBlockingOperation(); }); } // Un Virtual Thread "monte" sur un Platform Thread (carrier) // Lors d'un blocage I/O, il libère le carrier pour d'autres Virtual Threads private void performBlockingOperation() { try { Thread.sleep(1000); // Le Virtual Thread se détache du carrier ici } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } ``` La différence fondamentale réside dans la gestion du blocage. Quand un Platform Thread bloque (I/O, sleep), il reste attaché au thread OS. Un Virtual Thread se "démonte" du carrier thread, permettant à d'autres Virtual Threads d'utiliser ce carrier. ### Question 2 : Comment activer les Virtual Threads dans Spring Boot 3.4 ? Spring Boot 3.4 simplifie l'activation des Virtual Threads à une seule propriété de configuration. L'ensemble du framework s'adapte automatiquement : Tomcat, les contrôleurs REST, et les appels bloquants bénéficient immédiatement de cette optimisation. ```yaml # application.yml # Activation globale des Virtual Threads spring: threads: virtual: enabled: true # Configuration optionnelle du pool Tomcat server: tomcat: threads: max: 200 # Moins critique avec Virtual Threads min-spare: 10 ``` ```java // WebConfig.java // Activation programmatique si nécessaire @Configuration public class WebConfig { @Bean public TomcatProtocolHandlerCustomizer virtualThreadCustomizer() { // Chaque requête HTTP s'exécute sur un Virtual Thread return protocolHandler -> { protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor()); }; } } ``` L'activation modifie le comportement de Tomcat : au lieu d'un pool fixe de threads, chaque requête obtient son propre Virtual Thread. Cette approche élimine le goulot d'étranglement du pool de threads traditionnel. ### Question 3 : Expliquez le concept de "mounting" et "unmounting" Le mounting désigne l'attachement d'un Virtual Thread à un carrier thread (Platform Thread). L'unmounting survient lors d'opérations bloquantes, libérant le carrier pour d'autres Virtual Threads. Ce mécanisme permet une utilisation optimale des ressources CPU. ```java // MountingDemo.java // Illustration du cycle mounting/unmounting public class MountingDemo { public void demonstrateMounting() { Thread.ofVirtual().start(() -> { // MOUNTED : le Virtual Thread utilise un carrier thread System.out.println("Carrier: " + getCurrentCarrier()); // UNMOUNTING : libère le carrier pendant le blocage performDatabaseQuery(); // Appel JDBC bloquant // REMOUNTED : peut être sur un carrier différent System.out.println("Nouveau carrier: " + getCurrentCarrier()); }); } // Les opérations bloquantes déclenchent l'unmounting automatiquement private void performDatabaseQuery() { // Connection JDBC, lecture fichier, appel réseau... // Le Virtual Thread se détache pendant l'attente I/O } private String getCurrentCarrier() { // Récupère le nom du thread porteur actuel return Thread.currentThread().toString(); } } ``` Ce mécanisme est transparent pour le développeur. Le code s'écrit de manière impérative classique, mais la JVM optimise automatiquement l'utilisation des carriers. Un pool de quelques carriers peut servir des millions de Virtual Threads. > **Point technique** > > Le nombre de carrier threads correspond généralement au nombre de cœurs CPU. La JVM ajuste ce pool dynamiquement via le ForkJoinPool. ## Cas d'usage et anti-patterns ### Question 4 : Quand les Virtual Threads apportent-ils un gain de performance ? Les Virtual Threads excellent pour les workloads I/O-bound : appels REST externes, requêtes base de données, lecture de fichiers. Ces opérations passent la majorité du temps en attente, période durant laquelle le Virtual Thread libère son carrier. ```java // IOBoundService.java // Cas idéal pour Virtual Threads @Service public class IOBoundService { private final RestClient restClient; private final UserRepository userRepository; // Chaque appel implique de l'attente réseau et BDD // Les Virtual Threads brillent ici public UserProfile enrichUserProfile(Long userId) { // Appel BDD - le VT se détache pendant la requête SQL User user = userRepository.findById(userId).orElseThrow(); // Appel REST externe - le VT se détache pendant l'attente HTTP ExternalData externalData = restClient .get() .uri("/api/external/{id}", userId) .retrieve() .body(ExternalData.class); // Agrégation des données return new UserProfile(user, externalData); } } ``` Le gain provient de la capacité à traiter plus de requêtes concurrentes. Avec 200 Platform Threads et des requêtes de 100ms, le débit maximal est de 2000 req/s. Avec des Virtual Threads, ce débit peut atteindre 50 000+ req/s sur la même machine. ### Question 5 : Quels sont les anti-patterns à éviter avec les Virtual Threads ? Les Virtual Threads ne conviennent pas aux workloads CPU-bound ni aux cas impliquant du "pinning". Le pinning survient quand un Virtual Thread reste attaché à son carrier malgré un blocage, annulant les bénéfices de la virtualisation. ```java // AntiPatterns.java // Exemples de cas à éviter @Service public class AntiPatterns { // ANTI-PATTERN 1 : Calcul CPU-intensif // Les Virtual Threads n'apportent rien ici public BigInteger computeFactorial(int n) { // 100% CPU, aucun I/O, pas d'unmounting possible BigInteger result = BigInteger.ONE; for (int i = 2; i <= n; i++) { result = result.multiply(BigInteger.valueOf(i)); } return result; // Le carrier est monopolisé tout le long } // ANTI-PATTERN 2 : Synchronized cause le pinning private final Object lock = new Object(); public void pinnedOperation() { synchronized (lock) { // PINNING : le VT reste sur le carrier performDatabaseQuery(); // L'unmounting ne se produit pas ! } } // SOLUTION : Utiliser ReentrantLock private final ReentrantLock reentrantLock = new ReentrantLock(); public void unpinnedOperation() { reentrantLock.lock(); try { performDatabaseQuery(); // Unmounting possible } finally { reentrantLock.unlock(); } } } ``` Le pinning transforme un Virtual Thread en Platform Thread du point de vue des ressources. Les causes principales sont les blocs `synchronized` et les appels JNI natifs. La migration vers `ReentrantLock` résout le premier cas. ### Question 6 : Comment détecter le pinning dans une application ? La JVM offre des options de diagnostic pour identifier les cas de pinning. Ces informations sont essentielles lors de la migration vers les Virtual Threads, car le pinning peut dégrader les performances au lieu de les améliorer. ```java // PinningDiagnostics.java // Configuration et détection du pinning public class PinningDiagnostics { // Option JVM pour logger le pinning // -Djdk.tracePinnedThreads=full (détaillé) // -Djdk.tracePinnedThreads=short (résumé) // Exemple de code causant du pinning public void demonstratePinning() { Thread.ofVirtual().start(() -> { synchronized (this) { // Ce log apparaîtra avec tracePinnedThreads activé try { Thread.sleep(100); // Pinned pendant le sleep ! } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }); } } ``` ```bash # Sortie typique avec -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) ``` L'analyse des logs de pinning révèle les points chauds à corriger. Une application avec un pinning fréquent n'exploite pas pleinement les Virtual Threads et peut même être plus lente qu'avec des Platform Threads. ## Benchmarks de performance ### Question 7 : Quels gains de performance attendre avec les Virtual Threads ? Les benchmarks montrent des améliorations significatives pour les applications I/O-bound typiques. Le gain dépend du ratio temps I/O / temps CPU et du niveau de concurrence requis. ```java // BenchmarkController.java // Endpoint pour mesurer les performances @RestController @RequestMapping("/api/benchmark") public class BenchmarkController { private final ExternalApiClient apiClient; // Simulation d'un endpoint typique avec appels externes @GetMapping("/user/{id}") public ResponseEntity getUser(@PathVariable Long id) { // Trois appels I/O séquentiels 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 # Résultats benchmark - 10 000 requêtes concurrentes # Configuration : 8 cœurs, 16GB RAM, latence simulée 230ms/requête Platform Threads (pool 200) : - Throughput : 850 req/s - P99 latence : 1250ms - Mémoire heap : 2.1GB Virtual Threads : - Throughput : 4200 req/s - P99 latence : 280ms - Mémoire heap : 850MB Gain : x5 throughput, x4.5 réduction latence P99 ``` Les Virtual Threads réduisent également la consommation mémoire car chaque thread n'alloue que quelques KB au lieu de ~1MB pour un Platform Thread. ### Question 8 : Comment configurer les pools de connexion avec les Virtual Threads ? Les pools de connexion (HikariCP, lettuce) deviennent le nouveau goulot d'étranglement avec les Virtual Threads. Un pool de 10 connexions limite à 10 requêtes BDD simultanées, même avec des millions de Virtual Threads. ```yaml # application.yml # Configuration HikariCP optimisée pour Virtual Threads spring: datasource: hikari: # Plus de connexions car les Virtual Threads permettent plus de concurrence maximum-pool-size: 50 minimum-idle: 10 # Timeout plus court car plus de demandes concurrentes connection-timeout: 5000 # Validation rapide validation-timeout: 3000 # Redis avec Lettuce - déjà async-friendly data: redis: lettuce: pool: max-active: 50 max-idle: 20 ``` ```java // ConnectionPoolMonitor.java // Monitoring du pool pour éviter la 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()); // Alerte si trop de threads en attente if (pool.getThreadsAwaitingConnection() > 100) { log.warn("Connection pool contention detected!"); } } } ``` Le dimensionnement du pool dépend de la capacité de la base de données, pas du nombre de Virtual Threads. Une base PostgreSQL standard supporte ~100-200 connexions actives. ### Question 9 : Comment mesurer l'impact des Virtual Threads avec Micrometer ? Micrometer et Spring Boot Actuator fournissent des métriques essentielles pour évaluer l'efficacité des Virtual Threads. Ces métriques permettent de valider les gains et d'identifier les problèmes potentiels. ```java // VirtualThreadMetrics.java // Métriques personnalisées pour Virtual Threads @Component public class VirtualThreadMetrics { private final MeterRegistry registry; private final AtomicLong activeVirtualThreads = new AtomicLong(0); @PostConstruct public void registerMetrics() { // Compteur de Virtual Threads actifs Gauge.builder("virtual.threads.active", activeVirtualThreads, AtomicLong::get) .description("Number of active virtual threads") .register(registry); // Métriques JVM pour les carriers Gauge.builder("virtual.threads.carriers", this::getCarrierCount) .description("Number of carrier threads") .register(registry); } private double getCarrierCount() { // Récupère le nombre de carriers du ForkJoinPool return ForkJoinPool.commonPool().getPoolSize(); } // Intercepteur pour tracer les requêtes public void trackVirtualThread(Runnable task) { activeVirtualThreads.incrementAndGet(); try { task.run(); } finally { activeVirtualThreads.decrementAndGet(); } } } ``` ```yaml # application.yml # Exposition des métriques management: endpoints: web: exposure: include: health,metrics,prometheus metrics: tags: application: ${spring.application.name} ``` L'analyse des métriques révèle le ratio Virtual Threads / Carriers et permet d'identifier les périodes de contention sur le pool de connexions. ## Migration et compatibilité ### Question 10 : Quelles bibliothèques sont compatibles avec les Virtual Threads ? La compatibilité dépend de l'utilisation de blocs `synchronized` et d'appels natifs. L'écosystème Spring est largement compatible, mais certaines bibliothèques requièrent des versions spécifiques. ```java // CompatibilityCheck.java // Vérification de la compatibilité des dépendances @Configuration public class CompatibilityCheck { // Bibliothèques compatibles (versions recommandées) // - Spring Boot 3.2+ (support natif) // - HikariCP 5.1+ (ReentrantLock au lieu de synchronized) // - Lettuce 6.3+ (I/O non-bloquant) // - Jackson 2.16+ (pas de synchronized) // Bibliothèques nécessitant attention // - JDBC drivers : vérifier la version // - Certains clients HTTP legacy @Bean public CommandLineRunner checkCompatibility() { return args -> { // Log des versions pour audit log.info("Java version: {}", System.getProperty("java.version")); log.info("Virtual threads available: {}", Thread.ofVirtual() != null); // Vérification du support if (Runtime.version().feature() < 21) { throw new IllegalStateException( "Java 21+ required for Virtual Threads"); } }; } } ``` La plupart des frameworks modernes ont migré vers `ReentrantLock`. Pour les dépendances legacy, des tests de charge avec `-Djdk.tracePinnedThreads=short` révèlent les problèmes de pinning. ### Question 11 : Comment migrer progressivement vers les Virtual Threads ? Une migration progressive permet de valider les gains et d'identifier les problèmes sans risquer la production. La stratégie recommandée isole les endpoints Virtual Threads des endpoints traditionnels. ```java // DualExecutorConfig.java // Configuration pour migration progressive @Configuration public class DualExecutorConfig { // Executor traditionnel pour les endpoints legacy @Bean("platformExecutor") public ExecutorService platformExecutor() { return Executors.newFixedThreadPool(200); } // Executor Virtual Threads pour les nouveaux endpoints @Bean("virtualExecutor") public ExecutorService virtualExecutor() { return Executors.newVirtualThreadPerTaskExecutor(); } } @RestController @RequestMapping("/api/v2") public class MigratedController { @Qualifier("virtualExecutor") private final ExecutorService executor; // Endpoint migré vers Virtual Threads @GetMapping("/users/{id}") public CompletableFuture getUser(@PathVariable Long id) { return CompletableFuture.supplyAsync(() -> { // Code métier inchangé return userService.findById(id); }, executor); } } ``` ```yaml # application.yml # Feature flag pour la migration features: virtual-threads: enabled: true endpoints: - /api/v2/** - /api/reports/** # Désactiver pour rollback rapide # features.virtual-threads.enabled: false ``` > **Point de vigilance** > > Ne pas activer les Virtual Threads globalement avant d'avoir testé toutes les dépendances. Le pinning peut dégrader les performances de manière significative. ### Question 12 : Quels tests effectuer avant la mise en production ? La validation des Virtual Threads nécessite des tests de charge, des tests de pinning et des tests de compatibilité. Ces tests doivent simuler des conditions de production réalistes. ```java // VirtualThreadLoadTest.java // Test de charge avec JUnit et 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(); // Lance 5000 requêtes simultanées 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); // Assertions sur les performances assertThat(successCount.get()).isGreaterThan(4900); // >98% succès assertThat(errorCount.get()).isLessThan(100); } } ``` Les tests doivent couvrir les scénarios de contention (pool de connexions saturé), de timeout, et de charge soutenue sur plusieurs minutes. ## Questions avancées ### Question 13 : Comment les Virtual Threads interagissent-ils avec le Structured Concurrency ? Le Structured Concurrency (JEP 453) complète les Virtual Threads en garantissant que les tâches concurrentes partagent le même cycle de vie. Cette approche simplifie la gestion des erreurs et des annulations. ```java // StructuredConcurrencyExample.java // Combinaison Virtual Threads + Structured Concurrency public class StructuredConcurrencyExample { public UserDashboard fetchDashboard(Long userId) throws Exception { // StructuredTaskScope garantit que toutes les tâches se terminent ensemble try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { // Trois tâches parallèles sur Virtual Threads Subtask profileTask = scope.fork(() -> userService.getProfile(userId)); Subtask> notifTask = scope.fork(() -> notificationService.getRecent(userId)); Subtask balanceTask = scope.fork(() -> accountService.getBalance(userId)); // Attend toutes les tâches ou échoue sur la première erreur scope.join(); scope.throwIfFailed(); // Toutes les tâches ont réussi - agrégation sécurisée return new UserDashboard( profileTask.get(), notifTask.get(), balanceTask.get() ); } // Si une tâche échoue, les autres sont automatiquement annulées } } ``` Le Structured Concurrency évite les fuites de threads et simplifie le débogage car la pile d'appels reflète la structure logique du code. ### Question 14 : Quelle est la différence entre Virtual Threads et reactive programming ? Les deux approches résolvent le même problème (efficacité I/O) mais avec des modèles de programmation différents. Les Virtual Threads permettent un code impératif classique, tandis que le reactive nécessite une réécriture en flux. ```java // ComparisonService.java // Même logique : impératif vs reactive @Service public class ComparisonService { // APPROCHE VIRTUAL THREADS : code impératif classique // Simple à lire, déboguer et maintenir 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); } // APPROCHE REACTIVE : flux et opérateurs // Plus complexe mais backpressure natif 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() )); } } ``` | Critère | Virtual Threads | Reactive | |---------|-----------------|----------| | Courbe d'apprentissage | Faible | Élevée | | Débogage | Stack traces classiques | Complexe | | Backpressure | Manuel | Natif | | Écosystème | Croissant | Mature | | Migration legacy | Simple | Réécriture | Les Virtual Threads sont recommandés pour les nouvelles applications et les migrations. Le reactive reste pertinent pour les cas nécessitant un backpressure sophistiqué. ### Question 15 : Comment gérer les ThreadLocal avec les Virtual Threads ? Les ThreadLocal fonctionnent avec les Virtual Threads mais consomment de la mémoire pour chaque instance. Les Scoped Values (JEP 446) offrent une alternative plus efficace pour le partage de contexte. ```java // ThreadLocalVsScopedValue.java // Comparaison des approches de contexte public class ThreadLocalVsScopedValue { // APPROCHE CLASSIQUE : ThreadLocal // Fonctionne mais coûteux en mémoire avec millions de VT private static final ThreadLocal requestContext = new ThreadLocal<>(); public void processWithThreadLocal(Request request) { requestContext.set(new RequestContext(request.getTraceId())); try { // Le contexte est accessible partout dans le thread processRequest(); } finally { requestContext.remove(); // Important pour éviter les fuites } } // APPROCHE MODERNE : ScopedValue (Java 21+) // Plus efficace, immuable, scope explicite private static final ScopedValue CONTEXT = ScopedValue.newInstance(); public void processWithScopedValue(Request request) { ScopedValue.where(CONTEXT, new RequestContext(request.getTraceId())) .run(() -> { // Le contexte est accessible dans ce scope processRequest(); // Pas besoin de cleanup - automatique à la fin du scope }); } private void processRequest() { // Accès au contexte String traceId = CONTEXT.isBound() ? CONTEXT.get().getTraceId() : "unknown"; log.info("Processing with trace: {}", traceId); } } ``` Les ScopedValues sont recommandés pour les nouveaux développements. Pour le code legacy utilisant ThreadLocal, une migration progressive est conseillée. ## Conclusion Les Virtual Threads transforment le développement Java backend en permettant un code simple et performant. Les points essentiels à retenir : **Fondamentaux :** - ✅ Activer avec `spring.threads.virtual.enabled=true` - ✅ Idéal pour les workloads I/O-bound (REST, BDD, fichiers) - ✅ Éviter pour les calculs CPU-intensifs **Performance :** - ✅ Dimensionner les pools de connexion (nouveau goulot d'étranglement) - ✅ Surveiller le pinning avec `-Djdk.tracePinnedThreads` - ✅ Migrer `synchronized` vers `ReentrantLock` **Migration :** - ✅ Tester progressivement par groupe d'endpoints - ✅ Valider la compatibilité des dépendances - ✅ Utiliser Structured Concurrency pour la concurrence parallèle La maîtrise des Virtual Threads distingue les candidats qui comprennent les enjeux de performance des applications modernes. Ces concepts sont désormais incontournables dans les entretiens techniques Spring Boot. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/fr/blog/spring-boot/spring-boot-virtual-threads-interview