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 de la JVM étaient coûteux en ressources, la synchronisation 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 Java a longtemps fait partie du quotidien des développeurs backend. Beaucoup ont commencé avec 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 constitué une avancée réelle : déléguer la gestion du cycle de vie des threads à un pool, partager les ressources, limiter la contention. Mais le problème de fond demeurait entier : un thread OS bloqué sur une I/O est un thread OS perdu, que ce soit le vôtre ou celui du pool.
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. 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.
Cette instruction n’est jamais atomique. Elle se décompose en trois opérations distinctes : lecture → modification → écriture. Plusieurs threads peuvent alors entrer en compétition et interférer les uns avec les autres.
Ce sont des comportements structurels, pas des cas exceptionnels :
Ces bugs apparaissent naturellement dès que la concurrence n’est pas explicitement modélisée dans la conception du système. Ils ne sont pas immédiatement visibles dans le code, dépendent 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 brivety
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, ces patterns peuvent générer une forte pression mémoire.
Voici une grille de lecture pour orienter vos choix :
| Contexte | Approche recommandée |
| Charge principalement I/O (HTTP, BDD, messaging) | Threads virtuels (Java 21+) |
| Orchestration de tâches, composition asynchrone | CompletableFuture |
| Traitements CPU-intensifs sur collections en mémoire | Streams parallèles / Fork-Join |
| Tâches liées avec portée de vie commune | Structured Concurrency (Java 21+) |
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 et la Structured Concurrency 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.