1. Qu’est-ce qu’une ressource partagée
Vous avez déjà rencontré des ressources partagées. La maison où vit une famille — c’est une ressource partagée par cette famille. Le réfrigérateur du bureau — une ressource partagée par tous les collègues. L’idée est claire.
En programmation, une ressource partagée est une variable, un objet ou une structure de données auxquels plusieurs threads peuvent accéder simultanément. Cela peut être :
- Une variable-compteur du nombre de commandes traitées.
- Une liste de demandes, remplie par certains threads et traitée par d’autres.
- Un fichier ouvert dans lequel plusieurs threads écrivent.
- Une connexion à une base de données, utilisée par différentes parties du programme.
En Java, tout objet ou variable auquel plusieurs threads peuvent accéder devient potentiellement une « ressource partagée ».
Exemple de ressource partagée : compteur global
public class Counter {
public int value = 0;
}
Si plusieurs threads incrémentent ce compteur, ils accèdent tous à la même variable value — voilà votre ressource partagée.
2. Problèmes d’accès simultané
Dans un programme monothread, tout est simple : un thread — un exécutant — il suit le code comme un train sur des rails. Mais dès que plusieurs threads entrent en jeu, c’est la véritable « danse des sabres » : les threads peuvent interférer les uns avec les autres aux endroits les plus inattendus.
Race condition (état de course)
Race condition — situation où le résultat du programme dépend de la façon dont les actions des threads se sont « mélangées ». Autrement dit, si vous lancez plusieurs fois le même programme, le résultat peut varier — et ce n’est pas un bug, c’est une « feature » du multithreading.
Exemple classique : deux threads incrémentent un compteur
Essayons de modéliser une situation simple : nous avons un compteur partagé et deux threads l’incrémentent mille fois chacun.
public class Counter {
public int value = 0;
}
public class CounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable incrementTask = () -> {
for (int i = 0; i < 1000; i++) {
counter.value++; // ENDROIT DANGEREUX !
}
};
Thread t1 = new Thread(incrementTask);
Thread t2 = new Thread(incrementTask);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Valeur attendue: 2000");
System.out.println("Valeur réelle: " + counter.value);
}
}
Que verra-t-on à l’écran ?
Parfois — 2000, parfois — 1985, parfois — 1937… Pourquoi ? Parce que l’opération counter.value++ n’est pas atomique ! Elle se compose de trois étapes :
- Lire la valeur actuelle de counter.value.
- L’augmenter de 1.
- Écrire la valeur de nouveau.
Si deux threads lisent simultanément la même valeur, l’augmentent, puis écrivent chacun le résultat — une incrémentation sera « perdue ». C’est ce qu’on appelle une lost update — mise à jour perdue.
État incohérent de l’objet
Si vous avez un objet complexe composé de plusieurs champs et que des threads modifient en même temps des champs différents, l’objet peut se retrouver dans un état « étrange » ou incohérent. Par exemple, le solde a diminué mais l’historique des opérations n’a pas été mis à jour — le client panique, le comptable est sous le choc.
3. Pourquoi la synchronisation est nécessaire
La synchronisation — c’est une manière de dire au programme : « Stop, ce morceau de code ne doit être exécuté que par un seul thread à la fois ! Les autres attendront ! » C’est comme le panneau « Nettoyage ! Ne pas entrer ! » sur la porte des toilettes : tant qu’une personne est à l’intérieur, les autres attendent dehors (et maudissent mentalement celui qui n’en finit pas).
Garantir l’intégrité des données
Si nous voulons que notre compteur s’incrémente toujours correctement, nous devons interdire la modification simultanée de sa valeur par plusieurs threads.
Exemple : synchroniser l’incrément du compteur
public class Counter {
public int value = 0;
public synchronized void increment() {
value++;
}
}
Désormais, si deux threads appellent increment(), un seul d’entre eux pourra exécuter cette méthode à un instant donné. Le second attendra que le premier ait terminé.
Schéma : ce qui se passe lors de la synchronisation
+-------------------+
| Thread 1 | --\
+-------------------+ \
| \
V \
+-------------------+ > [ synchronized increment() ]
| Thread 2 | --/ /
+-------------------+ / /
| / /
V / /
+-------------------+ / /
| Thread 3 | --/ /
+-------------------+ /
| /
V /
+-------------------+ /
| Thread N |/
+-------------------+
Tous les threads se mettent en file d’attente pour exécuter la section protégée du code. Un seul thread peut se trouver à l’intérieur de la « section critique » (bloc synchronized) à la fois.
4. Bref aperçu des modes de synchronisation
La synchronisation en Java n’est pas un mécanisme unique, mais tout un « arsenal » d’outils permettant de protéger une ressource partagée contre l’accès simultané.
Mot-clé synchronized
C’est l’outil principal de synchronisation en Java. On peut l’utiliser de deux façons :
Méthode synchronisée
public synchronized void increment() {
value++;
}
Bloc synchronisé
public void increment() {
synchronized (this) {
value++;
}
}
Ici, this est l’objet sur lequel on verrouille. Tant qu’un thread exécute ce bloc, les autres threads qui souhaitent entrer dans un bloc identique sur le même objet attendront.
Classes spécialisées de java.util.concurrent
- Lock, ReentrantLock — alternative plus flexible à synchronized.
- ReadWriteLock — pour séparer les verrous de lecture et d’écriture.
- Semaphore — limite le nombre de threads exécutant le code simultanément.
- CountDownLatch, CyclicBarrier et autres — pour coordonner le travail des threads.
Important : Aujourd’hui, nous ne faisons que découvrir les bases — nous parlerons de ces classes un peu plus tard.
5. Exemple pratique : application avec compteur multithread
Supposons que nous écrivions des statistiques d’accès des utilisateurs à un service. Chaque thread est un utilisateur distinct qui incrémente un compteur partagé.
Sans synchronisation
public class Counter {
public int value = 0;
}
public class MultiThreadCounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable user = () -> {
for (int i = 0; i < 10000; i++) {
counter.value++;
}
};
Thread t1 = new Thread(user);
Thread t2 = new Thread(user);
Thread t3 = new Thread(user);
t1.start();
t2.start();
t3.start();
t1.join();
t2.join();
t3.join();
System.out.println("Valeur attendue: 30000");
System.out.println("Valeur réelle: " + counter.value);
}
}
Résultat : Presque toujours inférieur à 30000. Parfois — bien inférieur ! Pourquoi ? Parce que les threads se « coupent la route ».
Synchronisation : corriger l’erreur
public class Counter {
public int value = 0;
public synchronized void increment() {
value++;
}
}
public class MultiThreadCounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable user = () -> {
for (int i = 0; i < 10000; i++) {
counter.increment();
}
};
Thread t1 = new Thread(user);
Thread t2 = new Thread(user);
Thread t3 = new Thread(user);
t1.start();
t2.start();
t3.start();
t1.join();
t2.join();
t3.join();
System.out.println("Valeur attendue: 30000");
System.out.println("Valeur réelle: " + counter.value);
}
}
Résultat : Toujours 30000. Hourra, la synchronisation fonctionne !
6. Nuances utiles
Visualisation : à quoi ressemble une race condition
Faisons un petit tableau pour montrer comment deux threads peuvent « perdre » un incrément :
| Étape | Thread 1 | Thread 2 | Valeur de value |
|---|---|---|---|
| 1 | Lit value=0 | 0 | |
| 2 | Lit value=0 | 0 | |
| 3 | Incrémente à 1 | 0 | |
| 4 | Incrémente à 1 | 0 | |
| 5 | Écrit 1 | 1 | |
| 6 | Écrit 1 | 1 |
Quand a-t-on besoin de synchronisation
La synchronisation n’est pas toujours nécessaire. Quand une variable vit dans son petit monde et qu’un seul thread l’utilise — on peut être tranquille. Mais dès que vous la partagez avec d’autres threads, la synchronisation devient indispensable. Même si tout semble aller — n’y croyez pas. L’erreur de concurrence est sournoise : elle peut se cacher longtemps, puis surgir au pire moment.
Pour la suite : quels autres moyens de synchronisation existent
Aujourd’hui, nous n’avons vu que l’outil de base — synchronized. Dans les prochaines leçons, nous aborderons :
- Comment fonctionne le moniteur d’objet et les types de verrous.
- Ce que sont les méthodes synchronisées statiques (static + synchronized).
- Comment fonctionne le mot-clé volatile et à quoi il sert.
- Quels sont les outils modernes de synchronisation (Lock, Semaphore, etc.).
7. Erreurs courantes avec les ressources partagées
Erreur n° 1 : ignorer le multithreading.
L’une des erreurs les plus répandues — ne pas envisager qu’une variable puisse être accessible depuis plusieurs threads. Même si votre programme est monothread aujourd’hui, quelqu’un ajoutera des threads plus tard — et des bugs apparaîtront « de nulle part ».
Erreur n° 2 : synchronisation insuffisante ou excessive.
Si vous ne synchronisez pas l’accès à une ressource partagée — vous obtiendrez des race conditions et des données incohérentes. Si vous synchronisez tout à outrance, le programme « s’étouffera » sous les verrous et deviendra lent. Synchronisez toujours uniquement ce qui est réellement nécessaire.
Erreur n° 3 : se synchroniser sur le mauvais objet.
Si vous synchronisez l’accès sur des objets différents (par exemple sur des variables locales ou des littéraux de chaîne), cela ne protégera pas la ressource partagée. Tous les threads doivent se synchroniser sur le même objet.
Erreur n° 4 : attendre l’atomicité d’opérations non atomiques.
L’opération i++ n’est pas atomique ! Même si la variable est déclarée volatile, cela ne rend pas l’incrément atomique. Pour ces opérations, il faut une synchronisation.
Erreur n° 5 : « J’ai eu de la chance, chez moi tout fonctionne ».
Une race condition peut ne pas se manifester sur votre machine, mais se manifestera forcément sur un serveur ou chez un utilisateur. Ne comptez jamais sur la « chance » dans les programmes multithread !
GO TO FULL VERSION