Bonnes pratiques du multithreading : édition Java — les usages à l'ère des threads virtuels

· Mis à jour le: · · Multithreading, Java, Application métier, Investigation de bugs, Conception

Historique des révisions (première version, publiée le 2 Aug 2026)
Première publication
Citer cet article(DOI (archive enregistrée): 10.5281/zenodo.22175926)

Les DOI ci-dessous renvoient à des versions déjà archivées et peuvent différer du texte actuel. Pour citer le texte actuel, utilisez l’URL de cette page.

Go Komura (2026). Bonnes pratiques du multithreading : édition Java — les usages à l'ère des threads virtuels. KomuraSoft LLC. https://comcomponent.com/fr/blog/multithreading-best-practices-java/

DOI (archive enregistrée)
10.5281/zenodo.22175926
DOI (dernière version enregistrée)
10.5281/zenodo.22175927

« 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. » Même s’il s’agit dans tous les cas de multithreading, le choix du moyen d’exécution, la protection des données partagées, et les questions d’interface et d’arrêt doivent être pensés séparément.

Java intègre le multithreading dans le langage depuis JDK 1.0 et a constitué une boîte à outils mature dans java.util.concurrent. JDK 21 a aussi officialisé les threads virtuels. Précisément parce que les outils sont nombreux, le point clé de la conception est de choisir dans l’ordre : ce que l’on fait tourner en parallèle, ce que l’on partage, et comment on arrête.1

Cet article est l’édition Java de la série pratique sur le multithreading. Il s’adresse aux développeurs qui écrivent des systèmes métier, des traitements par lots et des applications serveur, vise principalement la version LTS JDK 21 et suivantes, et rassemble les principes et les points d’attention d’après des sources primaires à la date d’août 2026. Il se lit de manière autonome. Les mêmes principes déclinés dans d’autres langages se trouvent dans « l’édition .NET », « l’édition C++ » et « l’édition langage C ».

1. D’abord la conclusion — concevoir séparément l’exécution, le partage et l’arrêt

Choisir les threads virtuels ne résout pas automatiquement la concurrence sur les données partagées, ni le chemin d’arrêt. Dans le code métier, ne créez pas de threads directement ; confiez l’exécution des tâches à ExecutorService, puis concevez dans l’ordre suivant.2

Ce qu’il faut décider Politique de base Chapitre à lire
Avec quoi exécuter Un thread virtuel par tâche pour l’attente d’E/S, des threads de plateforme à peu près au nombre de cœurs pour le calcul CPU Chapitre 2
Jusqu’où accepter Un Semaphore pour le nombre d’appels simultanés à un service externe ; un plafond de capacité ou d’admission pour le travail qui s’accumule Chapitres 2 et 3
Que partager Découper en résultats partiels, et utiliser des données immuables, des collections concurrentes et des files Chapitre 3
Comment protéger l’état Les classes Atomic pour la mise à jour atomique d’une valeur unique, un verrou dédié pour un état composé. Ne pas attendre d’atomicité de volatile Chapitre 4
Comment arrêter Combiner l’arrêt coopératif par interruption et une vérification d’achèvement bornée par un délai Chapitres 5 et 6
Comment traiter l’UI et l’investigation L’UI sur son thread dédié. Des dumps distincts pour les threads de plateforme et les threads virtuels Chapitres 7 et 8

Si vous lisez depuis le début, suivez l’ordre de ce tableau. Si vous enquêtez maintenant sur un « ça ne s’arrête pas », commencez par les chapitres 5 et 6 ; si c’est « ça a figé », par le chapitre 8. Les précautions d’épinglage, qui diffèrent entre JDK 21 à 23 et JDK 24 et suivants, sont au § 2.4, et la distinction entre fonctionnalités en preview et fonctionnalités officielles au chapitre 9.

Dans le diagramme, un trait continu marque une relation qui vaut toujours et un trait pointillé une relation conditionnelle (les conditions figurent dans l’explication de chaque relation sur la page de détail). La liste complète des relations (28 au total, avec preuve et niveau de certitude) et les définitions des concepts principaux sont rassemblées sur la page de détail de la carte des connaissances (en japonais). Données : JSON-LD / Turtle

2. Choisir le moyen d’exécution — séparer l’attente d’E/S et le calcul CPU

2.1. Confier le travail à ExecutorService

En Java aussi, la règle de base est de ne pas disperser new Thread dans le code métier. Séparez le « travail », exprimé en Runnable / Callable, de la « façon de l’exécuter », comme le nombre de threads et la file, et laissez la création, la réutilisation et la destruction des threads à ExecutorService.2

Choisissez d’abord le mécanisme d’exécution selon ce qui domine le travail.13

Attente d'E/S surtoutappels HTTP, BD, fichiersCalcul qui utilise le CPUUne tâche à exécuter en parallèleQu'est-ce qui domine la tâche ?Threads virtuelsExecutors.newVirtualThreadPerTaskExecutor()Un par tâche. Jamais en poolPool fixe de threads de plateformeExecutors.newFixedThreadPool(environ le nombre de cœurs)ou un parallel streamLimiter les appels simultanés aux services externesavec un Semaphore, pas un pool

Figure 1 : Choisissez les threads virtuels pour l’attente d’E/S et un pool classique pour le calcul CPU. Concevez à part la limite d’accès simultanés.

Les threads virtuels ne sont pas des « threads rapides ». Ils n’augmentent pas la vitesse d’exécution du calcul lui-même ; c’est un outil pour traiter en parallèle une grande quantité de travail riche en attentes, et augmenter le débit. Pour un calcul qui sature le CPU, utilisez comme auparavant un pool fixe de threads de plateforme à peu près au nombre de cœurs, ou un parallel stream.3

2.2. Ne pas mettre les threads virtuels en pool ; protéger avec un Semaphore les ressources à limiter

Les threads virtuels sont des threads légers découplés des threads OS. Parce qu’ils peuvent relâcher le thread OS pendant les opérations bloquantes que le JDK prend en charge, comme les E/S, les verrous et sleep, ils atteignent une concurrence suffisante pour qu’une seule JVM en gère des millions. Tous les blocages ne permettent pas de le relâcher, toutefois. Les conditions d’épinglage sont expliquées à part au § 2.4.3

La façon de les utiliser est un thread virtuel par tâche. Traitez-les comme bon marché et jetables ; ne concevez pas leur réutilisation en les plaçant dans un newFixedThreadPool. Utilisez Executors.newVirtualThreadPerTaskExecutor().13

En revanche, une limite du type « au plus 10 connexions simultanées à l’API externe » reste nécessaire. Exprimez ce plafond avec un Semaphore, pas avec la taille d’un pool de threads virtuels. Ne pas mettre les threads virtuels en pool et pouvoir accéder sans limite aux services externes sont deux choses distinctes.3

Ce qui s’exécute dans un thread virtuel, c’est du code synchrone ordinaire. La philosophie de conception est que du code simple « un thread par requête » peut tourner tel quel à grande échelle, sans être réécrit dans une forme analogue à async/await de .NET. La forme try (var executor = Executors.newVirtualThreadPerTaskExecutor()) est aussi disponible, mais vérifiez au § 6.3 combien de temps close() attend à la sortie.12

2.3. Pour un pool de threads de plateforme, penser aussi à un plafond sur la file d’attente

« Ne pas mettre en pool » concerne les threads virtuels. Java dispose de pools de threads matures depuis JDK 5 (2004), et on les choisit encore aujourd’hui selon l’usage.

ThreadPoolExecutor est un pool généraliste dont on configure en détail le nombre de threads, la file, la politique de refus, etc. ForkJoinPool est un pool à vol de travail, et son commonPool() sert de cible d’exécution asynchrone par défaut aux parallel streams et à CompletableFuture. Pour l’exécution périodique, il y a ScheduledThreadPoolExecutor. Partout où l’on réutilise des threads de plateforme, comme pour le calcul CPU, ce restent des outils importants.

Toutefois, ce que Executors.newFixedThreadPool limite, c’est le nombre de threads ; la file d’attente est sans borne. Dans un service résident où les soumissions dépassent durablement le traitement, les tâches en attente et leurs données continuent de consommer de la mémoire. Configurez ThreadPoolExecutor avec une file bornée et une politique de refus, ou placez un contrôle d’admission tel qu’un Semaphore du côté qui soumet, afin de pouvoir exercer une backpressure. La file du § 3.6 repose sur le même principe.

Un pool est une optimisation pour réutiliser des threads OS, coûteux à créer et à conserver. Les threads virtuels sont assez légers pour que les développeurs n’aient plus de raison de les réutiliser eux-mêmes. Cela ne signifie pas que les pools soient devenus inefficaces.

En réalité, sous les threads virtuels, l’ordonnanceur du JDK utilise un ForkJoinPool à vol de travail et, par défaut, fait tourner autant de threads porteurs (threads OS) qu’il y a de processeurs disponibles. Le tableau — beaucoup de concurrence avec peu de threads OS — est le même ; la JVM en prend la gestion. Il est utile de voir Java comme allant dans la même direction que l’E/S asynchrone de .NET, qui n’occupe pas un thread pendant une attente, tout en conservant la forme du code synchrone.1

2.4. Penser l’épinglage selon la version du JDK et selon l’endroit du blocage

L’épinglage (pinning) est l’état dans lequel un thread virtuel ne peut pas quitter son thread porteur, si bien que le thread OS continue d’attendre lui aussi. Un épinglage fréquent ou long érode l’avantage d’échelle des threads virtuels.3

Où se produit le blocage JDK 21 à 23 JDK 24 et suivants
Dans un bloc ou une méthode synchronized L’épinglage se produit. Envisager de remplacer les sites à blocage fréquent ou long par ReentrantLock JEP 491 a levé cette contrainte. Un remplacement mécanique uniquement comme contre-mesure d’épinglage n’est plus nécessaire
Pendant l’exécution de code natif (JNI) ou d’une foreign function Le thread porteur peut ne pas être relâché Distinct de l’amélioration de synchronized, l’épinglage à la frontière native demeure

Ce que JEP 491 de JDK 24 a levé, c’est l’épinglage de synchronized issu de l’implémentation des moniteurs. Si vous chargez un grand nombre d’opérations qui bloquent longtemps dans des pilotes JNI ou des API de périphériques, le problème d’épuisement des threads porteurs reste. Ne supposez pas « c’est JDK 24, donc on peut attendre n’importe où ». Vérifiez aussi si les consignes internes sont encore figées sur la mise en garde de l’époque JDK 21.43

3. Réduire l’état mutable partagé — revoir le partage avant d’écrire la synchronisation

3.1. Même count++ perd des additions quand les traitements se chevauchent

Une condition de concurrence (race condition) est un bug dont le résultat change selon l’ordre dans lequel plusieurs threads atteignent le code. count++ a l’air d’une seule expression, mais se décompose en lecture, addition et réécriture. Si un autre thread s’intercale, l’une des additions est perdue.

Thread Bvariable partagée countThread AThread Bvariable partagée countThread Acount = 10additionné 2 fois, mais count = 11l'addition du thread A a été perduelecture (10)lecture (10)addition locale (11)addition locale (11)réécriture (11)réécriture (11)

Figure 2 : Quand deux threads lisent la même valeur puis réécrivent, une seule des deux additions survit.

L’autre classique est l’interblocage, où les threads s’attendent mutuellement sur leurs verrous et ne peuvent plus avancer. Cela est traité au § 4.3. Les deux dépendent du timing, si bien que ce qui est rare sur une machine de développement peut se produire souvent en production, où le nombre de cœurs et la charge diffèrent. Ils cessent aussi de se reproduire dès que l’on ajoute un débogueur ou des journaux, parce que l’observation change le timing.

C’est précisément pourquoi l’on commence par réduire les endroits qui ont besoin de synchronisation, avant de synchroniser correctement. Si tourner sur plusieurs threads est une exigence, ce que la conception doit réduire, ce sont les données mutables partagées.

3.2. Le modèle mémoire Java définit quand les autres threads voient une écriture

En Java, la visibilité des données partagées est définie par la relation happens-before du modèle mémoire Java (JMM). Accéder à une variable partagée sans synchronisation n’est pas un comportement indéfini comme en C++, mais une valeur périmée peut rester visible, ou des écritures peuvent apparaître dans un autre ordre.5

Par exemple, si une boucle se contente de consulter un drapeau boolean, une valeur modifiée par un autre thread peut ne jamais devenir visible. Ce n’est pas un bug de la JVM ; c’est une erreur de cohérence mémoire due à l’absence de la synchronisation nécessaire.

Ce qui crée happens-before, ce sont synchronized, volatile et les classes de java.util.concurrent. Les collections concurrentes garantissent aussi, dans leur spécification, la relation entre une mise à jour et une récupération ultérieure de cette valeur. Au lieu de ruser avec une variable partagée brute, utilisez les garanties de ces outils. Notez toutefois que le fait qu’une valeur soit visible et le fait que plusieurs opérations s’exécutent comme un tout sont deux choses distinctes. La différence avec l’atomicité est exposée au § 4.1.56

3.3. Découper l’agrégation en résultats partiels et les fusionner à la fin

Pour une agrégation parallèle, plutôt que de faire écrire tous les threads dans une seule variable de total, commencez par faire construire à chaque thread un résultat partiel, puis les additionner à la fin. Les opérations reduce / collect des parallel streams fournissent cette structure comme cadre.

LongAdder, utilisé pour des incrémentations à haute fréquence, est aussi une stratégie qui disperse les mises à jour dans des cellules internes et les additionne à la lecture. Réduisez d’abord les écritures vers l’état partagé, et choisissez ensuite la synchronisation nécessaire.

3.4. Si vous rendez immuable, vérifiez jusqu’aux éléments

Partagez la configuration et les données de référence comme des données immuables, jamais réécrites après construction. Pour les remplacer, la pratique établie est de construire un nouvel objet et de permuter une référence volatile.

Toutefois, utiliser record ou List.copyOf ne rend pas à lui seul l’ensemble des données immuable. Les accesseurs d’un record renvoient les références de ses composants telles quelles. Même si List.copyOf / Map.copyOf rendent la collection non modifiable, les objets éléments ne sont pas copiés en profondeur.

Si les éléments sont mutables, un autre code qui détient une référence vers le même élément peut en réécrire le contenu, et la concurrence demeure. Pour partager comme données immuables sans synchronisation, confirmez que tout le graphe d’objets, éléments compris, est immuable. S’il y a des éléments mutables, coupez le partage par une copie profonde, ou faites aussi passer les éléments en record / types immuables. Distinguez « a l’air en lecture seule » et « est immuable ».

3.5. Utiliser les opérations composées de ConcurrentHashMap, et connaître la portée de leur garantie

Pour « créer et insérer si la clé est absente », n’écrivez pas le test et l’insertion séparément ; utilisez ConcurrentHashMap.computeIfAbsent. L’appel de méthode tout entier s’exécute de façon atomique, et si la clé est absente, la fonction de mapping est appelée exactement une fois dans cet unique appel. La garantie diffère de ConcurrentDictionary.GetOrAdd de .NET, dont la fabrique peut s’exécuter plus d’une fois en cas de contention.6

Pour un compteur de fréquences, la forme suivante est la pratique établie.

// Pratique établie pour un compteur de fréquences : computeIfAbsent + LongAdder
ConcurrentHashMap<String, LongAdder> freqs = new ConcurrentHashMap<>();
freqs.computeIfAbsent(key, k -> new LongAdder()).increment();

« Une fois dans cet appel » et « une fois dans la vie de cette clé » sont différents. Si la fonction renvoie null ou lève une exception, rien n’est enregistré, et un appel ultérieur la réexécute. Il en va de même si une entrée enregistrée est supprimée. Pour une initialisation qui ne tolère pas d’effets de bord dupliqués, concevez-la pour qu’elle réussisse en renvoyant une valeur non nulle, et incluez le traitement des entrées.6

De plus, en échange de l’exécution atomique, certaines mises à jour d’autres threads sont bloquées pendant le calcul. Gardez la fonction de mapping courte et simple, et ne mettez jamais à jour cette map elle-même depuis l’intérieur. Une mise à jour récursive détectable peut aboutir à une IllegalStateException.6

3.6. Utiliser une file bornée pour passer des données entre threads

Au lieu de faire toucher les données directement par plusieurs threads, faites-les transiter par une BlockingQueue. Avec un ArrayBlockingQueue auquel on a donné une capacité, put bloque lorsque la file est pleine, ce qui applique naturellement une backpressure au côté producteur.

C’est la même structure que le canal borné de l’édition .NET. Même avec les threads virtuels, une conception qui rend explicites la frontière producteur/consommateur et le plafond du retard reste efficace.

4. Protéger l’état partagé qui reste — distinguer volatile, les classes Atomic et les verrous

4.1. Visibilité et atomicité sont des garanties distinctes

volatile est un outil de visibilité et d’ordonnancement, c’est-à-dire de happens-before. Ce n’est pas une garantie que « lire, calculer, réécrire » se fasse d’un bloc, donc appliquer ++ à un volatile int depuis plusieurs threads n’empêche pas la perte d’addition de la figure 2.5

Ce qu’il faut protéger Outil à choisir Point d’attention
Notification d’état simple, permutation d’une référence vers des données immuables volatile Pas d’atomicité pour les opérations composées
Mise à jour atomique d’une valeur unique AtomicInteger / AtomicLong / AtomicReference Pour des statistiques incrémentées à haute fréquence, utiliser aussi LongAdder
Cohérence portant sur plusieurs valeurs Un verrou Tenir la même discipline partout où les mêmes données sont touchées

Comme dans les éditions .NET et C++, confiez les mises à jour atomiques aux classes Atomic et l’état composé aux verrous. L’essentiel est de ne pas chercher à rendre quelque chose thread-safe avec volatile seul.

4.2. Faire correspondre les verrous aux données qu’ils protègent, pas à des sections de code

Faites correspondre un objet de verrouillage dédié à chaque ensemble de données mutables à protéger. Puis prenez ce même verrou partout où ces données sont touchées. Rendez possible, en revue, de confirmer quel verrou protège quelles données.

Évitez synchronized(this), synchronized(SomeClass.class) et le verrouillage d’objets exposés publiquement. Du code extérieur peut verrouiller le même objet, et des collisions non voulues se produisent. Utilisez un private final Object lock = new Object(); jamais exposé, ou un ReentrantLock dédié.

4.3. Éviter le travail externe pendant qu’on tient un verrou, et figer l’ordre d’acquisition

Faire de l’E/S, appeler des écouteurs ou exécuter du code inconnu tout en tenant un verrou allonge le temps de détention. Si l’appelé prend un autre verrou, cela peut aussi créer une attente circulaire.

attend la libération du verrou 2attend la libération du verrou 1Thread Adétient le verrou 1Thread Bdétient le verrou 2

Figure 3 : Quand l’un détient le verrou 1 et attend le verrou 2 tandis que l’autre attend dans l’ordre inverse, aucun des deux ne peut avancer.

Là où plusieurs verrous sont pris, figez l’ordre d’acquisition pour tous les threads. Là où l’ordre ne peut pas être garanti, prévoyez un chemin avec tryLock(timeout) qui relâche les verrous détenus et recommence si le verrou ne peut pas être obtenu. Tenez les deux règles ensemble : pas de travail long ou externe pendant qu’on tient un verrou, et un ordre d’acquisition cohérent.

4.4. synchronized pour une exclusion courte, ReentrantLock quand il faut davantage

Pour une exclusion mutuelle courte et simple, synchronized suffit. Choisissez ReentrantLock lorsque vous avez besoin d’une acquisition temporisée avec tryLock(timeout), d’une politique d’équité, de plusieurs Condition, ou de séparer l’acquisition et la libération dans des méthodes distinctes.

Dans l’usage ordinaire de ReentrantLock, ne rompez jamais la forme try immédiatement après lock(), avec unlock() dans finally. N’attendez pas que le verrou soit libéré automatiquement comme avec le RAII de C++ ; structurez le code pour que le verrou soit libéré même en cas d’exception.

En combinaison avec les threads virtuels, vérifiez aussi les différences de JDK du § 2.4. À partir de JDK 24, il n’est pas nécessaire de remplacer synchronized de façon systématique uniquement comme contre-mesure d’épinglage. La discipline de garder les verrous courts, elle, ne change pas.4

5. Décider comment les tâches s’arrêtent — ne pas étouffer les interruptions

5.1. interrupt est un signal d’arrêt coopératif, pas une terminaison forcée

Concevez l’arrêt et l’annulation en Java autour de l’arrêt coopératif par interruption. t.interrupt() positionne le statut d’interruption du thread cible. Si le thread est bloqué dans sleep / wait / join ou équivalent, une InterruptedException le sort de l’attente, et le statut d’interruption est alors effacé.7

Dans l’API JDK 21 à laquelle se réfère cet article, les anciens mécanismes de force Thread.stop / suspend / resume lèvent UnsupportedOperationException. Au lieu de revenir à des mécanismes dangereux qui libèrent des verrous dans un état incohérent ou invitent à l’interblocage, faites en sorte que la tâche elle-même réponde au signal d’arrêt et se termine.7

Peut se terminer lui-mêmeNe peut pas (dans une bibliothèque, etc.)Côté qui arrête : t.interrupt()Le statut d'interruption est positionnéThread en calcul :vérifie Thread.interrupted() dans sa boucleBloqué dans sleep / wait / join :InterruptedException le réveille immédiatement(le statut est effacé)Fait le ménage et se termine lui-mêmeQue fait le catch ?Thread.currentThread().interrupt()restaure le statut et laisse le signal

Figure 4 : La tâche qui reçoit l’interruption fait le ménage et se termine elle-même. Étouffer l’exception fait perdre le signal d’arrêt.

5.2. Rendre explicite la responsabilité après réception d’InterruptedException

N’écrivez pas de code qui attrape InterruptedException et ne fait rien. Si vous pouvez terminer dans votre propre responsabilité, faites le ménage et terminez. Si vous laissez la décision à l’appelant, soit relancez l’exception telle quelle, soit restaurez le statut avec Thread.currentThread().interrupt() pour laisser le signal.7

Une boucle de calcul, elle aussi, doit vérifier l’interruption et avancer vers la sortie, comme dans la figure 4. Implémenter seulement le côté qui envoie la demande d’arrêt n’arrête rien si le côté qui reçoit l’ignore. Cette propriété s’applique telle quelle à shutdownNow() du chapitre suivant.

6. Arrêter ExecutorService — séparer la demande et la vérification d’achèvement

6.1. Appeler shutdownNow ne signifie pas à lui seul que l’arrêt est achevé

Lors de l’arrêt d’un ExecutorService, séparez l’opération qui cesse d’accepter, celle qui demande l’annulation, et celle qui attend l’achèvement.2

API Rôle Ce qu’elle ne garantit pas à elle seule
shutdown() Cesse d’accepter de nouvelles tâches et laisse s’exécuter celles déjà soumises Le thread appelant n’attend pas l’achèvement
awaitTermination(...) Après une demande d’arrêt, attend jusqu’à l’achèvement, l’expiration du délai ou une interruption, selon ce qui arrive en premier L’achèvement de l’arrêt en cas de dépassement de délai
shutdownNow() Tente d’arrêter les tâches en cours et renvoie les tâches en attente qui n’ont jamais tourné La terminaison forcée des tâches en cours, ou l’attente jusqu’à leur fin
close() Cesse d’accepter et attend jusqu’à la terminaison de l’Executor Un plafond sur le temps d’attente

shutdownNow() est du best effort. Les implémentations standard telles que ThreadPoolExecutor annulent typiquement par interruption, donc une tâche qui ne répond pas à l’interruption ne s’arrête pas. Si vous utilisez un Executor personnalisé, vérifiez la méthode d’annulation de cette implémentation, y compris si elle envoie une interruption.2

Pour annuler une tâche individuelle, utilisez Future.cancel(true). Là encore, ne confondez pas une demande d’arrêt transmise à une tâche en cours via une interruption avec le fait que la tâche ait réellement terminé son travail.

6.2. Utiliser un arrêt en deux étapes borné par un délai

D’abord cesser d’accepter et attendre que le travail aille jusqu’au bout ; une fois le délai dépassé, demander l’annulation et attendre encore une fois l’achèvement. S’il n’est toujours pas terminé, le signaler à l’appelant d’une façon distincte du succès. L’exemple suivant s’appuie sur le schéma officiel en deux étapes et y ajoute le succès ou l’échec de l’arrêt et le traitement des Future non exécutés.2

/** Renvoie true une fois l'arrêt achevé. Ne pas passer à la libération des ressources partagées tant que c'est false. */
boolean shutdownAndAwaitTermination(ExecutorService pool) {
    pool.shutdown();                    // Étape 1 : cesser d'accepter et attendre l'achèvement
    try {
        if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
            // Étape 2 : demander l'annulation. Les tâches déchargées sans avoir tourné sont renvoyées,
            // donc marquer leurs Future comme annulés pour réveiller les appelants qui attendent 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 inachevé. Le signaler sous une forme distincte du succès
            }
        }
        return true;
    } catch (InterruptedException ex) {
        pool.shutdownNow().forEach(r -> {
            if (r instanceof Future<?> f) f.cancel(false);
        });
        Thread.currentThread().interrupt();   // Restaurer aussi le statut d'interruption de ce thread
        return false;                   // L'arrêt peut être inachevé sur ce chemin aussi
    }
}
Achevé dans le délaiDélai dépasséAchevéToujours pas finishutdown()Cesser d'accepter de nouvelles tâchesawaitTerminationattend l'achèvementArrêt achevéshutdownNow()Envoie interrupt aux tâches en cours(la réponse dépend de la tâche)awaitTerminationattend à nouveauEnregistrer comme anomalie(une tâche qui ignore les interruptions est suspecte)

Figure 5 : Séparez l’étape qui attend l’achèvement de celle qui demande l’annulation et attend encore. Si ce n’est toujours pas fini, traitez-le comme un arrêt inachevé.

Ne libérez pas les ressources partagées utilisées par les tâches tant que la valeur de retour est false. Non seulement lorsque la seconde attente expire, mais aussi sur le chemin où le côté qui attend a été interrompu, des tâches peuvent encore tourner. Ne vous contentez pas de journaliser et de traiter cela comme un succès ; laissez l’appelant constater que l’arrêt est inachevé.

6.3. Utiliser close et try-with-resources seulement dans des portées qui peuvent aller jusqu’au bout

À partir de JDK 19, ExecutorService peut s’utiliser comme AutoCloseable. try (var executor = ...) attend la terminaison avec close() en quittant la portée. Cela fonctionne aussi combiné à un Executor de threads virtuels.2

Toutefois, close() attend sans délai, donc ce n’est pas un substitut du schéma en deux étapes borné par un délai. S’il existe une tâche qui ne se termine jamais ou qui ne répond pas à l’interruption, le thread qui tente de fermer attend lui aussi indéfiniment.

Utilisez try-with-resources pour une portée finie où les tâches sont soumises sur place et où l’on peut attendre leur achèvement sur place. Sur les chemins où attendre indéfiniment n’est pas une option, comme l’arrêt de toute l’application, concevez une attente bornée par un délai et le traitement d’un arrêt inachevé.2

6.4. La tâche dans la file et le Future de l’utilisateur ne sont pas nécessairement le même objet

Ce que shutdownNow() renvoie, ce sont les objets restés dans la file d’exécution. Avec un submit simple, c’est normalement le FutureTask lui-même qui a été remis à l’utilisateur, donc le cancel(false) de l’exemple peut réveiller les appelants qui attendent sur get().

En revanche, lorsque les tâches sont soumises via un enveloppeur tel qu’ExecutorCompletionService, ce qui revient est l’enveloppeur dans la file, objet distinct du Future de l’utilisateur. Il existe des configurations dans lesquelles l’exemple ci-dessus, à lui seul, ne peut pas achever le Future de l’utilisateur.

Dans ce cas, conservez à la soumission une liste des Future côté utilisateur et annulez-les à l’arrêt, ou concevez le système pour que les tâches déchargées de la file soient renvoyées à leurs propriétaires. Vérifiez le chemin d’arrêt jusqu’à « le côté qui attend un travail dont on sait désormais qu’il ne s’exécutera pas ».

7. Ramener l’UI sur son thread dédié — l’EDT de Swing

L’UI de Swing est gérée par le thread de dispatch des événements (EDT). Les méthodes des composants Swing ne sont, en principe, pas thread-safe, et les toucher depuis plusieurs threads invite à des interférences entre threads et à des erreurs de cohérence mémoire.8

Pour mettre à jour l’écran depuis un autre thread, demandez-le à l’EDT avec SwingUtilities.invokeLater. Inversement, un traitement long sur l’EDT fige l’UI, donc poussez le travail lourd vers un thread de travail avec SwingWorker ou équivalent. Faire sortir le travail et ramener l’affichage du résultat sur le thread d’UI vont ensemble.8

La même structure s’applique à JavaFX. Demandez les mises à jour d’UI sur le thread d’application avec Platform.runLater. Quel que soit le nombre de types de threads, le principe selon lequel l’UI appartient exclusivement au thread qui la gère ne change pas.

8. Préparer la vérification et l’investigation — revue de conception, dumps et essais de charge

8.1. Revoir d’abord les données partagées et le chemin d’arrêt

Même lorsque les tests ordinaires passent, il se peut simplement qu’aucune contention ne se soit produite. N’attendez pas des tests seuls qu’ils trouvent les bugs de concurrence ; placez la première ligne de défense dans la conception.

Utilisez un tableau de correspondance pour vérifier les données mutables partagées, le verrou qui protège chacune, et l’ordre d’acquisition des verrous. Vérifiez aussi qu’il n’y a pas de catch qui étouffe InterruptedException, et que les chemins d’arrêt et d’interruption atteignent toutes les tâches. Transformez directement la conception des chapitres 3 à 6 en points de revue.

8.2. Prendre le dump qui correspond au type de thread

Examinez l’état des threads de plateforme avec jstack ou jcmd <pid> Thread.print. L’option -l ajoute aussi des informations de verrou.9

Toutefois, le format de dump traditionnel n’inclut pas les threads virtuels de l’application. Pour suivre une requête bloquée dans une configuration qui utilise des threads virtuels, capturez un format qui les inclut avec jcmd <pid> Thread.dump_to_file -format=json <fichier>.1

Pour enquêter sur un hang, prenez deux ou trois dumps à quelques secondes d’intervalle et comparez les threads qui ne bougent pas. Pour un thread de plateforme en attente d’un verrou, suivez ce qu’il attend et qui détient ce verrou. Pour les threads virtuels, utilisez le dump qui les inclut pour voir où dans le traitement ils se sont arrêtés. Journaliser les expirations de tryLock(timeout) donne aussi un déclencheur pour prendre un dump.

8.3. Secouer l’ordre d’exécution sous une charge comparable à la production

En plus des dumps comme deuxième ligne de défense, faites des essais de stress comme troisième. Tourner longtemps avec plus de parallélisme qu’il n’y a de cœurs, randomiser l’ordre de traitement et insérer des délais artificiels rendent plus facile d’atteindre les ordres d’exécution où la contention se produit.

Faites au moins une fois, avant la mise en production, un essai avec un volume de données et un nombre de threads comparables à la production. Ne traitez toutefois pas le succès d’un essai de charge comme une raison de se passer de concevoir l’état partagé.

9. La place des nouvelles API — séparer preview et fonctionnalités officielles

Ce chapitre reflète l’état en août 2026. Il sépare les outils de base déjà disponibles des API encore en développement, pour ne pas les confondre.

Structured Concurrency (StructuredTaskScope) est une API qui traite plusieurs sous-tâches liées comme une seule unité de travail et structure la propagation des échecs et l’annulation. Elle est prévue pour s’utiliser avec les threads virtuels, mais à ce stade c’est encore une fonctionnalité en preview. La cinquième preview de JDK 25 (JEP 505) a reformé l’API autour de StructuredTaskScope.open(), et elle continue dans JDK 26 comme sixième preview (JEP 525).1011

Scoped Values, en revanche, qui gèrent le partage de contexte immuable, ont été officialisés dans JDK 25. C’est une solution aux problèmes de ThreadLocal, comme la mutabilité, la gestion de la durée de vie et le coût d’héritage.12

La direction visée par les nouvelles API est la même que les principes de cet article : rendre explicites les frontières des tâches, pousser le partage vers l’immuabilité, et traiter l’arrêt de façon coopérative.

10. Synthèse — 10 points à vérifier avant l’implémentation et en revue

# Ce qu’il faut vérifier
1 Le code métier évite-t-il de créer new Thread directement et confie-t-il les tâches à ExecutorService ?
2 L’attente d’E/S et le calcul CPU ont-ils des mécanismes d’exécution distincts, avec un plafond ou une backpressure aussi pour la file du pool fixe ?
3 Les threads virtuels restent-ils hors pool, les appels simultanés aux services externes étant limités par un Semaphore ?
4 Les données partagées sont-elles orientées vers le découpage, l’immuabilité et le passage, les éléments des record et des collections immuables étant aussi vérifiés ?
5 Les verrous dédiés correspondent-ils aux données, en évitant synchronized(this) et les verrous sur des objets publics ?
6 Les opérations composées telles que computeIfAbsent sont-elles utilisées, en respectant les conditions de réexécution de la fonction de mapping et en la gardant courte ?
7 N’attend-on pas d’atomicité de volatile, les compteurs étant confiés aux classes Atomic ou à LongAdder et l’état composé aux verrous ?
8 InterruptedException n’est-elle jamais étouffée, de sorte que le signal d’arrêt atteigne le côté tâche ?
9 L’arrêt est-il un schéma en deux étapes borné par un délai ? close() est-il limité aux portées qui peuvent garantir l’achèvement, et les arrêts inachevés et Future non exécutés sont-ils traités ?
10 Les mises à jour d’UI Swing / JavaFX sont-elles regroupées sur l’EDT / le thread d’application ?

Les threads virtuels de Java ont ouvert une voie pour faire passer à l’échelle du code synchrone simple, tel quel. Pour autant, l’outil de débit, les outils de protection de l’état partagé et l’outil pour signaler un arrêt sont distincts. Choisir en maintenant séparés les rôles de l’exécution, du partage et de l’arrêt est le fondement de la conception multithread en Java.

Articles associés

Domaines de conseil associés

KomuraSoft LLC assure des revues de conception multithread pour les systèmes métier et les traitements par lots en Java, l’investigation de défaillances liées à la concurrence telles que la corruption d’état partagé et « ça ne s’arrête parfois pas, ou ça fige » (analyse de dumps de threads), et le conseil technique sur l’adoption des threads virtuels.

Références

  1. 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 qui réduisent fortement l’effort d’écriture, de maintenance et d’observation d’applications concurrentes à haut débit ; sur la philosophie de conception consistant à faire passer à l’échelle du code synchrone simple « un thread par requête », tel quel ; sur le fait que l’ordonnanceur de threads virtuels du JDK est un ForkJoinPool à vol de travail opérant en mode FIFO, avec un parallélisme par défaut égal au nombre de processeurs disponibles ; et sur l’ajout d’un nouveau format de dump de threads incluant les threads virtuels, jcmd Thread.dump_to_file (texte brut et JSON), alors que les dumps traditionnels n’incluent pas les threads virtuels. ↩ ↩2 ↩3 ↩4 ↩5 ↩6

  2. Oracle, ExecutorService (Java SE 21 & JDK 21 API). Sur le fait que shutdown() laisse les tâches déjà soumises aller jusqu’au bout tout en cessant d’en accepter de nouvelles ; que shutdownNow() tente d’arrêter les tâches en cours et renvoie la liste des tâches qui attendaient d’être exécutées, bien que l’implémentation typique annule via Thread.interrupt(), qu’il n’y a pas de garantie au-delà du best effort, et qu’une tâche qui ne répond pas à l’interruption ne se termine pas ; que l’on peut attendre l’achèvement avec awaitTermination ; que close() (Java 19 et suivants, AutoCloseable) appelle shutdown et attend l’achèvement, utilisable avec try-with-resources ; et que l’arrêt en deux étapes shutdown, puis awaitTermination, puis shutdownNow est montré comme exemple d’usage. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8

  3. Oracle Java SE Core Libraries, Virtual Threads. Sur le fait que les threads virtuels sont des threads légers implémentés par l’environnement d’exécution Java, qui relâchent leur thread OS pendant une E/S bloquante ; qu’il s’agit d’une fonctionnalité d’échelle (débit) plutôt que de vitesse (latence), inadaptée au traitement intensif en CPU ; qu’il ne faut jamais mettre les threads virtuels en pool et en utiliser un par tâche (newVirtualThreadPerTaskExecutor) ; qu’il faut utiliser un Semaphore plutôt qu’un pool de threads pour limiter la concurrence ; que, dans JDK 21, bloquer à l’intérieur de synchronized provoquait un épinglage au thread OS, pour lequel le remplacement des sites à blocage fréquent ou long par ReentrantLock était conseillé ; et que l’on peut détecter l’épinglage avec -Djdk.tracePinnedThreads. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7

  4. OpenJDK, JEP 491: Synchronize Virtual Threads without Pinning. Sur le fait que l’implémentation des moniteurs de la JVM a été réécrite pour les threads virtuels dans JDK 24, de sorte que bloquer dans un bloc ou une méthode synchronized n’épingle plus un thread virtuel à son thread porteur ; et que cela rend en principe inutile la contre-mesure de l’époque JDK 21 à 23 consistant à « remplacer synchronized par ReentrantLock ». ↩ ↩2

  5. Oracle, The Java Tutorials, Memory Consistency Errors. Sur le fait que les erreurs de cohérence mémoire naissent lorsque plusieurs threads ont des vues incohérentes des mêmes données ; que la clé pour les éviter est la relation happens-before (garantie qu’une écriture mémoire d’une instruction est visible d’une autre instruction) ; et que synchronized, volatile et Thread.start / join, entre autres, créent des relations happens-before. ↩ ↩2 ↩3

  6. Oracle, ConcurrentHashMap (Java SE 21 & JDK 21 API). Sur le fait que l’appel de méthode tout entier de computeIfAbsent s’exécute de façon atomique, la fonction de mapping étant appelée exactement une fois lorsque la clé est absente ; que certaines opérations de mise à jour d’autres threads sont bloquées pendant le calcul, qu’il faut donc le garder court et simple ; que la fonction de mapping a interdiction de modifier cette map, une mise à jour récursive détectable aboutissant à IllegalStateException ; et que les opérations de récupération (get) ne bloquent pas, une relation happens-before tenant entre une mise à jour pour une clé donnée et une récupération ultérieure. ↩ ↩2 ↩3 ↩4

  7. Oracle, Thread (Java SE 21 & JDK 21 API). Sur le fait que Thread.stop / suspend / resume sont fondamentalement dangereux (les verrous sont libérés dans un état incohérent et des objets corrompus deviennent visibles ; suspend invite à l’interblocage), ce qui les rend dépréciés en vue de suppression, et qu’ils lèvent désormais UnsupportedOperationException lorsqu’on les appelle ; que interrupt() positionne le statut d’interruption et réveille un thread bloqué dans sleep / wait / join en levant InterruptedException (le statut d’interruption étant alors effacé) ; et sur la différence de traitement de ce statut entre interrupted() et isInterrupted(). ↩ ↩2 ↩3

  8. Oracle, The Java Tutorials, The Event Dispatch Thread. Sur le fait que le code de traitement des événements Swing s’exécute sur le thread de dispatch des événements (EDT) ; que la plupart des méthodes des objets Swing ne sont pas thread-safe, si bien que les appeler depuis plusieurs threads invite à des interférences entre threads et à des erreurs de cohérence mémoire, ce qui veut dire que l’accès aux composants Swing doit, en principe, se faire sur l’EDT ; que l’on demande des tâches à l’EDT depuis d’autres threads avec SwingUtilities.invokeLater / invokeAndWait ; et que les tâches sur l’EDT doivent se terminer rapidement. ↩ ↩2

  9. Oracle, The jstack Command (Java SE 21 Tools Reference). Sur le fait que jstack imprime les traces de pile (nom de classe, nom de méthode, numéro de ligne) de tous les threads d’un processus Java spécifié ; que l’option -l active un affichage détaillé incluant des informations de verrou supplémentaires ; et qu’il s’utilise aux côtés d’autres outils de diagnostic tels que jcmd. ↩

  10. OpenJDK, JEP 505: Structured Concurrency (Fifth Preview). Sur le fait que l’API de concurrence structurée traite un groupe 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é reformé en une forme ouverte par une méthode de fabrique statique (open) ; et qu’à JDK 25, c’est une cinquième preview et non une fonctionnalité officielle. ↩

  11. OpenJDK, JEP 525: Structured Concurrency (Sixth Preview). Sur le fait que la concurrence structurée continue aussi comme sixième preview dans JDK 26, ce qui signifie qu’en août 2026 elle reste une fonctionnalité en preview même dans le JDK courant, et que l’utiliser exige d’activer les fonctionnalités en preview. ↩

  12. OpenJDK, JEP 506: Scoped Values. Sur le fait que Scoped Values ont été officialisés dans JDK 25 ; et qu’il s’agit d’un mécanisme pour partager des données de contexte immuables de façon sûre et efficace à l’intérieur d’un thread et entre threads, apportant une solution aux problèmes de ThreadLocal (mutabilité, gestion de la durée de vie et coût d’héritage). ↩

Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.

Ces pages replacent le sujet dans un contexte plus large de services et de décisions.

Cet article est directement lié aux services suivants.

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.

Retour au blog