Programmation Java

Concurrence en Java : threads, synchronisation, executors et virtual threads

Avancé10 min de lectureMis à jour

Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise

Le code concurrent échoue d'une manière qu'aucun test unitaire ne révèle : la seule protection, c'est de comprendre les règles. Ce guide explique ce qu'est un thread, pourquoi deux threads peuvent ne pas voir la même valeur d'une variable, comment synchronized, volatile, les locks et les atomiques assurent visibilité et atomicité, comment les executors et CompletableFuture structurent le travail, et en quoi les virtual threads de Java 21 changent la donne pour les services dominés par les I/O. Vous saurez écrire du code à état partagé correct, et pas seulement chanceux.

Ce que vous allez apprendre

  • Expliquer les notions du modèle mémoire Java : atomicité, visibilité et happens-before
  • Protéger correctement un état partagé avec synchronized, volatile, les locks de java.util.concurrent et les atomiques
  • Exécuter et coordonner des tâches avec ExecutorService, Future et CompletableFuture
  • Choisir les collections concurrentes et les files bloquantes adaptées à un schéma producteur-consommateur
  • Gérer correctement l'interruption et l'arrêt, et savoir quand les virtual threads s'appliquent

Le multithreading en Java n'est pas difficile parce que l'API est vaste — la plupart des programmes n'utilisent qu'une poignée de classes —, mais parce que les défaillances sont invisibles : un code faux s'exécute correctement un million de fois sur votre portable, puis corrompt le total d'une commande sur un serveur à 32 cœurs un vendredi soir. Les règles qui décident de la correction sont celles du modèle mémoire Java, et une fois que vous les connaissez, les constructions de java.util.concurrent ne ressemblent plus à un catalogue mais à des réponses à des questions précises. Ce guide vise Java 21, la version qui a aussi introduit les virtual threads.

Les threads et ce qui tourne mal

Un thread est un fil d'exécution indépendant qui partage le tas (heap) du processus avec tous les autres threads. En créer un est trivial :

Thread t = new Thread(() -> System.out.println("running on " + Thread.currentThread().getName()));
t.start();          // schedules the thread; run() would just call the method on the current thread
t.join();           // wait for it to finish

Les ennuis commencent avec l'état mutable partagé. Deux propriétés doivent être respectées pour que des données partagées restent correctes :

  • Atomicité — du point de vue des autres threads, une opération s'exécute entièrement ou pas du tout. count++ n'est pas atomique : c'est une lecture, une addition et une écriture, et deux threads qui les entrelacent perdent un incrément.
  • Visibilité — une écriture faite par un thread finit par être vue par un autre. Sans synchronisation, rien ne le garantit : le JIT peut conserver une valeur dans un registre ou réordonner des instructions, et un cœur peut servir une ligne de cache périmée. Une boucle while (!stopped) {} peut réellement tourner indéfiniment, même après qu'un autre thread a exécuté stopped = true.

Le modèle mémoire Java (chapitre 17 de la JLS) définit, via la relation happens-before, quand une écriture est garantie visible par une lecture. Les arêtes utiles : libérer un moniteur happens-before toute acquisition ultérieure de ce moniteur ; une écriture dans un champ volatile happens-before toute lecture ultérieure de ce champ ; Thread.start() happens-before tout ce qui s'exécute dans le thread démarré ; tout ce qui s'exécute dans un thread happens-before le retour de join() sur ce thread ; soumettre une tâche à un executor happens-before l'exécution de la tâche. Tout code concurrent correct s'appuie sur l'une de ces arêtes, même si son auteur ne la nomme jamais.

synchronized, volatile et atomiques

synchronized acquiert le verrou intrinsèque (le moniteur) d'un objet, exécute le bloc, puis le libère. Un seul thread à la fois détient un moniteur donné : le bloc est donc atomique vis-à-vis des autres blocs synchronisés sur le même moniteur, et la paire acquisition/libération assure la visibilité.

public final class Counter {
    private long value;                                   // guarded by this

    public synchronized void increment() { value++; }     // locks `this`
    public synchronized long get() { return value; }      // reads need the lock too, for visibility
}

Remarquez le synchronized sur get(). Un verrou ne protège des données que si chaque accès, lectures comprises, le prend. Ne synchroniser que les écritures laisse les lecteurs avec des valeurs périmées.

Un champ volatile apporte la visibilité sans l'exclusion mutuelle : les lectures et écritures vont directement en mémoire principale (conceptuellement) et ne sont pas réordonnées avec le code environnant. C'est le bon outil pour un indicateur ou une référence qu'un thread publie et que d'autres lisent ; c'est le mauvais pour count++.

private volatile boolean running = true;

public void stop() { running = false; }                   // visible to the worker immediately

public void run() {
    while (running) { doWork(); }                          // exits promptly after stop()
}

Pour les compteurs et les mises à jour d'une seule variable, java.util.concurrent.atomic fournit des classes sans verrou fondées sur l'instruction compare-and-swap du processeur : AtomicInteger, AtomicLong, AtomicReference, AtomicBoolean et, pour du comptage à forte contention, LongAdder. incrementAndGet, compareAndSet et updateAndGet(fn) sont atomiques et visibles sans verrou.

private final AtomicLong requests = new AtomicLong();
requests.incrementAndGet();

private final AtomicReference<Config> config = new AtomicReference<>(initial);
config.updateAndGet(c -> c.withTimeout(Duration.ofSeconds(5)));   // retried until the CAS succeeds

L'autre voie vers la sûreté consiste à ne pas avoir d'état mutable partagé du tout : les objets immuables (records, List.of) circulent librement entre threads, et la conception concurrente la plus simple est souvent « chaque tâche possède ses données ; on combine les résultats à la fin ».

Locks et conditions

synchronized n'accepte aucun délai d'attente, ne peut pas être interrompu pendant qu'il attend et libère toujours le verrou à la sortie du bloc. ReentrantLock offre tryLock(timeout), lockInterruptibly() et l'équité (fairness), au prix d'une libération manuelle dans un finally :

private final ReentrantLock lock = new ReentrantLock();

public boolean transfer(Account to, long cents) throws InterruptedException {
    if (!lock.tryLock(1, TimeUnit.SECONDS)) return false;    // give up rather than deadlock
    try {
        debit(cents);
        to.credit(cents);
        return true;
    } finally {
        lock.unlock();                                       // always
    }
}

ReentrantReadWriteLock laisse de nombreux lecteurs avancer en parallèle tandis que les écrivains sont exclusifs, ce qui convient aux caches majoritairement lus ; StampedLock ajoute des lectures optimistes pour le même usage, au prix d'une complexité accrue.

Un deadlock survient quand le thread A détient le verrou 1 et attend le verrou 2 pendant que le thread B détient le 2 et attend le 1. Le remède classique est un ordre global d'acquisition (toujours verrouiller d'abord le compte dont l'identifiant est le plus petit) ; tryLock avec timeout sert de solution de repli quand imposer un ordre n'est pas praticable. Un thread dump (jcmd <pid> Thread.print) signale explicitement les threads en deadlock.

Object.wait()/notifyAll() et Condition.await()/signalAll() permettent à un thread de dormir jusqu'à ce qu'un état change. Appelez-les toujours dans une boucle qui revérifie la condition, car les réveils intempestifs (spurious wake-ups) sont autorisés et un autre thread a pu consommer l'état entre-temps. En pratique, vous écrirez rarement ce code vous-même : BlockingQueue, CountDownLatch, Semaphore et CompletableFuture l'encapsulent.

Les executors : la bonne façon d'exécuter des tâches

Créer un thread par tâche ne passe pas à l'échelle et ne vous donne aucun cycle de vie. Un ExecutorService découple ce qu'il faut exécuter (un Runnable ou un Callable<T>) de la manière de l'exécuter (un pool de N threads, un thread unique, un virtual thread par tâche) :

ExecutorService pool = Executors.newFixedThreadPool(8);
try {
    List<Callable<Long>> jobs = files.stream()
            .map(f -> (Callable<Long>) () -> Files.size(f))
            .toList();

    long total = 0;
    for (Future<Long> f : pool.invokeAll(jobs)) {
        total += f.get();                                  // rethrows the task's exception as ExecutionException
    }
    System.out.println("Total bytes: " + total);
} finally {
    pool.shutdown();                                       // stop accepting; finish queued tasks
    if (!pool.awaitTermination(30, TimeUnit.SECONDS)) pool.shutdownNow();
}

Depuis Java 19, ExecutorService implémente AutoCloseable, et close() effectue un shutdown() suivi d'un awaitTermination() sans limite de durée : le try-with-resources fonctionne donc pour les executors à courte durée de vie.

Dimensionnement du pool : pour du travail CPU-bound, à peu près le nombre de cœurs (Runtime.getRuntime().availableProcessors()) ; pour des I/O bloquantes sur des threads de plateforme, davantage — ou, mieux, des virtual threads. ThreadPoolExecutor expose la taille de base, la taille maximale, la file d'attente et la politique de rejet ; les fabriques d'Executors les masquent, et newCachedThreadPool en particulier crée des threads sans limite sous charge. Un ScheduledExecutorService exécute des tâches après un délai ou à cadence fixe ; il remplace java.util.Timer, dont l'unique thread meurt à la première exception non interceptée.

Les exceptions levées dans les tâches n'arrivent pas dans vos logs si vous n'allez pas les chercher : un Runnable soumis avec execute part vers le gestionnaire d'exceptions non interceptées du thread, tandis qu'une tâche soumise avec submit est capturée dans le Future et perdue en silence si vous n'appelez jamais get(). Enveloppez le corps des tâches dans un try/catch qui journalise, ou consommez toujours le future.

CompletableFuture : composer du travail asynchrone

Future.get() bloque. CompletableFuture vous permet de décrire ce qui se passe quand un résultat arrive, et de combiner plusieurs résultats, sans bloquer de thread avant la toute fin :

CompletableFuture<Customer> customer = CompletableFuture.supplyAsync(() -> customers.load(id), pool);
CompletableFuture<List<Order>> orders = CompletableFuture.supplyAsync(() -> orderRepo.forCustomer(id), pool);

CompletableFuture<Invoice> invoice = customer
        .thenCombine(orders, (c, os) -> Invoice.of(c, os))          // when both complete
        .thenApply(pricing::apply)                                   // transform
        .exceptionally(ex -> Invoice.failed(id, ex))                 // recover
        .orTimeout(5, TimeUnit.SECONDS);                             // fail if too slow (Java 9)

Invoice result = invoice.join();                                     // block only at the edge

thenApply transforme, thenCompose enchaîne une étape qui renvoie elle-même un future (le flatMap des futures), thenCombine en réunit deux, allOf/anyOf en réunissent plusieurs, et handle/whenComplete/exceptionally traitent les échecs. Les méthodes suffixées par Async exécutent la suite sur un executor fourni plutôt que sur le thread qui a terminé l'étape précédente ; sans executor fourni, les méthodes *Async et supplyAsync utilisent le ForkJoinPool commun, dimensionné pour le travail CPU et partagé avec les streams parallèles — n'y bloquez jamais.

Collections concurrentes et files

Les collections de java.util.concurrent sont conçues pour le partage, contrairement aux wrappers Collections.synchronizedX, qui sérialisent chaque appel et laissent pourtant l'itération et les séquences check-then-act non sûres.

ClasseUsage
ConcurrentHashMapMap partagée ; compute, merge, putIfAbsent sont atomiques par clé ; l'itération ne lève jamais d'exception
CopyOnWriteArrayListListes de listeners et de configuration : écritures rares, lectures fréquentes, itération sur un instantané
ConcurrentLinkedQueue / ConcurrentLinkedDequeFile non bloquante pour transmettre des éléments sans attendre
ArrayBlockingQueue / LinkedBlockingQueueProducteur–consommateur avec back-pressure : put bloque quand la file est pleine, take quand elle est vide
ConcurrentSkipListMap / SetMap/set concurrents triés

ConcurrentHashMap.computeIfAbsent(key, fn) est atomique pour la clé, ce qui fait d'un cache de mémoïsation une affaire d'une ligne — mais fn doit être court et ne doit pas toucher à la même map, sous peine de deadlock ou d'IllegalStateException.

Le schéma producteur–consommateur avec une BlockingQueue bornée est la manière standard de relier des étapes qui avancent à des vitesses différentes ; c'est la borne qui empêche un producteur rapide d'épuiser la mémoire. Un élément poison pill ou un shutdownNow signale aux consommateurs qu'ils doivent s'arrêter.

Utilitaires de coordination

  • CountDownLatch — attendre que N événements se soient produits (N workers démarrés, N résultats arrivés). À usage unique.
  • CyclicBarrier — N threads s'attendent mutuellement en un point, puis continuent ; réutilisable.
  • Semaphore — limite l'accès concurrent à une ressource à K permis, par exemple K téléchargements simultanés.
  • Phaser — une barrière plus générale, à plusieurs phases.
  • ThreadLocal — une variable propre à chaque thread, utilisée pour le contexte de requête et pour des utilitaires non thread-safe comme SimpleDateFormat ; appelez toujours remove() à la fin de la tâche quand les threads viennent d'un pool, sinon la valeur fuit vers la tâche suivante.

Interruption et arrêt

Java n'offre aucun moyen sûr de tuer un thread. L'interruption est une demande coopérative : thread.interrupt() positionne un indicateur ; les méthodes bloquantes (sleep, wait, join, BlockingQueue.take, Future.get) réagissent en levant InterruptedException et en effaçant l'indicateur ; les boucles CPU-bound doivent interroger elles-mêmes Thread.currentThread().isInterrupted().

La règle quand vous interceptez InterruptedException sans pouvoir la propager : restaurez l'indicateur et arrêtez-vous dès que possible.

@Override
public void run() {
    try {
        while (!Thread.currentThread().isInterrupted()) {
            Job job = queue.take();                        // throws if interrupted while waiting
            process(job);
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();                // preserve the request for callers
    }
}

Avaler l'exception, c'est exactement ainsi qu'un service en vient à ignorer shutdownNow() et à tourner jusqu'à ce que le système d'exploitation le tue.

Virtual threads (Java 21)

Un thread de plateforme enveloppe un thread du système d'exploitation : environ un mégaoctet de pile réservé, quelques milliers par processus tout au plus, et une création coûteuse. C'est la raison d'être des pools de threads, et la raison pour laquelle les serveurs qui bloquent un thread par requête plafonnent à quelques milliers de requêtes simultanées. Un virtual thread (JEP 444, finalisée dans Java 21) est un thread léger que la JVM ordonnance sur un petit pool de threads porteurs (carrier threads). Quand il bloque sur une I/O, sa pile est mise de côté sur le tas et le porteur exécute un autre virtual thread. Des millions peuvent coexister.

try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<String>> results = new ArrayList<>();
    for (String url : urls) {
        results.add(executor.submit(() -> httpClient.send(request(url), BodyHandlers.ofString()).body()));
    }
    for (Future<String> f : results) System.out.println(f.get().length());
}   // close() waits for every task

Le modèle de programmation est le modèle bloquant habituel — pas de callbacks, pas d'opérateurs réactifs —, et c'est tout l'intérêt. Quelques règles :

  • Ne mettez pas les virtual threads en pool. Ils ne coûtent presque rien ; créez-en un par tâche. Pour limiter la concurrence face à une ressource rare, utilisez plutôt un Semaphore.
  • Évitez synchronized autour des appels bloquants. Sur Java 21, un virtual thread bloqué à l'intérieur d'un bloc synchronized ou d'un appel natif est épinglé (pinned) à son porteur, ce qui annule tout le bénéfice. Remplacez-le par ReentrantLock, qui n'épingle pas. (Java 24 a supprimé l'essentiel de l'épinglage, mais c'est probablement la LTS 21 que vous faites tourner.)
  • Méfiez-vous de ThreadLocal. Un million de threads avec chacun un objet en cache, cela fait beaucoup de mémoire ; les scoped values (fonctionnalité en preview dans Java 21) en sont le successeur désigné.
  • Le travail CPU-bound n'y gagne rien. Les virtual threads réduisent le coût de l'attente, pas celui du calcul.

Spring Boot 3.2+ exécute le traitement des requêtes Tomcat sur des virtual threads dès que spring.threads.virtual.enabled=true est défini : c'est le moyen le plus rapide de les essayer dans un service existant.

Erreurs courantes

Lectures non synchronisées. Verrouiller les écritures mais lire un champ sans le verrou, ou sans volatile, autorise des valeurs périmées indéfiniment.

Check-then-act sur une collection partagée. if (!map.containsKey(k)) map.put(k, v) est une race condition ; utilisez putIfAbsent ou computeIfAbsent.

Bloquer dans le pool commun. CompletableFuture.supplyAsync(() -> jdbcCall()) sans executor, ou des I/O bloquantes dans un stream parallèle, affament tous les autres utilisateurs de ForkJoinPool.commonPool().

Perdre les exceptions des tâches. submit() sans get() masque l'échec. Consommez le future ou journalisez dans la tâche.

Files et pools non bornés. newCachedThreadPool et LinkedBlockingQueue sans capacité transforment un pic de trafic en OutOfMemoryError.

Oublier shutdown(). Les threads non-daemon d'un pool maintiennent la JVM en vie après le retour de main.

Avaler InterruptedException. Cela casse l'annulation pour tous les appelants au-dessus de vous.

Double-checked locking sans volatile. Le bug classique du singleton paresseux ; l'idiome de la classe holder ou un simple synchronized sont plus simples et corrects.

ThreadLocal sans remove(). Les threads d'un pool transportent le contexte de la requête précédente dans la suivante — une faille de sécurité autant qu'une fuite.

Points clés à retenir

  • La correction dépend du modèle mémoire : des données mutables partagées exigent une arête happens-before — un verrou, volatile, un atomique, ou une transmission via un executor ou une collection concurrente — pour les écrivains comme pour les lecteurs.
  • synchronized apporte atomicité et visibilité ; volatile, la visibilité seulement ; les atomiques, des mises à jour sans verrou d'une seule variable ; ReentrantLock ajoute les timeouts et l'interruptibilité.
  • Exécutez les tâches via un ExecutorService avec un pool borné et un arrêt explicite ; composez les résultats asynchrones avec CompletableFuture et fournissez votre propre executor pour le travail bloquant.
  • Préférez ConcurrentHashMap, CopyOnWriteArrayList et des BlockingQueue bornées aux wrappers synchronisés ; leurs opérations atomiques par clé éliminent la plupart des races check-then-act.
  • L'interruption est coopérative : traitez InterruptedException en restaurant l'indicateur et en sortant.
  • Les virtual threads font passer à l'échelle le code bloquant un thread par tâche pour les services dominés par les I/O ; créez-en un par tâche, évitez synchronized autour des appels bloquants sur Java 21, et n'attendez aucun gain pour le travail CPU-bound.

Sources et lectures complémentaires

Questions fréquentes

Quelle est la différence entre synchronized et volatile ?
volatile garantit la visibilité : une écriture est vue par toute lecture ultérieure, quel que soit le thread. Il ne rend pas atomiques les opérations composées comme count++. synchronized garantit à la fois l'exclusion mutuelle et la visibilité pour tout ce qui se passe dans le bloc. Utilisez volatile pour un indicateur écrit par un thread et lu par d'autres ; utilisez synchronized, un Lock ou une classe atomique quand une séquence lecture-modification-écriture doit être indivisible.
Quand faut-il utiliser les virtual threads ?
Pour les charges comportant beaucoup de tâches concurrentes qui passent l'essentiel de leur temps bloquées sur des I/O : handlers HTTP, appels à la base de données, consommateurs de messages. Les virtual threads permettent au modèle un thread par requête de monter à des centaines de milliers de tâches sans pool. Ils n'accélèrent pas le travail CPU-bound, et sur Java 21 un code qui conserve un bloc synchronized pendant un appel bloquant épingle le thread porteur : passez ces endroits en revue avant de migrer.
Pourquoi ne pas créer de threads avec new Thread() dans le code applicatif ?
Créer des threads sans limite épuise la mémoire et le CPU sous charge, et un thread brut n'offre ni file d'attente, ni gestion du résultat, ni arrêt propre. Un ExecutorService borne la concurrence, renvoie des Future, propage les exceptions et s'arrête proprement. Pour les virtual threads, utilisez Executors.newVirtualThreadPerTaskExecutor.
Comment arrêter un thread en toute sécurité ?
Demandez-lui de s'arrêter en l'interrompant, et écrivez la tâche pour qu'elle vérifie Thread.interrupted() dans ses boucles et laisse remonter InterruptedException depuis les appels bloquants. Depuis Java 20, Thread.stop lève UnsupportedOperationException, parce qu'il laissait les locks et les données dans un état indéterminé. Les executors exposent ce mécanisme via shutdownNow, qui interrompt toutes les tâches en cours.