Bonnes pratiques du multithreading en pratique — édition Java : les usages à l'ère des threads virtuels
· Go Komura · Multithreading, Java, Application métier, Investigation de bugs, Conception
« On veut paralléliser en Java le traitement par lots d’un système métier. » « Dans notre application web Spring, le cache partagé se corrompt de temps en temps. » « On a hérité d’une vieille application Swing truffée de new Thread. » ── Java est un langage qui intègre le multithreading depuis JDK 1.0, qui dispose de la boîte à outils mature java.util.concurrent, et qui a encore réécrit les évidences du traitement concurrent avec les threads virtuels de JDK 21. Les outils étant nombreux, savoir « lequel choisir » se traduit directement en qualité de conception.
Cet article est l’édition Java de la série pratique sur le multithreading. Destiné aux développeurs qui écrivent des systèmes métier, des traitements par lots ou des applications serveur en Java, il traduit dans les outils de Java (en visant principalement JDK 21 et ses versions LTS ultérieures) les principes de conception du multithreading ── ne jamais créer de threads directement, réduire l’état mutable partagé, la discipline des verrous, concevoir d’abord comment arrêter ── tout en couvrant les usages propres à l’ère des threads virtuels et les pièges spécifiques à Java, sur la base de sources primaires à la date d’août 2026. Il est écrit pour se lire de manière autonome. Les mêmes principes déclinés dans d’autres langages existent aussi : « édition .NET », « édition C++ », « édition C ».
1. La conclusion, d’abord
- Ne pas écrire
new Threaddans le code métier vaut aussi pour Java. Confiez les tâches à unExecutorServiceet laissez la bibliothèque gérer le cycle de vie des threads.1 - Les tâches dominées par l’attente d’E/S vont aux threads virtuels. Officialisés dans JDK 21, les threads virtuels s’utilisent en « un thread virtuel par tâche » et ne doivent jamais être mis en pool. La limitation du nombre d’exécutions simultanées se fait avec un
Semaphore, pas avec un pool.23 - Les threads virtuels sont un outil de débit, pas un outil pour accélérer le calcul. La parallélisation des tâches liées au CPU reste, comme avant, l’affaire d’un pool de threads de plateforme dimensionné autour du nombre de cœurs (pool fixe ou parallel stream).3
- Les verrous doivent utiliser un objet de verrouillage
private finalou unReentrantLockdédié.synchronized(this)ou un verrou sur un objet public entre en collision avec du code externe. Si vous avez besoin d’une acquisition avec délai d’expiration (tryLock), utilisezReentrantLock. - Dans JDK 21 à 23, le blocage à l’intérieur d’un bloc synchronized épinglait les threads virtuels, mais ce problème a été résolu dans JDK 24 (JEP 491). Distinguez bien les anciennes mises en garde de la réalité actuelle.34
volatilegarantit la visibilité et l’ordre, pas l’atomicité. Pour les compteurs, utilisezAtomicInteger/LongAdder; pour les états composés, un verrou.5- L’arrêt par interruption est la seule bonne réponse pour arrêter un thread.
Thread.stop/suspend/resumelèvent désormais uneUnsupportedOperationException. Ne jamais étoufferInterruptedException: restaurez le statut ou relancez-la plus haut.6 - L’arrêt d’un
ExecutorServicesuit le schéma en deux étapes « shutdown → awaitTermination → shutdownNow ».shutdownNowest du best effort (l’implémentation standard passe par l’interruption), ce qui suppose que les tâches répondent elles-mêmes à l’interruption.1 - L’interface Swing appartient exclusivement à l’EDT (Event Dispatch Thread). Depuis un autre thread, passez par
SwingUtilities.invokeLater.7
2. Pourquoi le multithreading est-il difficile ── conditions de concurrence, interblocages et modèle mémoire
Les problèmes qu’apporte le multithreading se ramènent, quel que soit le langage, à deux catégories.
La condition de concurrence (race condition) est un bug où le résultat change selon l’ordre dans lequel plusieurs threads atteignent un morceau de code donné. L’exemple classique est le compteur partagé : l’expression unique count++ se décompose en réalité en trois étapes ── « lecture → addition → réécriture ». Si deux threads entrent dans ces trois étapes en même temps, l’addition de l’un est écrasée et perdue par la réécriture de l’autre. Le résultat change à chaque exécution, et il est impossible de prévoir lequel se produira.
sequenceDiagram
participant A as Thread A
participant M as variable partagée count
participant B as Thread B
Note over M: count = 10
A->>M: lecture (10)
B->>M: lecture (10)
A->>A: addition locale (11)
B->>B: addition locale (11)
A->>M: réécriture (11)
B->>M: réécriture (11)
Note over M: additionné 2 fois, mais count = 11<br/>l'addition du thread A a été perdue
Figure 1 : condition de concurrence typique où une addition est perdue sur un compteur partagé. Si un autre thread s’intercale entre les 3 étapes de count++, celui qui réécrit en dernier écrase l’autre.
L’interblocage (deadlock) est un état où deux threads attendent chacun le verrou détenu par l’autre, si bien qu’aucun des deux ne peut avancer. Le thread A détient le verrou 1 et attend le verrou 2, le thread B détient le verrou 2 et attend le verrou 1 ── il suffit de cela pour que les deux restent bloqués éternellement.
flowchart LR
A["Thread A<br/>détient le verrou 1"] -->|"attend la libération du verrou 2"| B["Thread B<br/>détient le verrou 2"]
B -->|"attend la libération du verrou 1"| A
Figure 2 : l’attente circulaire de l’interblocage. Dès que les flèches d’attente forment une boucle, tous les threads pris dans la boucle s’arrêtent pour toujours.
Les deux dépendent du timing : une combinaison d’ordres d’exécution qui ne se produit qu’une fois sur plusieurs dizaines de milliers sur une machine de développement peut se produire tous les jours sur un serveur de production dont le nombre de cœurs et la charge diffèrent. « Ça ne se reproduit pas avec le débogueur attaché », « c’est parti quand j’ai ajouté des logs » viennent du fait que l’observation change le timing ── un comportement typique des bugs de concurrence. C’est précisément pour cela que tous les principes de cet article visent, avant même « bien synchroniser », à « réduire les endroits qui ont besoin d’être synchronisés ».
2.1. Un prérequis propre à Java ── le modèle mémoire et la relation happens-before
Ce qui est propre à Java, par-dessus tout cela, c’est que la façon dont les données partagées deviennent visibles est définie par la relation happens-before du modèle mémoire de Java (JMM).
Lire et écrire une variable partagée sans synchronisation ne produit pas de « comportement indéfini » comme en C++, mais il est parfaitement légitime que l’ancienne valeur continue d’être vue, ou que l’ordre des écritures apparaisse inversé. Une erreur de cohérence mémoire du type « la boucle continue de regarder un drapeau boolean, mais la valeur modifiée par un autre thread n’est jamais visible » est un comportement que le JMM autorise, pas un bug de la JVM.5 L’outil qui empêche cela est le mécanisme qui crée la relation happens-before ── synchronized, volatile, et les classes de java.util.concurrent. Si vous utilisez correctement les collections concurrentes, la bibliothèque garantit elle-même « la relation happens-before entre une opération de mise à jour et une récupération ultérieure ».8
Autrement dit, la ligne de conduite pratique en Java se résume ainsi : ne bricolez pas avec des variables partagées nues. Utilisez les outils de java.util.concurrent pour le partage, et laissez la bibliothèque créer les relations happens-before.
3. Comment créer des threads ── ExecutorService et threads virtuels
3.1. Séparer la tâche et l’exécution
C’est ExecutorService qui porte, en Java, le principe « ne pas créer de threads soi-même ». Il sépare le travail (Runnable / Callable) de la façon dont il est exécuté (combien de threads, quelle file d’attente), et confie à la bibliothèque la création, la réutilisation et la destruction des threads.1
À partir de JDK 21, le choix du moyen d’exécution se résume à une alternative simple.23
flowchart TB
S["Une tâche à paralléliser"] --> Q1{"De quoi la tâche est-elle dominée ?"}
Q1 -->|"Attente d'E/S<br/>appel HTTP, BD, fichier"| VT["Thread virtuel<br/>Executors.newVirtualThreadPerTaskExecutor()<br/>Un thread par tâche. Pas de pool"]
Q1 -->|"Calcul consommant du CPU"| PT["Pool fixe de threads de plateforme<br/>Executors.newFixedThreadPool(≈ nb de cœurs)<br/>ou parallel stream"]
VT --> LIMIT["Limiter les accès simultanés à un service externe<br/>avec un Semaphore, pas avec un pool"]
Figure 3 : le choix du moyen d’exécution à partir de JDK 21. On trace d’abord la ligne « pour l’E/S, on change la façon d’attendre ; pour le CPU, on parallélise », en confiant l’E/S aux threads virtuels et le CPU aux pools classiques.
Il y a un point de vigilance côté CPU-bound. Executors.newFixedThreadPool limite bien le nombre de threads, mais la file d’attente est illimitée. Dans un service permanent où les arrivées dépassent en continu la capacité de traitement, seuls les threads sont limités au nombre de cœurs, tandis que les tâches empilées dans la file et leurs données continuent de consommer de la mémoire. Dans une telle configuration, utilisez directement ThreadPoolExecutor pour configurer une file à capacité limitée accompagnée d’une politique de rejet, ou placez une limite d’entrée comme un Semaphore du côté producteur pour pouvoir appliquer une contre-pression (le même principe que la discussion sur les files du chapitre 4).
3.2. Ne pas se tromper sur l’usage des threads virtuels
Les threads virtuels sont des threads légers détachés des threads OS : ils libèrent le thread OS pendant les opérations bloquantes du JDK (E/S de la bibliothèque standard, verrous, sleep, etc.), ce qui permet d’en faire tourner plusieurs millions dans une seule JVM. Cela dit, ce n’est pas « n’importe quel blocage » qui libère le thread. En cas de blocage pendant l’exécution de code natif (JNI) ou d’une foreign function, le thread virtuel reste épinglé à son thread porteur (carrier thread). Ce que JDK 24 (le JEP 491 mentionné plus loin) a résolu, c’est l’épinglage causé par synchronized ── l’épinglage aux frontières natives subsiste, donc faire tourner sur un grand nombre de threads virtuels un traitement qui bloque longtemps via un pilote ou une API d’appareil passant par JNI épuisera les threads porteurs. Cela dit, comme le souligne le guide officiel, ce ne sont pas des « threads rapides ». La vitesse d’exécution du code ne change pas ; ce qu’ils offrent, c’est de l’échelle (du débit).3
Trois règles disciplinaires en régissent l’usage.3
- Ne pas les mettre en pool. Les threads virtuels sont bon marché et jetables ; « nombre de tâches = nombre de threads virtuels » est l’état correct. Mettre des threads virtuels dans
newFixedThreadPoolest une erreur : utilisez la formetry (var executor = Executors.newVirtualThreadPerTaskExecutor()). - Limiter le nombre d’exécutions simultanées avec
Semaphore. Une limite comme « au plus 10 connexions simultanées vers une API externe » s’exprime avec un sémaphore, pas avec la taille d’un pool. - Ne pas les utiliser pour du travail CPU-bound. La parallélisation du calcul reste, comme avant, l’affaire de threads de plateforme dimensionnés autour du nombre de cœurs.
Ce qui tourne à l’intérieur d’un thread virtuel reste du code synchrone ordinaire. Contrairement à l’async/await de .NET qui réécrit la forme du code, l’idée de conception des threads virtuels est de faire tourner tel quel, en très grand nombre, « le code simple d’un thread par requête ».2
3.3. Une confusion à éviter ── « pas besoin de pool » ne vaut que pour les threads virtuels
Ne déduisez pas de la règle « pas de pool » que « Java n’a pas (ou a un mécanisme inefficace) de pool de threads ». C’est même l’inverse : le pool de Java est un outil mature de la bibliothèque standard depuis JDK 5 (2004). ThreadPoolExecutor, le pool généraliste finement configurable (créé via les diverses fabriques d’Executors), ForkJoinPool, à vol de travail (work-stealing), dont l’instance commune commonPool() est la destination d’exécution par défaut des parallel streams et de CompletableFuture, ScheduledThreadPoolExecutor pour l’exécution périodique ── pour le travail CPU-bound, ce sont toujours eux les acteurs principaux.
Un pool est, à l’origine, une optimisation qui repose sur l’idée que « créer et retenir un thread OS coûte cher, donc on le réutilise ». Les threads virtuels ont fait disparaître cette prémisse en ramenant le coût de création quasiment à zéro, ce qui a fait disparaître la raison de les réutiliser ── ce n’est pas que le pool est devenu inefficace, c’est que l’optimisation qu’est le pool est devenue superflue tant les threads sont devenus légers, voilà la bonne façon de comprendre. Et sous les pieds des threads virtuels, l’ordonnanceur du JDK fait tourner, sous la forme d’un ForkJoinPool à vol de travail, un nombre de threads porteurs (threads OS) proche du nombre de cœurs.2 Autrement dit, le schéma « traiter un grand volume de travail concurrent avec un petit nombre de threads OS en pool » reste préservé ── seule la gestion de ce pool est passée des mains du développeur à la JVM. On peut dire que la réponse de Java atteint, sans changer la forme du code, le même aboutissement que l’async/await de .NET qui rend le thread au pool au moment du await.
4. Réduire l’état mutable partagé ── découper, immuabiliser, collections concurrentes et files d’attente
Une concurrence ne survient que lorsque « plusieurs threads » et « des données mutables partagées » sont réunis. Le nombre de threads est fixé par les exigences, donc c’est le partage que la conception peut réduire. Les moyens se répartissent en trois familles ── « découper », « immuabiliser », « faire transiter » ── qui s’écrivent ainsi en Java.
Découper. Dans une agrégation parallèle, plutôt que d’écrire dans une variable de somme partagée depuis chaque thread, chaque thread construit un résultat partiel qui est fusionné à la fin. Le reduce / collect des parallel streams fournit exactement ce schéma comme cadre, et le LongAdder évoqué plus loin est également une implémentation de cette stratégie de découpage : « diviser les cellules en interne pour disperser la concurrence, et les additionner à la lecture ». Réduire le nombre d’écritures dans le partagé passe avant de bien écrire la synchronisation.
Immuabiliser. Avec record et des collections immuables (List.copyOf / Map.copyOf), vous pouvez créer des données « qui ne sont jamais modifiées après construction » et les partager sans synchronisation. Pour la configuration ou les données de référence, le schéma classique consiste à créer un nouvel objet à chaque remplacement et à réaffecter une référence volatile. Mais « apparaître en lecture seule » et « être immuable » sont deux choses différentes. Les accesseurs d’un record renvoient directement les références de ses composants, et la copie de List.copyOf est également superficielle (elle ne duplique pas les objets éléments), donc si les éléments eux-mêmes sont mutables, quiconque détient un alias peut réécrire leur contenu, et la concurrence subsiste. Vous ne pouvez partager sans synchronisation que si le graphe d’objets tout entier, éléments compris, est immuable. S’il contient des éléments mutables, passez une copie profonde, ou faites tendre les éléments eux aussi vers des record / types immuables.
Utiliser les opérations composées des collections concurrentes. Pour le « s’il n’existe pas, le créer et l’insérer » de ConcurrentHashMap, utilisez computeIfAbsent. Cette méthode s’exécute de façon atomique dans son ensemble : si la clé est absente, la fonction de mapping n’est appelée qu’une seule fois exactement au sein de cet appel.8 La garantie diffère de ConcurrentDictionary.GetOrAdd de .NET (où la factory peut s’exécuter plusieurs fois en cas de concurrence) ── un point que confondent facilement ceux qui naviguent entre les deux langages. Ce n’est cependant pas « exactement une fois dans la vie de la clé ». Si la fonction retourne null ou lève une exception, le mapping n’est pas enregistré, et la fonction sera exécutée à nouveau lors d’un appel ultérieur (de même si l’entrée est supprimée après enregistrement). Pour une initialisation dont les effets de bord ne doivent jamais se répéter, il faut concevoir en tenant compte du fait que la fonction doit réussir sans retourner null. Cela dit, en contrepartie de son atomicité, certaines mises à jour d’autres threads sont bloquées pendant le calcul : gardez la fonction de mapping courte, et ne mettez jamais à jour cette même map à l’intérieur de la fonction (une mise à jour récursive peut lever IllegalStateException).8
// Le schéma classique du compteur de fréquence : computeIfAbsent + LongAdder
ConcurrentHashMap<String, LongAdder> freqs = new ConcurrentHashMap<>();
freqs.computeIfAbsent(key, k -> new LongAdder()).increment();
Faire transiter par une file. Faites transiter le flux de données entre threads par une BlockingQueue. Avec un ArrayBlockingQueue à capacité fixée, put se bloque une fois pleine, ce qui crée une contre-pression naturelle ── le même schéma que les canaux à capacité bornée de l’édition .NET. Même à l’ère des threads virtuels, cette conception qui délimite clairement la frontière producteur/consommateur reste valable.
5. La discipline des verrous ── synchronized et ReentrantLock
5.1. Sur quoi verrouiller, que ne pas faire pendant le verrouillage
L’unité de verrouillage se pense non pas comme « une section de code » mais comme « une donnée ». Associez un objet de verrouillage à chaque ensemble de données mutables à protéger, et prenez ce même verrou à tous les endroits qui touchent cette donnée ── c’est l’effondrement de cette correspondance qui constitue la réalité des bugs de concurrence. synchronized(this) ou synchronized(SomeClass.class) sont à éviter, car du code externe peut verrouiller le même objet ; associez un private final Object lock = new Object(); non exposé à la donnée à protéger, en correspondance un pour un.
Deux règles d’usage s’y ajoutent. Premièrement, ne rien faire de long ou d’externe pendant que le verrou est tenu. Une E/S, un appel de listener, ou l’exécution de code inconnu pendant que vous détenez le verrou allonge le temps de détention et risque de créer l’attente circulaire de la figure 2 si le code appelé tente de prendre un autre verrou. Deuxièmement, fixer un ordre d’acquisition pour plusieurs verrous. Là où vous prenez deux verrous ou plus, faites de l’ordre identique pour tous les threads une règle, et pour les endroits où l’ordre ne peut pas être garanti, prévoyez un chemin de type « si on ne peut pas l’obtenir, on lâche et on recommence » avec le tryLock(timeout) évoqué plus loin.
synchronized suffit pour « une exclusion courte et simple ». Passez à ReentrantLock quand vous avez besoin de ce qui suit.
- Une acquisition avec délai d’expiration (timeout) via
tryLock(timeout)(transformer un blocage éternel en un échec que l’on peut enregistrer et traiter) - Une politique d’équité, plusieurs
Condition, ou le besoin de séparer l’acquisition et la libération du verrou dans des méthodes différentes
Quand vous utilisez ReentrantLock, ne dérogez pas à la forme « try juste après lock(), unlock() dans finally » (comme il n’existe pas de syntaxe équivalente au RAII de C++, cette forme est toute la discipline).
5.2. Threads virtuels et épinglage ── ce qui a changé avec JDK 24
À l’introduction des threads virtuels (JDK 21 à 23), il existait une contrainte selon laquelle bloquer à l’intérieur d’un bloc synchronized épinglait le thread virtuel à son thread OS (impossible de libérer le thread OS, perdant ainsi l’avantage de l’échelle), et le remplacement des points de blocage fréquents ou longs par ReentrantLock était recommandé.3 Cette contrainte a été résolue par la réécriture de l’implémentation des moniteurs dans le JEP 491 de JDK 24, et synchronized n’épingle plus les threads virtuels.4 Si vous utilisez JDK 24 ou une version ultérieure, un remplacement systématique motivé par l’épinglage n’est plus nécessaire. Il vaut la peine de vérifier que d’anciennes lignes directrices internes à l’entreprise ne sont pas restées figées à l’époque de JDK 21.
5.3. La place des types Atomic et de volatile
La mise à jour atomique d’une seule variable relève de AtomicInteger / AtomicLong / AtomicReference (ou de LongAdder, plus résistant à la concurrence, pour une statistique simplement additionnée à haute fréquence). volatile garantit la visibilité et l’ordre (happens-before), pas l’atomicité d’une opération composée.5 La même conclusion que dans les éditions .NET et C++ s’applique à Java : drapeaux et valeurs uniques relèvent des types Atomic, les états composés d’un verrou, et on ne compte jamais sur volatile seul.
6. Concevoir comment arrêter ── l’interruption comme langage commun
6.1. Les bonnes pratiques de l’interruption
L’arrêt et l’annulation en Java sont unifiés autour de l’interruption. t.interrupt() positionne le statut d’interruption du thread cible, et si celui-ci est bloqué dans sleep / wait / join, il le réveille immédiatement en levant InterruptedException (le statut d’interruption est alors effacé).6 Les anciens moyens de forçage Thread.stop / suspend / resume sont fondamentalement dangereux, et aujourd’hui les appeler lève une UnsupportedOperationException.6
flowchart TB
OWNER["Le côté qui arrête : t.interrupt()"] --> ST["Le statut d'interruption est positionné"]
ST --> A["Thread en cours de calcul :<br/>vérifie Thread.interrupted() en boucle"]
ST --> B["Bloqué dans sleep / wait / join :<br/>InterruptedException est levée et le réveille immédiatement<br/>(le statut est effacé)"]
A --> E["Nettoie et se termine lui-même"]
B --> C{"Que faire dans le catch ?"}
C -->|"Peut se terminer lui-même"| E
C -->|"Ne peut pas se terminer (dans une bibliothèque, etc.)"| R["Thread.currentThread().interrupt()<br/>restaure le statut pour laisser le signal"]
R --> E
Figure 4 : l’arrêt coopératif par interruption. Étouffer InterruptedException fait disparaître le signal d’arrêt ── une fois dans le catch, le choix se limite à « se terminer » ou « restaurer le statut ».
Une seule règle pratique suffit à retenir. N’écrivez jamais de code qui attrape InterruptedException sans rien faire. Si vous pouvez terminer dans le cadre de votre propre responsabilité, terminez ; sinon, restaurez le statut avec Thread.currentThread().interrupt() pour transmettre le signal à l’appelant (voir la FAQ).
6.2. L’arrêt en deux étapes d’ExecutorService
L’API d’arrêt d’ExecutorService repose sur le modèle de l’interruption. shutdown() arrête l’acceptation de nouvelles tâches et laisse les tâches déjà soumises s’exécuter jusqu’au bout ; shutdownNow() tente d’arrêter les tâches en cours d’exécution. En tant que spécification d’interface, cela reste du best effort, et l’implémentation standard (ThreadPoolExecutor, etc.) précise explicitement qu’elle annule typiquement via Thread.interrupt() ── autrement dit, une tâche qui ne répond pas à l’interruption ne s’arrêtera pas non plus avec shutdownNow, et si vous utilisez un Executor personnalisé, vous devez vérifier dans sa documentation quelle méthode d’annulation il emploie (envoie-t-il ou non une interruption).1 Le schéma classique d’arrêt que présente la documentation officielle est le suivant, en deux étapes.1
/** true si l'arrêt est terminé. Ne jamais passer à la libération des ressources partagées tant que c'est false. */
boolean shutdownAndAwaitTermination(ExecutorService pool) {
pool.shutdown(); // Étape 1 : arrête l'acceptation de nouvelles tâches et attend la fin
try {
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
// Étape 2 : demande l'annulation. Les tâches non exécutées et retirées sont retournées,
// on marque leur Future comme annulé pour réveiller l'appelant en attente sur get()
pool.shutdownNow().forEach(r -> {
if (r instanceof Future<?> f) f.cancel(false);
});
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
System.err.println("Pool did not terminate");
return false; // Arrêt incomplet. À signaler distinctement d'un succès
}
}
return true;
} catch (InterruptedException ex) {
pool.shutdownNow().forEach(r -> {
if (r instanceof Future<?> f) f.cancel(false);
});
Thread.currentThread().interrupt(); // Restaure aussi son propre statut d'interruption
return false; // Ce chemin aussi peut laisser l'arrêt incomplet
}
}
flowchart TB
S["shutdown()<br/>Arrête l'acceptation de nouvelles tâches"] --> W1{"awaitTermination<br/>attend la fin des tâches"}
W1 -->|"Terminé dans le délai"| DONE["Arrêt terminé"]
W1 -->|"Délai dépassé"| NOW["shutdownNow()<br/>envoie interrupt aux tâches en cours<br/>(réponse dépendant de la tâche)"]
NOW --> W2{"awaitTermination<br/>attend à nouveau"}
W2 -->|"Terminé"| DONE
W2 -->|"Toujours pas terminé"| LOG["Enregistré comme anomalie<br/>(suspecter une tâche qui ne répond pas à l'interruption)"]
Figure 5 : l’arrêt en deux étapes d’ExecutorService. Une conception par paliers : « attendre calmement → exiger par interruption → si ça ne se termine toujours pas, l’observer comme une anomalie ».
Notez que l’annulation des Runnable que renvoie shutdownNow() a une limite. Ce qui est retourné, ce sont les objets qui se trouvaient dans la file d’exécution : avec un submit nu, il s’agit du FutureTask lui-même remis à l’utilisateur, mais pour des tâches soumises via un wrapper comme ExecutorCompletionService, il s’agit du wrapper présent dans la file, distinct du Future de l’utilisateur. Dans cette configuration, l’annulation ci-dessus ne termine pas le Future côté utilisateur : conservez vous-même, au moment de la soumission, une liste des Future, et annulez-la lors de l’arrêt (ou retournez les tâches retirées à leur propriétaire).
Le close() (AutoCloseable) de JDK 19 et suivants permet d’écrire « shutdown puis attendre la fin » sous forme de try-with-resources, et la forme moderne de base est try (var executor = ...) combinée à newVirtualThreadPerTaskExecutor pour les threads virtuels.1 Mais close() ne remplace pas le schéma en deux étapes ci-dessus. Il attend la fin sans délai d’expiration, donc s’il y a ne serait-ce qu’une seule tâche qui ne répond pas à l’interruption ou qui ne se termine jamais, le thread qui tente de fermer se bloquera pour toujours. C’est un outil adapté aux cas où les tâches de la portée sont en nombre fini et garanties de se terminer (soumettre sur place et attendre sur place) ; pour un chemin comme l’arrêt de l’application, où l’on veut absolument terminer en temps fini, utilisez le schéma en deux étapes avec délai. L’annulation d’une tâche individuelle passe elle aussi par l’interruption, avec Future.cancel(true).
7. Le thread d’interface ── l’EDT de Swing
Les applications de bureau obéissent toutes, quel que soit le langage ou le framework, à la règle « l’UI appartient exclusivement au thread qui la gère ». Dans Swing, ce thread exclusif est l’Event Dispatch Thread (EDT) : les méthodes des composants Swing ne sont en principe pas thread-safe, et les toucher depuis plusieurs threads provoque des interférences de threads et des erreurs de cohérence mémoire. Une mise à jour de l’écran depuis un autre thread doit passer par SwingUtilities.invokeLater pour être confiée à l’EDT ; à l’inverse, un traitement long sur l’EDT fige l’interface, donc les tâches lourdes doivent être déléguées à un thread de travail, par exemple via SwingWorker.7 Le principe est le même dans JavaFX : les mises à jour d’UI sont confiées au thread applicatif via Platform.runLater.
8. Vérification et débogage ── le thread dump comme arme
On ne peut pas espérer que les tests trouvent les bugs de concurrence, car un test ordinaire compte comme un succès une exécution où « par hasard, il n’y a pas eu de concurrence ». Il faut se préparer sur trois niveaux.
La première ligne de défense est la conception. En revue, vérifiez, sous forme de tableau : quelles données mutables sont partagées, quel verrou protège chacune (la correspondance de 5.1), si l’ordre d’acquisition des verrous est unique, s’il existe un catch qui étouffe InterruptedException, et si les chemins d’arrêt (shutdown/interruption) atteignent bien toutes les tâches.
Deuxièmement, maîtrisez le thread dump. Java dispose d’un outil standard pour capturer « l’état figé de tous les threads à cet instant précis » : jstack (ou jcmd <pid> Thread.print) affiche les traces de pile, et l’option -l ajoute des informations sur les verrous.9 Attention, ce format de dump traditionnel est destiné aux threads de plateforme et n’inclut pas les threads virtuels de l’application. Pour suivre une requête bloquée dans une configuration qui utilise des threads virtuels (chapitre 3), utilisez jcmd <pid> Thread.dump_to_file -format=json <fichier>, qui inclut aussi les threads virtuels.2 Pour investiguer un blocage, la procédure de base consiste à prendre 2 ou 3 dumps espacés de quelques secondes, et à croiser quel verrou attend chaque thread inactif, et qui détient ce verrou. Consigner dans les logs les expirations de tryLock(timeout) (5.2) permet même d’automatiser le déclenchement d’un dump.
Troisièmement, secouez le système avec de la charge. Faire tourner longtemps avec un degré de parallélisme supérieur au nombre de cœurs, randomiser l’ordre de traitement, injecter des délais artificiels ── ce sont des tests de stress qui rendent, sur une machine de développement, plus probable de « tirer » une concurrence. Faites passer au moins une fois avant chaque publication un test avec un volume de données et un nombre de threads représentatifs de la production.
9. L’avenir du traitement concurrent en Java ── la concurrence structurée
Un dernier mot, un peu en avance sur son temps. Sur la base des threads virtuels, Structured Concurrency (StructuredTaskScope), qui traite plusieurs sous-tâches comme une seule unité de travail et structure la propagation des échecs et l’annulation, est en développement ── au mois d’août 2026, il s’agit encore d’une fonctionnalité en préversion (preview). La cinquième préversion de JDK 25 (JEP 505) a révisé l’API vers la forme StructuredTaskScope.open(), et JDK 26, la version actuelle, la poursuit en sixième préversion (JEP 525).1011 De son côté, Scoped Values, un partage de contexte immuable qui résout les problèmes de ThreadLocal, a été officialisé dans JDK 25.12 Les principes de cet article (délimiter clairement les frontières des tâches, partager de façon immuable, arrêter de façon coopérative) vont dans le même sens que ces nouvelles API.
10. Conclusion ── liste de vérification pour Java
- Reste-t-il un
new Threaddans le code métier (repose-t-il bien surExecutorService/ les threads virtuels) ? - L’exécution est-elle séparée entre I/O-bound et CPU-bound (la bifurcation de la figure 3) ?
- Les threads virtuels ne sont-ils pas mis en pool, et la limite de concurrence est-elle exprimée avec
Semaphore? - Les données partagées sont-elles immuables (
record/List.copyOf) ou reposent-elles sur les outils dejava.util.concurrent? - N’y a-t-il pas de
synchronized(this)ou de verrou sur un objet public ? - Les opérations composées de
ConcurrentHashMap(computeIfAbsent, etc.) sont-elles utilisées, avec des fonctions de mapping courtes ? - N’attendez-vous pas de l’atomicité de
volatile(les compteurs relèvent-ils bien des types Atomic / deLongAdder) ? - Existe-t-il ne serait-ce qu’un seul catch qui étouffe
InterruptedException? - L’arrêt d’
ExecutorServicesuit-il le schéma en deux étapes avec délai (les endroits qui utilisentclose()sont-ils limités à une portée où la fin des tâches est garantie) ? - Les mises à jour d’UI Swing/JavaFX sont-elles bien concentrées sur l’EDT / le thread applicatif ?
Java est l’un des langages où les outils de traitement concurrent sont les plus aboutis, et l’arrivée des threads virtuels a ouvert la voie à « faire tourner à grande échelle du code synchrone, tel quel, sans le réécrire ». C’est précisément pour cela que bien cerner la répartition des rôles entre les outils ── lequel sert le débit, lequel sert l’exclusion, quel est le signal d’arrêt ── constitue l’essentiel de la conception du multithreading en Java.
Articles connexes
- Bonnes pratiques du multithreading en pratique — édition .NET
- Bonnes pratiques du multithreading en pratique — édition C++
- Bonnes pratiques du multithreading en pratique — édition C
- Tableau de décision pratique pour C# async/await - Task.Run et ConfigureAwait
Domaines de conseil associés
Komura Software LLC prend en charge la revue de conception du multithreading pour les systèmes métier et les traitements par lots écrits en Java, l’investigation de bugs liés au traitement concurrent — corruption d’état partagé, comportements intermittents où « ça ne s’arrête pas » ou « ça se fige » (analyse de thread dumps) —, ainsi que le conseil technique sur l’introduction des threads virtuels.
Références
-
Oracle, ExecutorService (Java SE 21 & JDK 21 API). Sur le fait que shutdown() laisse les tâches déjà soumises s’exécuter jusqu’au bout tout en arrêtant l’acceptation de nouvelles tâches, que shutdownNow() tente d’arrêter les tâches en cours et retourne la liste des tâches en attente, mais que l’implémentation typique annule via Thread.interrupt() sans garantie au-delà du best effort, si bien qu’une tâche qui ne répond pas à l’interruption ne se termine pas, que awaitTermination permet d’attendre la fin, que close() (depuis Java 19, AutoCloseable) effectue shutdown puis attend la fin et s’utilise avec try-with-resources, et que l’exemple d’utilisation présente l’arrêt en deux étapes shutdown → awaitTermination → shutdownNow. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
OpenJDK, JEP 444: Virtual Threads. Sur le fait que les threads virtuels sont devenus une fonctionnalité officielle dans JDK 21, qu’il s’agit de threads légers réduisant considérablement l’effort d’écriture, de maintenance et d’observation d’applications concurrentes à haut débit, sur l’idée de conception consistant à faire tourner tel quel, à grande échelle, du code synchrone simple en « un thread par requête », sur le fait que l’ordonnanceur de threads virtuels du JDK est un ForkJoinPool à vol de travail fonctionnant en mode FIFO avec un parallélisme par défaut égal au nombre de processeurs disponibles, et sur le nouveau format de thread dump incluant les threads virtuels, ajouté sous la forme de jcmd Thread.dump_to_file (au format texte brut et JSON), le format traditionnel de thread dump n’incluant pas les threads virtuels. ↩ ↩2 ↩3 ↩4 ↩5
-
Oracle Java SE Core Libraries, Virtual Threads. Sur le fait que les threads virtuels sont des threads légers implémentés par le runtime Java qui libèrent le thread OS lors d’E/S bloquantes, qu’il s’agit d’une fonctionnalité pour l’échelle (le débit) et non pour la vitesse (la latence), peu adaptée aux traitements intensifs en CPU, sur la règle de ne jamais les mettre en pool et d’en utiliser un par tâche (newVirtualThreadPerTaskExecutor), sur l’usage d’un Semaphore plutôt que d’un pool de threads pour limiter la concurrence, sur le fait qu’à l’époque de JDK 21, bloquer à l’intérieur d’un synchronized provoquait un épinglage au thread OS, d’où la recommandation de remplacer par ReentrantLock dans les cas fréquents ou longs, et sur la détection de l’épinglage avec -Djdk.tracePinnedThreads. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
OpenJDK, JEP 491: Synchronize Virtual Threads without Pinning. Sur le fait que JDK 24 a réécrit l’implémentation des moniteurs de la JVM pour prendre en charge les threads virtuels, si bien que bloquer dans un bloc ou une méthode synchronized n’épingle plus le thread virtuel à son thread porteur, et que cela rend en principe inutile la mesure de l’époque JDK 21-23 consistant à remplacer synchronized par ReentrantLock. ↩ ↩2
-
Oracle, The Java Tutorials, Memory Consistency Errors. Sur le fait qu’une erreur de cohérence mémoire survient lorsque plusieurs threads ont une vision incohérente d’une même donnée, que la clé pour l’éviter est la relation happens-before (la garantie qu’une écriture mémoire effectuée par une instruction soit visible d’une autre instruction), et que synchronized, volatile, ainsi que Thread.start / join, entre autres, créent des relations happens-before. ↩ ↩2 ↩3
-
Oracle, Thread (Java SE 21 & JDK 21 API). Sur le fait que Thread.stop / suspend / resume sont fondamentalement dangereux (des verrous sont libérés dans un état incohérent et des objets corrompus deviennent visibles ; suspend peut provoquer des interblocages), qu’ils sont dépréciés en vue d’une suppression, et qu’ils lèvent désormais une UnsupportedOperationException à l’appel, sur le fait qu’interrupt() positionne le statut d’interruption et réveille en levant InterruptedException un thread bloqué dans sleep / wait / join (le statut est alors effacé), et sur la différence de traitement du statut entre interrupted() et isInterrupted(). ↩ ↩2 ↩3
-
Oracle, The Java Tutorials, The Event Dispatch Thread. Sur le fait que le code de traitement des événements Swing s’exécute sur l’Event Dispatch Thread (EDT), que la plupart des méthodes des objets Swing ne sont pas thread-safe et que leur appel depuis plusieurs threads provoque des interférences de threads et des erreurs de cohérence mémoire, d’où le principe d’un accès aux composants Swing réalisé sur l’EDT, que les autres threads doivent confier des tâches à l’EDT via SwingUtilities.invokeLater / invokeAndWait, et que les tâches sur l’EDT doivent se terminer rapidement. ↩ ↩2
-
Oracle, ConcurrentHashMap (Java SE 21 & JDK 21 API). Sur le fait que l’appel entier de computeIfAbsent s’exécute de façon atomique et que, si la clé est absente, la fonction de mapping n’est appelée qu’une seule fois exactement, que certaines opérations de mise à jour d’autres threads sont bloquées pendant le calcul, ce qui impose de garder le calcul court et simple, que la fonction de mapping ne doit pas modifier cette map elle-même sous peine de lever IllegalStateException en cas de mise à jour récursive détectable, que les opérations de lecture (get) ne bloquent pas, et qu’une relation happens-before s’établit entre une mise à jour pour une clé donnée et une lecture ultérieure. ↩ ↩2 ↩3
-
Oracle, The jstack Command (Java SE 21 Tools Reference). Sur le fait que jstack affiche les traces de pile (nom de classe, nom de méthode, numéro de ligne) de tous les threads d’un processus Java donné, que l’option -l ajoute des informations détaillées sur les verrous, et sur l’usage combiné avec d’autres outils de diagnostic comme jcmd. ↩
-
OpenJDK, JEP 505: Structured Concurrency (Fifth Preview). Sur le fait que l’API de concurrence structurée traite un ensemble de sous-tâches liées comme une seule unité de travail et structure la propagation des erreurs et l’annulation, que StructuredTaskScope a été révisé vers une ouverture par méthode de fabrique statique (open), et sur le fait qu’il s’agissait, au moment de JDK 25, d’une cinquième préversion et non d’une fonctionnalité officielle. ↩
-
OpenJDK, JEP 525: Structured Concurrency (Sixth Preview). Sur le fait que la concurrence structurée se poursuit en sixième préversion dans JDK 26, c’est-à-dire qu’elle reste, au mois d’août 2026, une fonctionnalité en préversion dans le JDK actuel, dont l’utilisation nécessite l’activation des fonctionnalités en préversion. ↩
-
OpenJDK, JEP 506: Scoped Values. Sur le fait que Scoped Values a été officialisé dans JDK 25, qu’il s’agit d’un mécanisme de partage sûr et efficace de données de contexte immuables au sein d’un thread et entre threads, et qu’il répond aux problèmes de ThreadLocal (mutabilité, gestion de la durée de vie, coût d’héritage). ↩
Articles associés
Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.
Bonnes pratiques de multithreading en pratique — édition C++ : éliminer les accidents structurellement avec RAII et jthread
En C++, le multithreading est un monde où une course de données devient un comportement indéfini. Cet article couvre le piège du destruct...
Bonnes pratiques de multithreading en pratique — édition .NET : ce qu'il faut décider avant d'ajouter des threads
Un ensemble de bonnes pratiques de conception pour .NET/C# afin d'éviter que « démarrer un thread » ne fasse planter ou geler l'applicati...
Bonnes pratiques du multithreading en pratique — édition langage C — écrire en toute sécurité à la manière de l'API Win32
Le multithreading en C avec Win32 repose sur des règles éprouvées : création de threads avec _beginthreadex, verrous SRW et variables de ...
Externalisation et développement sur mesure d'une application Windows : ce qu'il faut clarifier avant de se lancer
Avant de confier l'externalisation ou le développement sur mesure d'une application Windows, voici les points à clarifier : révision d'un...
Cliché instantané de volume (VSS) : fonctionnement et pratique — Pourquoi peut-on sauvegarder des fichiers en cours d'utilisation ?
Un fichier en cours d'utilisation ne peut pas être copié à cause d'une violation de partage, alors comment les logiciels de sauvegarde y ...
Sujets associés
Ces pages replacent le sujet dans un contexte plus large de services et de décisions.
Thèmes techniques Windows
Portail des sujets sur le développement Windows, l'analyse des incidents et la valorisation des actifs existants.
Services liés à ce sujet
Cet article est directement lié aux services suivants.
Conseil technique et revue de conception
Clarification de la stratégie de modification, de la conception et du traitement des actifs existants.
Questions fréquentes
Questions souvent posées lors d’une consultation sur le sujet de cet article.
- Avec les threads virtuels, le pool de threads (ExecutorService) est-il devenu inutile ?
- Cela dépend de l'usage. Les threads virtuels sont un mécanisme conçu pour exécuter un grand nombre de tâches dominées par l'attente d'E/S : ils n'accélèrent pas le code, ils augmentent le débit. Pour un travail lié aux E/S, utilisez un thread virtuel par tâche (Executors.newVirtualThreadPerTaskExecutor) et ne mettez jamais les threads virtuels en pool. En revanche, pour paralléliser un calcul qui sature le CPU, un pool de threads de plateforme limité à environ le nombre de cœurs (ou un parallel stream) reste l'outil adapté, comme auparavant. Et si vous voulez limiter le nombre d'accès simultanés à un service externe, la recommandation à l'ère des threads virtuels est de limiter avec un Semaphore plutôt qu'avec la taille d'un pool.
- Faut-il utiliser synchronized ou ReentrantLock ?
- Pour une exclusion courte et simple, synchronized suffit et le code reste concis. Choisissez ReentrantLock quand vous avez besoin de fonctionnalités comme l'acquisition avec délai d'expiration via tryLock, une politique d'équité, ou plusieurs Condition. Il existe une mise en garde historique concernant la combinaison avec les threads virtuels : dans JDK 21 à 23, bloquer à l'intérieur d'un bloc synchronized épinglait le thread virtuel à son thread OS, si bien que le remplacement des points de blocage fréquents ou longs par ReentrantLock était recommandé. JDK 24 (JEP 491) a réécrit l'implémentation des moniteurs et supprimé cette contrainte. À partir de JDK 24, il n'est plus nécessaire de remplacer synchronized pour des raisons d'épinglage.
- Ajouter volatile rend-il une variable thread-safe ?
- Non. Le volatile de Java crée une relation happens-before entre l'écriture d'une variable et sa lecture, ce qui garantit la visibilité (le fait que les autres threads voient la dernière écriture) et l'ordonnancement, mais ne garantit pas l'atomicité d'une opération composée comme « lire, calculer, réécrire ». Incrémenter un compteur volatile int avec ++ depuis plusieurs threads fait perdre des additions. Utilisez AtomicInteger / AtomicLong (ou LongAdder pour une agrégation à haute fréquence) pour les compteurs, et un verrou pour protéger plusieurs variables ensemble. volatile n'est adapté que dans des situations comme un simple drapeau d'état, où un seul thread écrit et les autres ne font que lire.
- Peut-on attraper InterruptedException et l'ignorer ?
- Non. L'interruption est le signal standard de Java pour l'arrêt et l'annulation, et l'étouffer crée un « thread qui ne s'arrête jamais ». Au moment où InterruptedException est levée, le statut d'interruption a déjà été effacé ; si vous ne pouvez pas terminer le traitement vous-même, restaurez le statut avec Thread.currentThread().interrupt() pour laisser le signal à l'appelant, ou relancez l'exception telle quelle. Un bloc catch vide qui ne fait rien est une cause classique de bugs où l'arrêt ne prend pas effet ou où shutdownNow est ignoré.
- Thread.stop ne permet-il pas d'arrêter un thread ?
- Non, ce n'est pas possible. Thread.stop est fondamentalement dangereux (il libère les verrous en les laissant dans un état incohérent, exposant des objets corrompus à d'autres threads) et est déprécié depuis longtemps ; dans les versions actuelles de Java, l'appeler lève une UnsupportedOperationException. Il en va de même pour Thread.suspend / resume. Le seul moyen légitime d'arrêter un thread est l'arrêt coopératif par interruption. Avec ExecutorService, shutdown se contente d'arrêter l'acceptation de nouvelles tâches et attend la fin des tâches en cours, sans leur envoyer d'interruption. C'est shutdownNow qui tente d'arrêter les tâches en cours d'exécution, et cela reste du best effort (l'implémentation standard passe par l'interruption).
Profil de l’auteur
Page de présentation de l’auteur de l’article.
Go Komura
Représentant de KomuraSoft LLC
Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.