W3docs

Introduction au multithreading Java

Ce que sont les threads, pourquoi les utiliser en Java, et les compromis de la programmation concurrente.

Chaque programme Java que vous avez écrit jusqu'ici possédait un seul fil d'exécution — un curseur qui avance pas à pas dans le bytecode, une variable sur la pile, un appel de méthode à la fois. C'est le thread « main » que la JVM démarre pour vous. Le multithreading, c'est la JVM qui fait tourner plusieurs de ces curseurs en même temps, partageant le même tas. Deux threads peuvent être à l'intérieur de deux méthodes différentes sur deux objets différents au même instant — et c'est à la fois la puissance et le danger.

Les processeurs modernes ont de nombreux cœurs. Un programme à un seul thread laisse tous les autres inutilisés. Un serveur web qui traite une requête à la fois ne peut pas tirer parti d'une machine à 16 cœurs plus qu'il ne pourrait le faire avec un seul cœur. La raison d'être du multithreading est de mettre ces cœurs à profit et de maintenir la réactivité du programme lorsqu'une partie attend (le disque, le réseau, l'utilisateur).

Ce qu'est réellement un thread

Un thread Java est deux choses liées ensemble :

  • Un thread au niveau du système d'exploitation que le système d'exploitation planifie sur un cœur de CPU. Il possède un compteur de programme, un ensemble de registres et une pile native. L'OS le partage en tranches de temps avec tous les autres threads exécutables de la machine.
  • Un objet Java de type java.lang.Thread. Il porte un nom, une priorité, un indicateur daemon et — surtout — une référence au Runnable dont la méthode run() sera exécutée.

Quand vous appelez thread.start(), la JVM demande à l'OS de créer un nouveau thread natif qui, lors de son premier ordonnancement, appellera votre méthode run(). Le thread original continue immédiatement ; les deux s'exécutent désormais de façon concurrente.

public static void main(String[] args) {
  System.out.println("main: hello from " + Thread.currentThread().getName());

  Thread t = new Thread(() -> {
    System.out.println("worker: hello from " + Thread.currentThread().getName());
  }, "worker-1");

  t.start();                                         // worker runs concurrently with main
  System.out.println("main: continuing");
}

L'entrelacement des sorties n'est pas déterministe — l'OS décide quel thread s'exécute en premier, et cette décision change d'une exécution à l'autre. Ce non-déterminisme est le fait central de la programmation concurrente.

Pourquoi utiliser des threads

Deux motivations distinctes, souvent confondues :

  • Débit. Vous avez du travail lié au CPU — redimensionnement d'images, analyse, compression. Un thread utilise un cœur ; huit threads utilisent huit cœurs et terminent environ huit fois plus vite. C'est le parallélisme.
  • Réactivité. Vous avez un thread qui serait autrement bloqué — en attente d'une réponse réseau, d'une réponse de base de données, d'un clic utilisateur. Confier ce travail à un thread séparé permet au reste du programme de continuer à faire des choses utiles pendant l'attente. C'est la concurrence.

La plupart des programmes réels ont besoin des deux. Un serveur web utilise de nombreux threads pour qu'une requête lente ne bloque pas les autres (concurrence) et pour que de nombreuses requêtes rapides puissent être traitées en parallèle sur les cœurs (parallélisme).

Pourquoi les threads sont difficiles

Les threads partagent la mémoire. Le même HashMap, ArrayList, ou int counter++ peut être touché par deux threads au même instant — et la JVM, les caches CPU et le compilateur sont tous autorisés à réordonner les opérations d'une manière qui peut vous surprendre. Les trois problèmes que le code multithread rencontre sans cesse :

  • Conditions de course. Deux threads lisent-modifient-écrivent la même variable ; l'une des mises à jour est perdue. counter++ n'est pas atomique — c'est lire counter, ajouter un, écrire counter. Deux threads peuvent tous deux lire la même valeur et tous deux écrire valeur + 1, et vous perdez un incrément. La solution est la synchronisation — forcer la lecture-modification-écriture à se produire comme une étape indivisible.
  • Visibilité. Un thread écrit un champ ; un autre thread le lit et voit l'ancienne valeur, parce que chaque thread a son propre cache CPU et qu'il n'existe aucune règle forçant la propagation de l'écriture sans barrière mémoire. C'est pourquoi volatile, synchronized et java.util.concurrent existent.
  • Interblocage (Deadlock). Le thread A détient le verrou X et attend le verrou Y ; le thread B détient le verrou Y et attend le verrou X. Aucun ne progresse jamais. Le programme se fige sans exception ni ligne de journal. Le chapitre sur les deadlocks montre comment les détecter et les éviter.

Le reste de cette partie du livre consiste en grande partie à prévenir ces trois échecs tout en conservant le gain de débit.

Le vocabulaire que vous rencontrerez

Quelques termes qui apparaissent partout et que les chapitres suivants supposent acquis :

TermeCe que cela signifie
ConcurrencePlusieurs tâches progressent sur la même période de temps. Elles peuvent ou non s'exécuter littéralement au même instant.
ParallélismePlusieurs tâches s'exécutent littéralement au même instant sur des cœurs différents. Un sous-ensemble de la concurrence.
Exclusion mutuelleUn seul thread est autorisé dans une section critique à la fois. Les verrous et synchronized le garantissent.
Modèle mémoireLes règles qui indiquent quand un thread est garanti de voir l'écriture d'un autre thread. Défini par le JLS, affiné par JSR-133.
AtomiqueUne opération qui ne peut pas être observée à moitié réalisée. Soit elle s'est produite, soit non — aucun état intermédiaire visible par les autres threads.
Thread-safeUne classe dont l'API publique peut être appelée depuis plusieurs threads sans synchronisation externe et se comporter correctement.

Threads daemon et la règle de sortie de la JVM

Une chose qui surprend les débutants : la JVM se termine quand le dernier thread non-daemon se termine. Le thread main est non-daemon. Les threads que vous créez avec new Thread(...) sont non-daemon par défaut — donc démarrer un thread worker maintient la JVM en vie jusqu'à ce que ce worker se termine.

Vous pouvez marquer un thread comme daemon avec t.setDaemon(true) avant start(). Les threads daemon ne maintiennent pas la JVM en vie ; quand tous les threads non-daemon se terminent, la JVM les supprime. Utilisez les daemons pour des tâches en arrière-plan qui doivent mourir avec le programme (un timer qui interroge, un videur de métriques) — jamais pour un travail dont vous avez réellement besoin de l'achèvement (écritures de fichiers, commits de transactions).

Threads vs. threads virtuels

Java 21 a introduit les threads virtuels, qui sont identiques au niveau de l'API mais sont ordonnancés par la JVM sur un petit pool de threads OS. Le modèle mental de ce chapitre — un Thread Java égale un thread OS — décrit les « threads de plateforme », ce que vous obtenez avec le constructeur new Thread(...) standard. Les threads de plateforme sont coûteux : chacun nécessite environ 1 Mo de pile native et l'OS limite leur nombre par processus, donc vous les créez avec soin. Les threads virtuels sont bon marché — des millions conviennent — et ils rendent l'I/O bloquante à nouveau gratuite. Nous les abordons dans Java Virtual Threads ; jusqu'alors, « thread » signifie « thread de plateforme ».

Un exemple concret : série vs. parallèle

Le programme ci-dessous additionne une charge de travail CPU de deux façons — une fois séquentiellement sur le thread principal, une fois répartie sur quatre threads — et affiche le temps d'horloge murale pour chacune. Les chiffres varient selon la machine, mais la forme est identique partout : plus de threads, moins de temps, jusqu'à épuisement des cœurs.

java— editable, runs on the server

Ce qu'il faut retenir de l'exécution :

  • L'exécution série a utilisé un cœur ; l'exécution parallèle en a utilisé quatre. L'accélération est sous-linéaire (plus proche de 3x que de 4x) car l'OS, le GC et les autres threads JVM veulent également du temps CPU. La loi d'Amdahl en action — une petite fraction série (la boucle finale de somme des partiels, le démarrage de la boucle) plafonne l'accélération.
  • Chaque worker a écrit dans son propre slot dans partials[]. Deux threads n'ont jamais touché le même index, donc aucune synchronisation n'était nécessaire. C'est la forme la plus simple de parallélisme — partitionner les données et laisser chaque thread posséder sa partition.
  • t.join() est la façon dont main attend que worker-3 se termine. Sans les joins, la boucle lirait partials avant que les workers n'aient écrit, et parallelSum serait incorrect. join est l'unique pièce de coordination entre threads que ce programme utilise ; les chapitres suivants en introduiront beaucoup d'autres.
  • Le thread daemon en bas n'a pas maintenu la JVM en vie. Il allait dormir pendant 60 secondes, mais main est revenu et la JVM s'est terminée, supprimant le daemon en plein sommeil sans exécuter son instruction print. C'est le contrat du daemon.
  • Thread.currentThread().getName() et le nom explicite passé au constructeur Thread sont la façon de distinguer les threads dans les logs, les profileurs et les thread dumps. Nommez toujours vos threads — Thread-3 est inutile quand vous essayez de déterminer lequel est bloqué.

La suite

Le chapitre suivant, Java Thread Class, examine en détail l'objet Thread lui-même — ses constructeurs, la différence entre étendre Thread et lui passer un Runnable, et l'API pour le nommage, la priorité, le statut daemon et l'interruption.

Pratique

Pratique
Deux threads exécutent chacun `counter++` 100 000 fois sur un `int counter` partagé qui commence à 0. Après les deux `join()`, quelle est la valeur la plus probable de `counter` ?
Deux threads exécutent chacun `counter++` 100 000 fois sur un `int counter` partagé qui commence à 0. Après les deux `join()`, quelle est la valeur la plus probable de `counter` ?
Was this page helpful?