Concurrence vs Parallélisme
Concurrence
- C'est l'illusion que plusieurs tâches s'exécutent en même temps.
- En réalité, le processeur passe rapidement d'une tâche à l'autre (time-slicing) sur un seul coeur.
- Avantage : Ça rend ton application plus réactive — par exemple, une UI peut continuer à réagir pendant qu'un fichier est en train de se télécharger.
Exemple concret : téléchargement + affichage de progression
Sans concurrence, l'interface gèle pendant le téléchargement :
// Tout s'exécute dans le même thread → l'UI est bloquée pendant le téléchargement
telechargerFichier(); // prend 5 secondes, rien d'autre ne peut se passer
afficherProgression(100); // affiché seulement à la fin
Avec concurrence :
Thread telechargement = new Thread(() -> {
for (int i = 0; i <= 100; i += 10) {
System.out.println("Téléchargement : " + i + "%");
try { Thread.sleep(500); } catch (InterruptedException e) {}
}
});
Thread progression = new Thread(() -> {
for (int i = 0; i <= 10; i++) {
System.out.println("Interface : toujours réactive (" + i + ")");
try { Thread.sleep(500); } catch (InterruptedException e) {}
}
});
telechargement.start();
progression.start();
Les deux threads s'alternent rapidement — l'interface reste réactive pendant le téléchargement.
Parallélisme
- C'est quand plusieurs tâches s'exécutent vraiment en même temps.
- Il faut plusieurs coeurs de processeur (ou plusieurs processeurs).
- Ex. : A s'exécute sur le coeur 1, B sur le coeur 2, en vrai simultané.
Pourquoi utilise-t-on la concurrence même sans parallélisme ?
Parce que c'est un bon compromis pour l'efficacité :
- Les processeurs passent souvent du temps à attendre (lecture disque, réseau, entrées utilisateur...).
- En rendant le programme concurrent, on peut faire autre chose pendant qu'on attend.
- Ça permet aussi d'écrire du code plus structuré et réactif, même si on a qu'un seul cœur.
Threads
-
Un Thread est une unité d'exécution légère qui peut être partagé entre plusieurs coeurs.
-
Java offre plusieurs façons d'en créer, voici les deux premières:
// 1. Étendre Threadclass MonThread extends Thread {public void run() {System.out.println("Bonjour d'un thread !");}}// 2. Implémenter Runnableclass Tache implements Runnable {public void run() {System.out.println("Depuis Runnable");}} -
Exécution :
new MonThread().start();new Thread(new Tache()).start();
start()démarre un nouveau thread.
run()l'exécute dans le thread courant (donc pas concurrent). Alors, le code s'exécutera dans le thread principal, comme une méthode ordinaire — donc pas de concurrence du tout.
Thread vs Runnable
| Critère | Thread (héritage) | Runnable (implémentation) |
|---|---|---|
| Héritage multiple | Non possible (Java ne permet qu'une seule extension de classe) | Possible (on peut implémenter plusieurs interfaces) |
| Séparation logique des tâches | Moins claire (la logique métier est couplée à la gestion du thread) | Meilleure séparation (la logique métier est distincte du thread) |
| Réutilisabilité | Moins réutilisable | Plus réutilisable (peut être exécuté par différents threads) |
| Gestion des ressources | Chaque thread a sa propre instance | Plusieurs threads peuvent partager la même instance de Runnable |
Recommandations
-
Préférez implémenter
Runnablelorsque :- Vous souhaitez que votre classe puisse hériter d'une autre classe
- Vous voulez séparer la logique métier de la gestion des threads
-
Utilisez l'héritage de
Threaduniquement si :- Vous avez besoin de modifier le comportement de la classe
Threadelle-même
- Vous avez besoin de modifier le comportement de la classe
Runnable
https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/Runnable.html
Puisque l'interface Runnable est une interface fonctionnelle, on peut réduire le code précédent à ceci:
// Bonne pratique avec Runnable
class Tache implements Runnable {
@Override
public void run() {
System.out.println("Travail dans un thread !");
}
}
// Dans le main
Thread t = new Thread(new Tache());
t.start();
Par :
Thread t = new Thread(() -> {
System.out.println("Tâche dans une lambda !");
});
t.start();
Synchronisation du thread principal
Quand tu crées des threads, start() les lance en arrière-plan et le thread principal continue immédiatement. S'il n'a plus rien à faire, il se termine — avant même que les threads aient fini.
join()
join() bloque le thread principal jusqu'à ce que le thread ciblé se termine. Ça garantit qu'on n'avance pas avant que tout soit prêt.
t1.start();
t1.join(); // le thread principal attend ici que t1 soit terminé
Exemple concret : générer un rapport
Thread calculs = new Thread(() -> {
System.out.println("Calcul des statistiques...");
try { Thread.sleep(2000); } catch (InterruptedException e) {}
System.out.println("Statistiques prêtes.");
});
Thread graphiques = new Thread(() -> {
System.out.println("Génération des graphiques...");
try { Thread.sleep(3000); } catch (InterruptedException e) {}
System.out.println("Graphiques prêts.");
});
calculs.start();
graphiques.start();
// Sans join() → "Rapport généré !" s'affiche avant que les threads aient fini
calculs.join(); // attend que les calculs soient terminés
graphiques.join(); // puis attend les graphiques
System.out.println("Rapport généré !");
Incohérence des données
Quand plusieurs threads accèdent à une ressource partagée, comme un compte bancaire, ils peuvent entrer en collision. Ça peut mener à des incohérences et des plantages imprévisibles.
synchronized
synchronized sert à créer une section critique : une portion de code que un seul thread à la fois peut exécuter, pendant que les autres attendent leur tour.
Un seul thread — pas deux, pas trois. Tous les autres threads qui tentent d'entrer sont mis en attente jusqu'à ce que le thread en cours en ressorte.
L'objet passé à synchronized est un verrou (lock). Seul un thread à la fois peut tenir ce verrou et entrer dans le bloc.
public synchronized void retirer(int montant) {
// Seul UN thread peut être ici à la fois
// Les autres attendent à la porte
}
Si une classe a plusieurs méthodes synchronized, elles partagent toutes le même verrou (this). Un thread dans retirer() bloque donc aussi l'accès à getSolde() pour les autres threads.
public synchronized void retirer(int montant) { ... }
public synchronized int getSolde() { ... }
// → un thread dans retirer() empêche aussi l'appel à getSolde()
Tu peux aussi écrire :
public void retirer(int montant) {
synchronized (this) {
// même effet
}
}
synchronized sur une méthode agit comme un synchronized(this) autour du corps de la méthode.
Verrouiller sur n'importe quel objet
On peut passer n'importe quel objet comme verrou, pas seulement this. Deux blocs qui utilisent le même objet comme verrou ne peuvent pas s'exécuter en même temps.
Object lockA = new Object();
Object lockB = new Object();
synchronized (lockA) {
// protégé par lockA — lockB reste accessible
}
synchronized (lockB) {
// protégé par lockB — lockA reste accessible
}
C'est utile quand on veut plusieurs verrous indépendants dans un même programme — par exemple, un verrou par compte bancaire plutôt qu'un seul verrou global.
Race condition
Deux threads lisent la même valeur, la modifient chacun de leur côté, puis s'écrasent mutuellement.
Exemple concret : deux guichets retirent du même compte
class CompteBancaire {
private int solde = 1000;
public void retirer(int montant) {
if (solde >= montant) { // Thread A vérifie : 1000 >= 700 ✓
// Thread B vérifie : 1000 >= 700 ✓ (avant que A ait retiré)
solde -= montant; // A retire → solde = 300
// B retire → solde = -400 !!
}
}
public int getSolde() { return solde; }
}
// Dans le main :
CompteBancaire compte = new CompteBancaire();
Thread guichet1 = new Thread(() -> compte.retirer(700));
Thread guichet2 = new Thread(() -> compte.retirer(700));
guichet1.start();
guichet2.start();
guichet1.join();
guichet2.join();
System.out.println("Solde final : " + compte.getSolde()); // Peut être -400 !
Solution avec synchronized
class CompteBancaire {
private int solde = 1000;
public synchronized void retirer(int montant) {
if (solde >= montant) {
solde -= montant;
}
}
public synchronized int getSolde() { return solde; }
}
// Solde garanti : jamais en dessous de 0
Deadlock
Deux threads attendent chacun un verrou détenu par l'autre — plus personne ne bouge.
Exemple concret : deux virements croisés
class Compte {
String nom;
int solde;
Compte(String nom, int solde) { this.nom = nom; this.solde = solde; }
}
Compte compteA = new Compte("Alice", 1000);
Compte compteB = new Compte("Bob", 1000);
// Thread 1 : vire de A vers B
Thread virement1 = new Thread(() -> {
synchronized (compteA) {
System.out.println("Virement 1 : tient A, attend B...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (compteB) {
compteA.solde -= 200;
compteB.solde += 200;
}
}
});
// Thread 2 : vire de B vers A (ordre inverse → deadlock)
Thread virement2 = new Thread(() -> {
synchronized (compteB) {
System.out.println("Virement 2 : tient B, attend A...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (compteA) {
compteB.solde -= 200;
compteA.solde += 200;
}
}
});
// Les deux threads se bloquent mutuellement — le programme ne termine jamais.
Solution : toujours entrer dans les blocs synchronized dans le même ordre
Le deadlock vient du fait que Thread 1 entre dans synchronized(compteA) puis attend compteB, pendant que Thread 2 est déjà dans synchronized(compteB) et attend compteA. Chacun bloque ce que l'autre attend.
Si les deux threads entrent toujours dans synchronized(compteA) en premier, Thread 2 ne peut pas entrer dans son bloc tant que Thread 1 n'en est pas sorti. Il attend à l'extérieur sans bloquer quoi que ce soit — la dépendance circulaire disparaît.
Avant (deadlock) Après (solution)
──────────────────────────────── ────────────────────────────────
T1 : entre dans compteA, attend compteB T1 : entre dans compteA, entre dans compteB
T2 : entre dans compteB, attend compteA T2 : attend compteA (T1 l'a), puis entre
↑ chacun bloque l'autre ↑ T2 attend, mais ça déblocque
class Compte {
String nom;
int solde;
Compte(String nom, int solde) { this.nom = nom; this.solde = solde; }
}
Compte compteA = new Compte("Alice", 1000);
Compte compteB = new Compte("Bob", 1000);
// Thread 1 : vire de A vers B
Thread virement1 = new Thread(() -> {
synchronized (compteA) {
System.out.println("Virement 1 : dans compteA, entre dans compteB...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (compteB) {
compteA.solde -= 200;
compteB.solde += 200;
System.out.println("Virement 1 terminé.");
}
}
});
// Thread 2 : vire de B vers A — même ordre que T1 (compteA d'abord)
Thread virement2 = new Thread(() -> {
synchronized (compteA) {
System.out.println("Virement 2 : dans compteA, entre dans compteB...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (compteB) {
compteB.solde -= 200;
compteA.solde += 200;
System.out.println("Virement 2 terminé.");
}
}
});
virement1.start();
virement2.start();
// Thread 2 attend que Thread 1 sorte de synchronized(compteA) — pas de deadlock.
Livelock
Les threads ne sont pas bloqués, mais changent de stratégie en boucle sans progresser.
Comme deux personnes dans un corridor qui s'excusent, changent de côté, se recroisent, se ré-excusent, etc.
class Personne {
String nom;
boolean veutPasser;
Personne(String nom) { this.nom = nom; this.veutPasser = true; }
}
Personne alice = new Personne("Alice");
Personne bob = new Personne("Bob");
Thread threadAlice = new Thread(() -> {
while (alice.veutPasser) {
if (bob.veutPasser) {
System.out.println("Alice : Bob veut passer, je recule...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
continue; // recule et recommence
}
System.out.println("Alice passe !");
alice.veutPasser = false;
}
});
Thread threadBob = new Thread(() -> {
while (bob.veutPasser) {
if (alice.veutPasser) {
System.out.println("Bob : Alice veut passer, je recule...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
continue; // recule et recommence
}
System.out.println("Bob passe !");
bob.veutPasser = false;
}
});
threadAlice.start();
threadBob.start();
// Les deux reculent en même temps indéfiniment — personne ne passe jamais.
Starvation
Un thread n'arrive jamais à entrer dans le bloc synchronized parce que d'autres threads l'occupent en continu.
Un peu comme si dans une file, les gens te coupaient toujours parce que tu es trop poli.
Object verrou = new Object();
// Deux threads rapides qui se partagent le verrou en continu
Runnable tacheRapide = () -> {
for (int i = 0; i < 10_000; i++) {
synchronized (verrou) {
// travail très court — relâche le verrou presque immédiatement
}
}
};
// Un thread lent qui essaie d'entrer, mais attend toujours son tour
Thread tacheLente = new Thread(() -> {
for (int i = 1; i <= 5; i++) {
synchronized (verrou) {
System.out.println("Tâche lente a enfin le verrou ! (" + i + "/5)");
}
try { Thread.sleep(50); } catch (InterruptedException e) {}
}
});
new Thread(tacheRapide).start();
new Thread(tacheRapide).start();
tacheLente.start();
// La tâche lente progresse à peine — les deux threads rapides reprennent
// le verrou avant qu'elle ait une chance de l'obtenir.