Si vous développez des systèmes backend sans réfléchir explicitement à la concurrence Java, vous en faites déjà sans le savoir.
Chaque requête HTTP traitée par votre application Spring Boot, chaque job batch exécuté en parallèle, chaque appel vers des services downstream ou des bases de données… tout cela repose sur des mécanismes concurrents. La véritable question n’est pas de savoir si votre application exécute des traitements concurrents, mais si vous contrôlez leur comportement — ou si vous espérez simplement que tout se passera bien en production.
Pendant des années, Java a rendu cette problématique complexe : les threads étaient coûteux en ressources, la synchronisation était difficile à raisonner, et la moindre erreur pouvait introduire des race conditions, des deadlocks ou des problèmes de contention particulièrement difficiles à reproduire — souvent découverts en production à 2 h du matin.
Avec Java 21, le modèle de concurrence évolue de manière significative.
En pratique, la gestion explicite des threads a longtemps fait partie du quotidien des développeurs backend. Beaucoup ont commencé par un exemple comme celui-ci :
new Thread(() -> doWork()).start();
Et avec le temps, nous avons tous compris les limites de cette approche.
Les threads sont des ressources gérées par le système d’exploitation : coûteux à créer, lourds à maintenir et intrinsèquement limités en nombre. Les utiliser comme unité principale de concurrence revient à provisionner un serveur dédié pour chaque requête; faisable techniquement, mais difficilement viable à grande échelle.
Les ExecutorService ont représenté une avancée importante : mutualiser la gestion du cycle de vie des threads, partager les ressources et mieux contrôler la contention.
Mais le problème fondamental restait inchangé : un thread du système d’exploitation bloqué sur une opération d’I/O demeure une ressource immobilisée — qu’il appartienne à votre code ou à un pool de threads.
Le véritable enjeu : quand vous pensez « Je dois gérer 10 000 requêtes », ce que vous voulez réellement exprimer est : « Je dois gérer 10 000 tâches qui peuvent se bloquer, attendre et se terminer indépendamment. »
Le modèle de concurrence de Java repose historiquement sur la mémoire partagée mutable. C’est précisément ce qui rend les applications concurrentes difficiles à raisonner.
Même une ligne de code en apparence anodine peut introduire un comportement imprévisible :
counter++;
Non pas parce qu’elle est syntaxiquement ou fonctionnellement erronée, mais précisément parce qu’elle paraît inoffensive.
Ce sont des comportements structurels, et non des cas exceptionnels :
Ces problèmes apparaissent naturellement dès lors que la concurrence n’est pas explicitement modélisée dans la conception du système. Ils ne sont pas visibles à la lecture du code, dépendent fortement du timing d’exécution, et sont particulièrement difficiles à reproduire et à diagnostiquer.
Java met à disposition un ensemble riche de primitives de synchronisation :
Le problème ne réside pas dans l’absence de mécanisme, mais dans la capacité à déterminer quand ces mécanismes ne doivent pas être utilisés.
Une erreur courante consiste à recourir trop vite aux verrous (locks) comme solution par défaut. Voici la hiérarchie recommandée :
Avec l’introduction de CompletableFuture dans Java 8, on a clairement assisté à une évolution importante du modèle asynchrone.
Pour la première fois, il devenait possible d’écrire du code non bloquant sans gérer explicitement la création et la coordination des threads :
CompletableFuture.supplyAsync(() -> fetchData())
.thenApply(data -> transform(data))
.thenAccept(result -> save(result));
C’est puissant, composable, et cela permet de limiter le blocage des threads. Cependant, cette approche introduit aussi de nouveaux compromis. La gestion des erreurs, notamment,
n’est pas toujours intuitive :
CompletableFuture.supplyAsync(() -> fetchData())
.thenApply(data -> transform(data))
.exceptionally(ex -> fallback()) // quelle étape exactement ?
.thenAccept(result -> save(result));
Contrairement à un try-catch séquentiel, il n’est pas immédiatement évident de savoir quelle étape a échoué ni quelle partie de la pipeline a été court-circuitée. Le débogage des chaînes longues peut rapidement devenir un exercice fastidieux.
En d’autres termes, CompletableFuture a résolu une partie du problème, mais n’a pas simplifié la manière de penser la concurrence.
Des frameworks comme WebFlux avaient apporté une réponse aux workloads I/O intensifs, mais au prix d’un modèle mental radicalement différent (Mono, Flux, backpressure). Java 21 change profondément les règles du jeu avec les threads virtuels.
Avec les threads virtuels, Java introduit un modèle où les threads ne sont plus une contrainte de scalabilité :
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {executor.submit(() -> handleRequest(userId));}// rest omitted for brevity
Lorsqu’un appel bloquant survient sur un thread virtuel :
Résultat : bloquer un thread virtuel ne coûte presque rien. C’est l’exécution qui se met en attente, pas le thread OS.
Le pinning. Lorsqu’un thread virtuel exécute un bloc synchronized contenant une opération bloquante, il reste attaché (pinned) à son thread OS — annulant l’effet de scalabilité pour ce chemin d’exécution. Solution : remplacer synchronized par ReentrantLock dans ces contextes, ou éviter les appels bloquants à l’intérieur des blocs synchronisés.
ThreadLocal et ressources liées au thread. Des frameworks comme Spring ou Hibernate utilisent ThreadLocal pour stocker des contextes de requête (session, transaction, tenant). Avec des millions de threads virtuels potentiels, le nombre de copies ThreadLocal simultanées peut exploser de façon inattendue.
Voici une grille de lecture pour orienter vos choix :
| Contexte | Approche recommandée |
| Charge principalement I/O (HTTP, BDD, messaging) | Threads virtuels (Java 21+) |
| Combinaison de résultats parallèles, intégration d’APIs async | CompletableFuture |
| Traitements CPU-intensifs sur collections en mémoire | Streams parallèles / Fork-Join |
La concurrence Java a longtemps été une source de complexité — des détails d’implémentation qui s’immisçaient dans la logique métier. Les threads virtuels, CompletableFuture ne sont pas de simples nouvelles API : ils représentent un changement de niveau d’abstraction.
L’idée centrale est de décrire ce que votre programme doit faire, et de laisser la JVM décider comment l’exécuter efficacement. C’est cette séparation qui distingue un code concurrent robuste d’un code qui fonctionne par chance.
Java 21 offre enfin les outils pour écrire du code concurrent lisible, testable et scalable, sans sacrifier l’un au profit des autres.