🔝 Retour au Sommaire
Chapitre 8 — Concurrence et parallélisme · Retour au chapitre
Prérequis : POO (chapitre 3), lambdas et interfaces fonctionnelles (chapitre 4).
Cette première section pose les fondations. Avant de parler de pools, de virtual threads ou de concurrence structurée, il faut comprendre ce qu'est un thread, comment en créer un, et comment il vit et meurt. C'est la brique de base sur laquelle tout le reste du chapitre est construit — même les abstractions modernes finissent par s'appuyer dessus.
La concurrence ajoute de la complexité. Pourquoi l'accepter ? Parce qu'elle répond à trois besoins très concrets que le code séquentiel ne sait pas satisfaire.
1. La réactivité. Une interface graphique qui calcule pendant trois secondes se fige : plus aucun clic n'est pris en compte. Un serveur qui traite une requête à la fois fait patienter tous les autres clients. Déporter le travail long sur un autre thread permet de rester réactif pendant que le travail se fait ailleurs.
2. Le débit, surtout quand on attend. La plupart des applications passent l'essentiel de leur temps à attendre : une réponse réseau, un disque, une base de données. Prenons un programme qui interroge trois services web, chacun répondant en deux secondes. En séquentiel, on additionne les attentes : six secondes, pendant lesquelles le processeur ne fait pratiquement rien. En lançant les trois appels sur trois threads, les attentes se recouvrent : environ deux secondes au total. Même machine, même processeur, trois fois plus rapide — uniquement parce qu'on a cessé de rester inactif pendant les attentes.
3. Exploiter le matériel. Pour un travail réellement gourmand en calcul — agréger un million de lignes, traiter une image —, un seul cœur plafonne vite. Répartir le calcul sur les quatre, huit ou seize cœurs de la machine permet d'aller physiquement plus vite. Ce gain-là est plafonné par le nombre de cœurs disponibles (voir la distinction concurrence / parallélisme dans l'introduction du chapitre).
⚠️ La concurrence n'est jamais gratuite. Elle introduit des bugs subtils et non déterministes. On ne l'ajoute donc pas par réflexe : seulement lorsqu'un de ces trois besoins le justifie. Un programme séquentiel qui suffit est un bon programme.
Quand on lance un programme Java, le système d'exploitation crée un processus : une instance isolée de la JVM, avec sa propre mémoire. À l'intérieur de ce processus vivent un ou plusieurs threads (fils d'exécution).
Le point essentiel à retenir : les threads d'un même processus partagent la mémoire (le tas, heap), mais chacun possède sa propre pile d'appels (stack). Ce partage de mémoire est à la fois la grande force de la concurrence — communiquer est immédiat — et sa principale source de danger : deux threads qui modifient la même donnée en même temps peuvent la corrompre. C'est tout l'objet des sections 8.6 et 8.7.
Tout programme Java démarre avec un thread déjà actif : le thread main. C'est lui qui exécute votre méthode main. La JVM fait aussi tourner discrètement des threads de service (ramasse-miettes, compilation JIT…).
public static void main(String[] args) {
String nom = Thread.currentThread().getName();
System.out.println("J'exécute dans le thread : " + nom); // affiche "main"
}Un thread a besoin d'une chose : du code à exécuter. En Java, ce code se décrit avec l'interface Runnable, une interface fonctionnelle réduite à une seule méthode :
@FunctionalInterface
public interface Runnable {
void run();
}Comme c'est une interface fonctionnelle, on l'écrit le plus souvent avec une lambda. On confie ensuite ce Runnable à un objet Thread, puis on appelle start().
Runnable tache = () -> {
System.out.println("Bonjour depuis " + Thread.currentThread().getName());
};
Thread t = new Thread(tache, "mon-thread"); // on nomme le thread : utile pour le débogage
t.start(); // lance l'exécution dans un NOUVEAU threadIl existe une seconde manière, plus ancienne : étendre la classe Thread et redéfinir run().
class Salutation extends Thread {
@Override
public void run() {
System.out.println("Bonjour depuis " + getName());
}
}
new Salutation().start();Les deux fonctionnent, mais Runnable est nettement préférable, pour des raisons solides :
- Séparation des responsabilités.
Runnabledécrit quoi faire (la tâche),Threaddécrit comment l'exécuter (le mécanisme). En héritant deThread, on mélange les deux. - Liberté d'héritage. Java n'autorise qu'un seul héritage de classe. Étendre
Threadconsomme cette unique cartouche ; implémenterRunnablela laisse libre. - Réutilisabilité. Un même
Runnablepeut être exécuté par unThread, soumis à unExecutorService(section 8.2), ou porté par un thread virtuel (section 8.4). La tâche n'est pas couplée à un mode d'exécution.
🔧 Astuce Eclipse. Nommez toujours vos threads (
new Thread(tache, "import-csv")). Dans la vue Debug, des threads nommés sont infiniment plus lisibles queThread-0,Thread-1… On y reviendra en section 8.8.
C'est l'erreur de débutant la plus fréquente en concurrence Java. Les deux méthodes existent et compilent, mais ne font pas du tout la même chose :
start()demande à la JVM de créer un nouveau thread, qui appellerarun()en parallèle.run()est une méthode ordinaire : l'appeler directement exécute le code dans le thread courant, sans aucune concurrence.
Thread t = new Thread(() ->
System.out.println(Thread.currentThread().getName()));
t.run(); // ❌ affiche "main" : exécution dans le thread courant, séquentielle
t.start(); // ✅ affiche "Thread-0" : exécution dans un nouveau threadRetenez la règle simple : pour de la concurrence, on appelle toujours start(), jamais run().
Depuis Java 21, une fabrique fluide complète le constructeur historique. Elle prépare aussi le terrain pour les threads virtuels (section 8.4) en unifiant la création des deux familles.
// Thread « plateforme » (un vrai thread du système), via le builder
Thread t = Thread.ofPlatform()
.name("worker-", 0) // worker-0, worker-1, ... (compteur)
.daemon(true) // configuré avant le démarrage
.start(() -> travailler());
// Thread virtuel — léger, géré par la JVM (détaillé en 8.4)
Thread v = Thread.ofVirtual()
.name("vt-import")
.start(() -> importer());
// Raccourci équivalent
Thread.startVirtualThread(() -> importer());Le new Thread(runnable) reste parfaitement valide et omniprésent dans le code existant : c'est la forme à connaître en premier. Le builder est l'idiome moderne et recommandé pour les nouveaux développements.
Au cours de sa vie, un thread passe par une série d'états bien définis, exposés par l'énumération Thread.State. Les comprendre est indispensable pour déboguer : la vue Debug d'Eclipse affiche précisément l'état de chaque thread.
stateDiagram-v2
[*] --> NEW : new Thread(...)
NEW --> RUNNABLE : start()
RUNNABLE --> BLOCKED : attente d'un verrou (synchronized)
BLOCKED --> RUNNABLE : verrou obtenu
RUNNABLE --> WAITING : wait() / join() / park()
WAITING --> RUNNABLE : notify() / thread joint terminé / unpark()
RUNNABLE --> TIMED_WAITING : sleep(ms) / wait(ms) / join(ms)
TIMED_WAITING --> RUNNABLE : délai écoulé ou réveil
RUNNABLE --> TERMINATED : sortie de run()
TERMINATED --> [*]
Les six états en détail :
NEW— le thread est créé mais pas encore démarré (start()n'a pas été appelé).RUNNABLE— le thread est éligible à s'exécuter. Java ne distingue pas « en cours d'exécution » de « prêt mais en attente d'un cœur » : les deux sontRUNNABLE. C'est l'ordonnanceur du système qui décide quel thread occupe réellement un cœur à un instant donné.BLOCKED— le thread est bloqué en attente d'acquérir un verrou de moniteur, c'est-à-dire l'entrée dans un blocsynchronizeddéjà détenu par un autre thread (voir 8.6).WAITING— le thread attend indéfiniment qu'un événement se produise :Object.wait(),Thread.join()sans délai, ouLockSupport.park().TIMED_WAITING— commeWAITING, mais avec un délai maximal :Thread.sleep(ms),wait(ms),join(ms).TERMINATED— la méthoderun()est terminée (normalement ou par exception). Un thread terminé ne peut pas être relancé : appelerstart()une seconde fois lève uneIllegalThreadStateException.
💡 Subtilité. L'état
BLOCKEDconcerne spécifiquement les moniteurssynchronized. Les verrous dejava.util.concurrent(par ex.ReentrantLock) reposent en réalité surpark(): un thread en attente sur un tel verrou apparaît donc enWAITING, pas enBLOCKED. Ce détail surprend lors du débogage ; on y revient en section 8.6.
Quelques méthodes pilotent ce cycle de vie au quotidien :
start()— démarre le thread (NEW→RUNNABLE).Thread.sleep(millis)(statique) — met en pause le thread courant pour la durée indiquée (TIMED_WAITING). Attention :sleep()ne libère aucun verrou détenu — contrairement àwait(). Elle peut leverInterruptedException.join()/join(millis)— fait attendre le thread courant jusqu'à ce que le thread visé se termine. Indispensable pour récupérer un résultat avant de continuer.interrupt()— envoie une demande d'interruption (voir ci-dessous).isInterrupted()(d'instance) /Thread.interrupted()(statique, qui efface le drapeau) — testent l'état d'interruption.isAlive()—trueentrestart()et la terminaison.getName()/setName(), etthreadId()(qui remplacegetId(), déprécié depuis Java 19).
Thread t = new Thread(() -> chargerDonnees(), "chargeur");
t.start();
t.join(); // le thread main attend la fin de "chargeur"
System.out.println("Données chargées, on peut continuer.");On ne peut pas — et on ne doit pas — tuer un thread de force. C'est un point crucial.
Les anciennes méthodes
Thread.stop(),Thread.suspend()etThread.resume()étaient prévues pour cela. Elles sont dépréciées depuis 1998 car intrinsèquement dangereuses (stoppouvait laisser des données à moitié modifiées) ou sujettes aux interblocages (suspend/resume). Depuis Java 20, elles lèvent purement et simplementUnsupportedOperationException: elles n'existent donc plus que comme un vestige du passé. Ne les utilisez jamais.
Le bon modèle est la coopération : on demande au thread de s'arrêter, et le thread vérifie régulièrement cette demande pour s'interrompre proprement, à un moment sûr. Deux mécanismes coexistent.
Avec le drapeau d'interruption (recommandé) :
class Travailleur implements Runnable {
@Override
public void run() {
while (!Thread.currentThread().isInterrupted()) {
traiterUneUnite();
try {
Thread.sleep(100);
} catch (InterruptedException e) {
// sleep() a consommé le drapeau : on le restaure...
Thread.currentThread().interrupt();
break; // ...puis on sort de la boucle proprement
}
}
System.out.println("Arrêt propre du travailleur.");
}
}
Thread t = new Thread(new Travailleur(), "travailleur");
t.start();
// ... plus tard, quand on veut l'arrêter :
t.interrupt(); // simple demande, pas un ordre brutal
⚠️ Ne jamais avaler uneInterruptedException. Uncatchvide masque la demande d'arrêt et rend le thread impossible à interrompre. La règle : soit on propage l'exception, soit on restaure le drapeau avecThread.currentThread().interrupt(). Beaucoup de méthodes bloquantes (sleep,join,wait, les E/S…) lèvent cette exception : c'est le canal standard de l'interruption.
Avec un drapeau volatile (alternative, lorsque le travail ne bloque pas) :
class Service implements Runnable {
private volatile boolean actif = true; // volatile : visible par tous les threads
public void arreter() { actif = false; }
@Override
public void run() {
while (actif) {
traiterUneUnite();
}
}
}Le mot-clé volatile garantit que la modification de actif est visible immédiatement par le thread qui boucle — un point de visibilité mémoire approfondi en section 8.6.
Trois notions complémentaires, à connaître sans s'y attarder :
Threads démon (daemon). Un thread est soit utilisateur (par défaut), soit démon. La JVM s'arrête dès qu'il ne reste plus que des threads démon : ces derniers ne maintiennent pas l'application en vie. On les réserve aux tâches de fond accessoires (surveillance, nettoyage périodique). Le statut doit être fixé avant start() :
Thread veille = new Thread(this::surveiller, "veille");
veille.setDaemon(true);
veille.start();Priorités. setPriority(1..10) n'est qu'une suggestion à l'ordonnanceur, au comportement variable selon le système d'exploitation. En pratique, on ne s'appuie jamais dessus pour la correction d'un programme.
Exceptions non rattrapées. Une exception qui s'échappe de run() termine ce thread-là (et non toute l'application), souvent silencieusement. Pour ne pas la perdre, on installe un gestionnaire :
Thread t = new Thread(() -> { throw new IllegalStateException("boom"); }, "fragile");
t.setUncaughtExceptionHandler((thread, ex) ->
System.err.println(thread.getName() + " a échoué : " + ex.getMessage()));
t.start();Manipuler des objets Thread à la main fonctionne pour comprendre les mécanismes, mais ne passe pas à l'échelle dans le vrai code :
- créer un thread plateforme est coûteux (mémoire, ressource système) ; en lancer un par tâche devient vite intenable ;
- on se retrouve à réécrire soi-même la mutualisation (pools), la gestion des erreurs, la récupération des résultats, la coordination… autant de code répétitif et propice aux bugs.
C'est exactement ce que résolvent les abstractions des sections suivantes. Le ExecutorService (8.2) sépare la soumission d'une tâche de la gestion des threads. Pour les charges dominées par les E/S, les threads virtuels (8.4) suppriment carrément le coût du thread, rendant le modèle « un thread par tâche » de nouveau viable.
En une phrase : dans le code de production, on crée rarement un
Threaddirectement — on confie ses tâches à un exécuteur ou on utilise des threads virtuels. Mais on comprend ce qui se passe en dessous, et c'est l'objet de cette section.
- La concurrence sert à rester réactif, à augmenter le débit (surtout quand on attend des E/S) et à exploiter le multicœur — jamais à ajouter gratuitement.
- Les threads d'un processus partagent la mémoire (tas) mais ont chacun leur pile : c'est la source à la fois de leur efficacité et de leurs dangers.
- On décrit le travail avec
Runnable(à préférer à l'héritage deThread), puis on appellestart()— jamaisrun()directement. - Le
Thread.Builder(Thread.ofPlatform()/Thread.ofVirtual(), depuis Java 21) est l'idiome moderne de création. - Un thread traverse les états
NEW → RUNNABLE → (BLOCKED / WAITING / TIMED_WAITING) → TERMINATED; un thread terminé ne se relance pas. - On n'interrompt jamais un thread de force (
stop/suspend/resumelèventUnsupportedOperationExceptiondepuis Java 20) : on pratique l'interruption coopérative via le drapeau d'interruption ou un booléenvolatile, et on ne jamais avale uneInterruptedException.
⬅️ Chapitre 8 — Introduction · Sommaire · 8.2 ExecutorService, pools de threads, Future ➡️