Migration de Spring WebFlux vers les Virutal Threads
Une révolution pour les applications bancaires à haute concurrence
Les applications bancaires modernes traitent des millions de transactions simultanées : virements SEPA, prélèvements automatiques, signatures électroniques, consultations de solde, notifications temps réel… Chaque opération mobilise des ressources serveur précieuses. Face à cette montée en charge, deux paradigmes s’affrontent : la programmation réactive avec Spring WebFlux, longtemps considérée comme la référence de la haute concurrence, et les Virtual Threads introduits en Java 21 via le projet Loom.
Cet article explore comment une banque fictive a migré son backend de transactions de WebFlux vers les Virtual Threads, en analysant les gains mesurés en termes de débit, latence, consommation mémoire et maintenabilité du code.
Pourquoi cette migration ?
- Complexité accrue du code réactif
- Difficulté de débogage et de traçage
- Courbe d’apprentissage élevée
- Virtual Threads : même performance, code impératif
- Gains mesurés jusqu’à 40% sur le débit
Le Problème : Des Milliers de Transactions, Des Ressources Limitées
Une application bancaire gère en production jusqu’à 50 000 transactions par seconde aux heures de pointe. Chaque virement, chaque prélèvement, chaque appel à un service de signature mobilise un thread du système d’exploitation pour attendre la réponse d’une base de données, d’un service tiers ou d’un message broker. C’est précisément ce modèle « thread par requête » classique qui pose problème.
Le modèle Thread Classique (Platform Threads)
Dans un serveur Tomcat classique, chaque requête HTTP occupe un thread OS pendant toute sa durée de vie. Or, la majorité du temps est passée en attente I/O (base de données, API externe, Kafka…). Un thread OS pèse environ 1 à 2 Mo de mémoire pour sa pile. Un pool de 200 threads — limite courante — signifie donc une saturation rapide sous forte charge.
par thread OS en mémoire pile
threads pool Tomcat par défaut
du temps passé en attente I/O
Le modèle Réactif (WebFlux / Project Reactor)
Spring WebFlux résout ce problème en adoptant un modèle non-bloquant event-loop inspiré de Node.js. Quelques threads suffisent pour traiter des milliers de requêtes grâce à l’utilisation de Mono et Flux. Mais cela impose une réécriture complète du code en style fonctionnel-réactif, introduisant une complexité cognitive considérable pour les équipes habituées à la programmation impérative.
✅ Avantage
Excellent débit, faible consommation de threads OS
❌ Inconvénient
Code difficile à lire, déboguer, tester et maintenir
Introduction aux Virtual Threads — Le Projet Loom
Qu’est-ce qu’un Virtual Thread ?
Introduits en preview dans Java 19 et disponibles en version stable depuis Java 21 (LTS), les Virtual Threads sont des threads gérés par la JVM et non directement par le système d’exploitation. Ils s’appuient sur un nombre réduit de carrier threads (threads porteurs, qui eux sont des threads OS) pour exécuter des milliers, voire des millions de virtual threads.
Le mécanisme clé est le mount/unmount automatique : lorsqu’un virtual thread se bloque sur une opération I/O (lecture base de données, appel HTTP, lecture Kafka…), la JVM le démonte du carrier thread et monte un autre virtual thread en attente d’exécution. Ce changement de contexte est extrêmement léger — quelques dizaines de ko en mémoire, contre 1–2 Mo pour un thread OS.

Comparaison des modèles
- Ultra-légers
Quelques Ko en mémoire seulement. La JVM peut en créer des millions sans saturer la RAM du serveur, contrairement aux threads OS. - Code Impératif
Écrire du code synchrone classique comme avec les threads OS. Fini les chaînes flatMap/map/subscribe du monde réactif. - Montage Automatique
La JVM gère automatiquement le blocage I/O : un thread bloqué libère son carrier thread, qui peut immédiatement servir un autre virtual thread. - Intégration Spring
Spring Boot 3.2+ active nativement les virtual threads via une simple propriété de configuration. Zéro refactoring du code métier existant.
Architecture Avant / Après
🔴 Architecture WebFlux (Avant)
L’architecture réactive reposait sur une pile entièrement non-bloquante : Spring WebFlux + R2DBC (JDBC réactif) + Reactor Kafka. Chaque couche devait retourner un Mono<T> ou un Flux<T>, propageant le paradigme réactif à travers toutes les couches applicatives — controller, service, repository. Le moindre appel bloquant dans la chaîne risquait de saturer l’event-loop et de dégrader l’ensemble du service.
// Avant — Spring WebFlux
@GetMapping("/virement/{id}")
public Mono<ResponseEntity<VirementDTO>> getVirement(
@PathVariable String id) {
return virementService.findById(id)
.flatMap(v -> compteService.findById(v.getCompteId())
.map(c -> VirementDTO.of(v, c)))
.map(ResponseEntity::ok)
.defaultIfEmpty(
ResponseEntity.notFound().build());
}
🟢 Architecture Virtual Threads (Après)
Avec la migration vers les Virtual Threads, La banque a pu revenir à une pile Spring MVC classique avec JDBC et Spring Kafka synchrone. Le code redevient impératif, lisible, et testable unitairement de façon triviale. La configuration est minimale : une seule ligne dans application.properties suffit à activer les virtual threads globalement sur Tomcat.
// Après — Spring MVC + Virtual Threads
@GetMapping("/virement/{id}")
public ResponseEntity<VirementDTO> getVirement(
@PathVariable String id) {
Virement v = virementService.findById(id)
.orElseThrow(NotFoundException::new);
Compte c = compteService.findById(
v.getCompteId());
return ResponseEntity.ok(
VirementDTO.of(v, c));
}
Configuration Spring Boot 3.2+ : Une seule propriété active les virtual threads sur le pool de threads de Tomcat : spring.threads.virtual.enabled=true
Spring Boot se charge du reste automatiquement — aucune modification des controllers, services ou repositories n’est nécessaire.
Exemples de Code — Cas Métier Bancaires
Service de Virement SEPA avec Virtual Threads
Service de Virement
@Service
@Transactional
public class VirementService {
private final CompteRepository compteRepo;
private final VirementRepository virementRepo;
private final NotificationService notifService;
private final AuditService auditService;
public VirementDTO effectuerVirement(
VirementRequest request) {
// Vérification solde — appel JDBC bloquant
// Le VT se démonte pendant l'attente DB
Compte source = compteRepo
.findByIban(request.getIbanSource())
.orElseThrow(() -> new CompteNotFoundException(
request.getIbanSource()));
if (source.getSolde().compareTo(
request.getMontant()) < 0) {
throw new SoldeInsuffisantException();
}
Compte destination = compteRepo
.findByIban(request.getIbanDestinataire())
.orElseThrow();
// Débit / Crédit
source.debiter(request.getMontant());
destination.crediter(request.getMontant());
compteRepo.save(source);
compteRepo.save(destination);
Virement virement = virementRepo.save(
Virement.builder()
.montant(request.getMontant())
.source(source)
.destination(destination)
.statut(StatutVirement.EXECUTE)
.build());
// Notification async via ExecutorService
// avec Virtual Thread dédié
notifService.notifierAsync(virement);
auditService.enregistrer(virement);
return VirementDTO.from(virement);
}
}
Controller REST & Configuration
@RestController @RequestMapping("/api/v1/virements")
public class VirementController {
private final VirementService service;
@PostMapping
public ResponseEntity<VirementDTO> effectuerVirement(
@Valid @RequestBody VirementRequest request) {
VirementDTO result = service.effectuerVirement(request);
return ResponseEntity
.status(HttpStatus.CREATED).body(result);
}
}
// Configuration Virtual Threads
// application.properties
spring.threads.virtual.enabled=true
// Ou configuration Java explicite
@Configuration
public class VirtualThreadConfig {
@Bean
public TomcatProtocolHandlerCustomizer<?> virtualThreadCustomizer() {
return handler -> handler
.setExecutor(
Executors.newVirtualThreadPerTaskExecutor());
}
// Executor pour tâches async métier
@Bean("virementExecutor")
public Executor virementExecutor() {
return Executors
.newVirtualThreadPerTaskExecutor();
}
}
Parallélisation des Appels — Structured Concurrency
L’une des forces des Virtual Threads combinés à la Structured Concurrency (Java 21, StructuredTaskScope) est la capacité d’exécuter des appels I/O parallèles de façon lisible et sûre. Dans un scénario bancaire, la validation d’un virement implique souvent plusieurs vérifications simultanées : scoring anti-fraude, vérification IBAN, contrôle LCB-FT (lutte contre le blanchiment).
// Structured Concurrency avec Virtual Threads
// Java 21+ — vérifications parallèles
public ValidationResult validerVirement(
VirementRequest request)
throws InterruptedException {
try (var scope = new StructuredTaskScope
.ShutdownOnFailure()) {
// Lancement parallèle des 3 vérifications
// Chacune dans son propre Virtual Thread
var scoringFraude = scope.fork(() ->
fraudeService.calculerScore(
request.getIbanSource(),
request.getMontant()));
var validIban = scope.fork(() ->
ibanService.valider(
request.getIbanDestinataire()));
var lcbft = scope.fork(() ->
lcbftService.controler(
request.getIbanSource(),
request.getIbanDestinataire(),
request.getMontant()));
// Attente de toutes les tâches
// (ou échec si l'une d'elles échoue)
scope.join()
.throwIfFailed();
return ValidationResult.builder()
.scoreFraude(scoringFraude.get())
.ibanValide(validIban.get())
.lcbftOk(lcbft.get())
.build();
}
}
Avantages de la Structured Concurrency
- Lisibilité maximale
Le code exprime clairement quelles tâches sont parallèles et quand on attend leurs résultats, sans callbacks ni chaînes d’opérateurs réactifs. - Gestion d’erreurs intuitive
ShutdownOnFailure annule automatiquement toutes les tâches sœurs si l’une échoue. Plus besoin de gérer manuellement les onError réactifs. - Pas de fuite de ressources
Le bloc try-with-resources garantit que tous les virtual threads sont terminés avant de quitter le scope, éliminant les fuites de threads.
En WebFlux, cette même logique aurait nécessité Mono.zip() avec gestion explicite des erreurs sur chaque branche, rendant le code nettement plus verbeux et sujet aux erreurs.
Résultats de Performance
Mesures réalisées sous JMeter avec 5 000 utilisateurs virtuels simultanés, serveur 8 cœurs / 16 Go RAM

Débit — De 42 000 à 58 500 req/s en charge maximale
Latence P99 — De 95ms à 62ms au 99e percentile
Mémoire — Réduction de 1850 Mo à 1420 Mo de heap
Moins de code — Réduction du code réactif boilerplate estimée à 70%
Pièges et Bonnes Pratiques de la Migration
⚠️ Pièges à Éviter
- Thread Pinning
Un virtual thread est « épinglé » (pinned) à son carrier thread s’il se bloque à l’intérieur d’un bloc synchronized ou d’une méthode native. Dans ce cas, il se comporte comme un thread OS classique. Préférez ReentrantLock à synchronized pour les sections critiques longues. Depuis Java 24, ce comportement a été amélioré. - ThreadLocal mal utilisé
Avec des millions de virtual threads potentiels, les ThreadLocal peuvent engendrer des fuites mémoire massives si non nettoyés. Préférez les Scoped Values (Java 21) pour propager des contextes immuables (ex : userId, tenantId) à travers les virtual threads. - Pools de connexions limités
Les virtual threads amplifient la pression sur les pools de connexions JDBC. Si votre pool HikariCP est limité à 20 connexions, 1000 virtual threads en attente créent une file d’attente. Tuner maximumPoolSize en conséquence ou utiliser un sémaphore.
✅ Bonnes Pratiques
- Migrez progressivement
Commencez par activer spring.threads.virtual.enabled=true sans toucher au code. Mesurez. Puis supprimez progressivement les abstractions réactives. - Auditez les synchronized
Utilisez le flag JVM -Djdk.tracePinnedThreads=full pour identifier les zones de pinning dans votre codebase et librairies tierces. - Adoptez les Scoped Values
Remplacez les ThreadLocal par ScopedValue pour la propagation de contexte (sécurité, traçabilité, multi-tenant). - Surveillez avec Micrometer
Configurez des métriques sur le nombre de virtual threads actifs, les temps d’attente HikariCP et le pinning pour détecter les régressions.
Plan de Migration — Étapes Concrètes

Phase 1 — Prérequis (2 semaines)
- Mise à jour vers Java 21 LTS
- Spring Boot 3.2+ minimum
- Audit des dépendances réactives
- Identification des synchronized critiques
- Mise en place des tests de charge JMeter/Gatling
Phase 2 — Activation (1 semaine)
- Propriété spring.threads.virtual.enabled=true
- Tests de régression complets
- Benchmarks de performance baseline
- Activation du flag de trace pinning
- Monitoring Micrometer/Grafana
Phase 3 — Refactoring (4–6 semaines)
- Remplacement R2DBC → JDBC
- Suppression des Mono et Flux
- Migration ThreadLocal → ScopedValue
- Adoption StructuredTaskScope
- Benchmarks finaux et go-live
Conclusion — Virtual Threads, l’Avenir des Applications Java
La migration de Spring WebFlux vers les Virtual Threads représente bien plus qu’un simple changement de paradigme technique. C’est un retour au code impératif — lisible, maintenable, testable — sans compromis sur la performance. Le Projet réalise enfin la promesse longtemps attendue : écrire du code synchrone simple qui se comporte de façon asynchrone sous le capot.
Pour les équipes Java, l’impact est immédiat : réduction de la complexité cognitive, disparition des chaînes d’opérateurs réactifs, débogage facilité avec des stack traces lisibles, et intégration naturelle avec l’écosystème Spring existant. Les gains mesurés — +39% de débit, -35% de latence P99, -23% de consommation mémoire — confirment que les virtual threads ne sont pas un compromis mais une amélioration sur tous les fronts pour les workloads I/O-bound.
Spring WebFlux reste pertinent pour des cas très spécifiques : streaming de données continu (Flux), back-pressure explicite, ou intégration avec des systèmes entièrement réactifs. Mais pour la grande majorité des applications bancaires et enterprise, les Virtual Threads sont désormais le choix par défaut.
À Retenir
- Java 21 est stable en production
Virtual Threads en GA depuis sept. 2023 — prêts pour la production bancaire - Migration progressive possible
Activation en une ligne, refactoring incrémental sur plusieurs sprints - Gains mesurables immédiats
+39% débit, -35% latence P99 dès l’activation - Écosystème Spring mature
Spring Boot 3.2+ supporte nativement les virtual threads sur Tomcat, Jetty et Undertow
