BlogPost
18.05.2026

Concurrence en Java : ne vous souciez plus de la gestion des threads

Blogpost Canopee concurrence JAV

Introduction

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.

 

Les threads n’ont jamais été l’abstraction que nous voulions

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. »

La mémoire partagée : le véritable point de complexité de la concurrence

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.

En réalité, cette opération n’est pas atomique. Elle se décompose en plusieurs étapes distinctes :
Lecture de la valeur, incrément, puis écriture en mémoire. Sans mécanisme de synchronisation approprié, plusieurs threads peuvent alors interférer les uns avec les autres et provoquer des race conditions.

Les bugs classiques de la concurrence

Ce sont des comportements structurels, et non des cas exceptionnels :

  • Race conditions : résultat dépendant de l’entrelacement des opérations concurrentes sur un état partagé
  • Deadlocks : deux threads (ou plus) se bloquent mutuellement en attendant indéfiniment la libération de ressources
  • Problèmes de visibilité mémoire : un thread ne perçoit pas immédiatement (ou correctement) les modifications effectuées par un autre

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.

Synchronisation Java : indispensable, mais fréquemment mal maîtrisée

Java met à disposition un ensemble riche de primitives de synchronisation :

  • synchronized — le mot-clé de base pour l’exclusion mutuelle
  • volatile — garantit la visibilité mémoire entre threads
  • ReentrantLock — verrou réentrant avec plus de flexibilité
  • Types atomiques — AtomicInteger, AtomicLong, AtomicReference…
  • Synchronizers — Semaphore, CountDownLatch, CyclicBarrier
  • Collections concurrentes — java.util.concurrent (ConcurrentHashMap, BlockingQueue…)

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.

Hiérarchie des stratégies : du plus sûr au plus risqué

Une erreur courante consiste à recourir trop vite aux verrous (locks) comme solution par défaut. Voici la hiérarchie recommandée :

  • Éliminer l’état partagé — si deux threads ne partagent rien, il n’y a rien à synchroniser
  • Privilégier l’immuabilité — les record Java sont ici particulièrement pertinents ; un objet immuable peut être lu par n threads simultanément sans protection
  • Abstractions de haut niveau — ConcurrentHashMap, AtomicLong, BlockingQueue
  • En dernier recours seulement — verrous bas niveau (synchronized, ReentrantLock)

CompletableFuture : une avancée significative

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.

Les Threads virtuels Java 21

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.

Ce que les threads virtuels changent concrètement

Avec les threads virtuels, Java introduit un modèle où les threads ne sont plus une contrainte de scalabilité :

  • Ils sont légers, leur création est quasi gratuite
  • Leur ordonnancement est pris en charge par la JVM — sans qu’un thread OS soit alloué pour chacun.
  • Il devient possible d’en lancer des milliers, voire des millions, sans épuiser les ressources système

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {executor.submit(() -> handleRequest(userId));}// rest omitted for brevity

Lorsqu’un appel bloquant survient sur un thread virtuel :

  • Le thread virtuel indique qu’il attend une opération d’I/O
  • La JVM le détache du thread OS (unmounting)
  • Le thread OS est libéré et peut exécuter d’autres tâches
  • Une fois la réponse disponible, le thread virtuel est remappé et reprend son exécution

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.

Points de vigilance avec les threads virtuels

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.

Alors, quelle approche adopter concrètement ?

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

 

Conclusion

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.

sofiane
Sofiane BRANECI - Consultant Canopee
“ Diplômé de la Sorbonne, Sofiane s’est spécialisé dans la technologie Java et a eu l’occasion de travailler lors de son stage sur l’optimisation et l’amélioration d’un outil stratégique. Il a également pu concevoir et mettre en œuvre une solution d’export de données optimisée pour supporter les différentes masses données générées par les utilisateurs sous forme d’API. Sofiane fait également parti de nos Tech’Angels et prend part activement à l’animation de notre communauté Tech. ”