BlogPost
18.05.2026

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

Java

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

 

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

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

Moment partagée : le vrai défi de la concurrence Java

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.

Les bugs de concurrence classiques

Ce sont des comportements structurels, pas des cas exceptionnels :

  • Race conditions : résultat dépendant de l’ordre d’exécution des threads
  • Deadlocks : deux threads s’attendent mutuellement indéfiniment
  • Problèmes de visibilité mémoire : un thread ne voit pas les modifications d’un autre

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.

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 gestion est entièrement déléguée à la JVM, et non au système d’exploitation
  • 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 brivety

Lorsqu’un appel bloquant survient sur un thread virtuel :

  • Le thread virtuel indique qu’il attend une opération d’E/S
  • 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, ces patterns peuvent générer une forte pression mémoire.

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+)
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+)

 

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

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