La synchronisation est le mécanisme qui permet de coordonner l'accès de plusierus threads à une ressource partagée. Sans cette coordination, les données peuvent devenir incohérentes en raison d'interférences entre les opérations concurrentes.
Le problème de l'accès concurrent
Lorsqu'un bloc de code n'est pas atomique, un thread peut être interrompu par l'ordonnanceur au milieu de son exécution, laissant la ressource dans un état instable. Considérons l'exemple suivant où deux threads incrémentent un compteur partagé :
public class DemoSynchronisation implements Runnable {
private final Compteur compteur = new Compteur();
public static void main(String[] args) {
DemoSynchronisation instance = new DemoSynchronisation();
Thread th1 = new Thread(instance, "Alpha");
Thread th2 = new Thread(instance, "Beta");
th1.start();
th2.start();
}
@Override
public void run() {
compteur.incrementer(Thread.currentThread().getName());
}
}
class Compteur {
private static int valeur = 0;
public void incrementer(String nomThread) {
valeur++;
try {
// Simulation d'un traitement pour forcer le basculement de contexte
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread " + nomThread + " : Valeur actuelle = " + valeur);
}
}
Dans ce scénario, les deux threads peuvent lire la même valeur initiale avant que l'un d'eux ne termine sa mise à jour. Le résultat affiché sera souvent identique pour les deux threads (par exemple, "2"), car l'opération valeur++ et l'affichage ne forment pas une unité de travail indivisible.
Utilisation du mot-clé synchronized
Pour résoudre ce problème, Java propose le mot-clé synchronized. Il permet de verrouiller l'objet afin qu'un seul thread puisse exécuter le bloc de code à la fois. Lorsqu'un thread entre dans une zone synchronisée, il acquiert le moniteur (verrou) de l'objet. Tout autre thread tentant d'y accéder sera mis en attente.
On peut synchroniser un bloc spécifique ou une méthode entière :
class CompteurSecurise {
private static int valeur = 0;
// Synchronisation de la méthode entière
public synchronized void incrementer(String nomThread) {
valeur++;
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread " + nomThread + " : Valeur = " + valeur);
}
}
Avec synchronized, si le thread "Alpha" commence l'exécution, il conserve le verrou même pendant son sommeil (sleep). Le thread "Beta" doit attendre que "Alpha" sorte de la méthode pour démarrer à son tour.
Le phénomène d'interblocage (Deadlock)
L'utilisation de verrous multiples peut mener à un interblocage. Cela se produit lorsque deux threads attendent mutuellement une ressource détenue par l'autre, créant une impasse permanente.
public class SimulationInterblocage implements Runnable {
public int mode = 1;
private static final Object VerrouA = new Object();
private static final Object VerrouB = new Object();
@Override
public void run() {
if (mode == 1) {
synchronized (VerrouA) {
System.out.println("Mode 1: Verrou A acquis");
try { Thread.sleep(100); } catch (Exception e) {}
synchronized (VerrouB) {
System.out.println("Mode 1: Verrou B acquis");
}
}
} else {
synchronized (VerrouB) {
System.out.println("Mode 0: Verrou B acquis");
try { Thread.sleep(100); } catch (Exception e) {}
synchronized (VerrouA) {
System.out.println("Mode 0: Verrou A acquis");
}
}
}
}
public static void main(String[] args) {
SimulationInterblocage s1 = new SimulationInterblocage();
SimulationInterblocage s2 = new SimulationInterblocage();
s1.mode = 1;
s2.mode = 0;
new Thread(s1).start();
new Thread(s2).start();
}
}
Pour éviter cela, il est recommandé de limiter la granularité des verrous ou de s'assurer que tous les threads acquièrent les verrous dans le même ordre.
Communication entre threads : Producteur-Consommateur
Pour des interactions plus complexes, Java utilise wait() et notify(). Contrairement à sleep(), l'appel à wait() relâche le verrou sur l'objet, permettant à d'autres threads d'intervenir.
class Entrepot {
private int[] stock = new int[5];
private int index = 0;
public synchronized void produire(int id) {
while (index == stock.length) {
try { this.wait(); } catch (InterruptedException e) {}
}
this.notifyAll();
stock[index++] = id;
System.out.println("Produit : " + id);
}
public synchronized int consommer() {
while (index == 0) {
try { this.wait(); } catch (InterruptedException e) {}
}
this.notifyAll();
return stock[--index];
}
}
class Producteur implements Runnable {
Entrepot e;
Producteur(Entrepot e) { this.e = e; }
public void run() {
for (int i = 0; i < 10; i++) {
e. produire(i);
try { Thread.sleep(50); } catch (InterruptedException ex) {}
}
}
}
class Consommateur implements Runnable {
Entrepot e;
Consommateur(Entrepot e) { this.e = e; }
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println("Consommé : " + e.consommer());
try { Thread.sleep(100); } catch (InterruptedException ex) {}
}
}
}
Dans ce modèle, le producteur attend si l'entrepôt est plein, et le consommateur attend s'il est vide. notifyAll() réveille les threads en attente pour qu'ils vérifient à nouveau la condition de leur boucle while.