↑
CHAPITRE 12.3

Synchronisation et concurrence

Gérer l'accès concurrent aux ressources partagées
Quand plusieurs threads accèdent simultanément aux mêmes ressources (variables, objets), des problèmes de concurrence peuvent survenir. La synchronisation permet de contrôler l'accès aux ressources partagées pour éviter les race conditions et garantir la cohérence des données. Cette section explique les problèmes de concurrence et les solutions de synchronisation en Java.

12.3Synchronisation et concurrence

12.3.1 – Problème de race condition

Une race condition (condition de course) se produit quand plusieurs threads accèdent et modifient simultanément la même variable, ce qui peut conduire à des résultats imprévisibles ou incorrects.

⚠️ Exemple de problème

Exemple : Compteur partagé sans synchronisation

public class Compteur {
    private int valeur = 0;
    
    public void incrementer() {
        valeur++;  // Opération non atomique !
    }
    
    public int getValeur() {
        return valeur;
    }
}

// Utilisation avec plusieurs threads
public class TestRaceCondition {
    public static void main(String[] args) throws InterruptedException {
        Compteur compteur = new Compteur();
        
        // Créer 1000 threads qui incrémentent chacun
        Thread[] threads = new Thread[1000];
        for (int i = 0; i < 1000; i++) {
            threads[i] = new Thread(() -> {
                compteur.incrementer();
            });
            threads[i].start();
        }
        
        // Attendre que tous les threads se terminent
        for (Thread t : threads) {
            t.join();
        }
        
        // Résultat attendu : 1000
        // Résultat réel : souvent moins (par exemple 987, 992, etc.)
        System.out.println("Valeur finale : " + compteur.getValeur());
    }
}
Pourquoi le résultat est incorrect ?

L'opération valeur++ n'est pas atomique. Elle se décompose en plusieurs étapes :

  1. Lire la valeur actuelle
  2. L'incrémenter
  3. Écrire la nouvelle valeur

Si deux threads font ces opérations en même temps, ils peuvent tous les deux lire la même valeur, l'incrémenter, et écrire le même résultat, ce qui fait perdre une incrémentation.

🔍 Scénario de race condition

Exemple de scénario problématique :
  • Thread 1 lit valeur = 5
  • Thread 2 lit valeur = 5 (en mĂŞme temps)
  • Thread 1 calcule 5 + 1 = 6
  • Thread 2 calcule 5 + 1 = 6
  • Thread 1 Ă©crit 6
  • Thread 2 Ă©crit 6 (Ă©crase la valeur de Thread 1)
  • RĂ©sultat : valeur = 6 au lieu de 7

12.3.2 – Synchronisation avec synchronized

Le mot-clé synchronized permet de synchroniser l'accès aux méthodes ou blocs de code, garantissant qu'un seul thread à la fois peut exécuter le code synchronisé.

🔒 Méthode synchronisée

Exemple : Méthode synchronisée

public class CompteurSynchronise {
    private int valeur = 0;
    
    // Méthode synchronisée : un seul thread à la fois peut l'exécuter
    public synchronized void incrementer() {
        valeur++;
    }
    
    public synchronized int getValeur() {
        return valeur;
    }
}
Comment ça fonctionne :
  • Quand un thread entre dans une mĂ©thode synchronized, il acquiert un verrou (lock) sur l'objet
  • Les autres threads doivent attendre que le verrou soit libĂ©rĂ©
  • Une fois la mĂ©thode terminĂ©e, le verrou est libĂ©rĂ© et le thread suivant peut entrer
  • Cela garantit que les opĂ©rations sont atomiques

🔒 Bloc synchronisé

Vous pouvez aussi synchroniser seulement une partie du code avec un bloc synchronized :

public class Compteur {
    private int valeur = 0;
    private Object verrou = new Object();  // Objet utilisé comme verrou
    
    public void incrementer() {
        // Code non synchronisé (peut être exécuté en parallèle)
        System.out.println("Avant incrémentation");
        
        // Bloc synchronisé (un seul thread à la fois)
        synchronized (verrou) {
            valeur++;
        }
        
        // Code non synchronisé
        System.out.println("Après incrémentation");
    }
}

💻 Exemple complet : Compteur synchronisé

public class CompteurSynchronise {
    private int valeur = 0;
    
    public synchronized void incrementer() {
        valeur++;
    }
    
    public synchronized void decrementer() {
        valeur--;
    }
    
    public synchronized int getValeur() {
        return valeur;
    }
}

// Test
public class TestSynchronisation {
    public static void main(String[] args) throws InterruptedException {
        CompteurSynchronise compteur = new CompteurSynchronise();
        
        Thread[] threads = new Thread[1000];
        for (int i = 0; i < 1000; i++) {
            threads[i] = new Thread(() -> {
                compteur.incrementer();
            });
            threads[i].start();
        }
        
        for (Thread t : threads) {
            t.join();
        }
        
        // Maintenant le résultat est toujours correct : 1000
        System.out.println("Valeur finale : " + compteur.getValeur());
    }
}

📊 Synchronisation sur this

Quand vous synchronisez une méthode, c'est équivalent à synchroniser sur this :

// Ces deux méthodes sont équivalentes :

// Méthode 1 : Méthode synchronisée
public synchronized void methode() {
    // code
}

// Méthode 2 : Bloc synchronisé sur this
public void methode() {
    synchronized (this) {
        // code
    }
}

12.3.3 – Collections thread-safe

Les collections standard de Java (ArrayList, HashMap, etc.) ne sont pas thread-safe. Pour utiliser des collections dans un environnement multi-thread, vous devez utiliser des versions thread-safe ou synchroniser l'accès.

⚠️ Problème avec les collections non thread-safe

import java.util.ArrayList;
import java.util.List;

List<String> liste = new ArrayList<>();  // Non thread-safe

// Plusieurs threads ajoutent des éléments
Thread t1 = new Thread(() -> {
    for (int i = 0; i < 1000; i++) {
        liste.add("Element " + i);
    }
});

Thread t2 = new Thread(() -> {
    for (int i = 0; i < 1000; i++) {
        liste.add("Element " + i);
    }
});

t1.start();
t2.start();
// Problème : peut causer des erreurs ou des données perdues

âś… Solutions

Solution 1 : Collections.synchronizedList()

import java.util.Collections;
import java.util.List;
import java.util.ArrayList;

List<String> liste = Collections.synchronizedList(new ArrayList<>());

// Maintenant thread-safe
Thread t1 = new Thread(() -> {
    for (int i = 0; i < 1000; i++) {
        liste.add("Element " + i);
    }
});

Thread t2 = new Thread(() -> {
    for (int i = 0; i < 1000; i++) {
        liste.add("Element " + i);
    }
});

t1.start();
t2.start();

Solution 2 : Collections thread-safe (java.util.concurrent)

import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ConcurrentHashMap;

// Liste thread-safe
List<String> liste = new CopyOnWriteArrayList<>();

// Map thread-safe
Map<String, Integer> map = new ConcurrentHashMap<>();
Collections thread-safe disponibles :
  • CopyOnWriteArrayList : Liste thread-safe
  • ConcurrentHashMap : Map thread-safe
  • BlockingQueue : File thread-safe
  • ConcurrentLinkedQueue : Queue thread-safe

12.3.4 – Deadlock (interblocage)

Un deadlock (interblocage) se produit quand deux ou plusieurs threads s'attendent mutuellement pour libérer des verrous, créant une situation de blocage permanent.

⚠️ Exemple de deadlock

public class DeadlockExemple {
    private final Object verrou1 = new Object();
    private final Object verrou2 = new Object();
    
    public void methode1() {
        synchronized (verrou1) {
            System.out.println("Thread 1 : Verrou 1 acquis");
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {}
            
            synchronized (verrou2) {  // Attend verrou2
                System.out.println("Thread 1 : Verrou 2 acquis");
            }
        }
    }
    
    public void methode2() {
        synchronized (verrou2) {
            System.out.println("Thread 2 : Verrou 2 acquis");
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {}
            
            synchronized (verrou1) {  // Attend verrou1
                System.out.println("Thread 2 : Verrou 1 acquis");
            }
        }
    }
}

// Utilisation
DeadlockExemple exemple = new DeadlockExemple();

Thread t1 = new Thread(() -> exemple.methode1());
Thread t2 = new Thread(() -> exemple.methode2());

t1.start();
t2.start();
// Deadlock : les deux threads sont bloqués indéfiniment
Scénario de deadlock :
  • Thread 1 acquiert verrou1, attend verrou2
  • Thread 2 acquiert verrou2, attend verrou1
  • Chaque thread attend que l'autre libère son verrou
  • Blocage permanent !

✅ Prévention des deadlocks

  • Ordre des verrous : Toujours acquĂ©rir les verrous dans le mĂŞme ordre
  • Timeout : Utiliser des verrous avec timeout
  • Éviter les verrous multiples : Minimiser le nombre de verrous nĂ©cessaires
  • DĂ©tection : Surveiller les threads bloquĂ©s

💡 Points clés à retenir

  • Race condition : Problème quand plusieurs threads modifient la mĂŞme variable
  • synchronized : Garantit qu'un seul thread exĂ©cute le code Ă  la fois
  • MĂ©thode synchronisĂ©e : public synchronized void methode()
  • Bloc synchronisĂ© : synchronized (objet) { ... }
  • Collections thread-safe : Utiliser CopyOnWriteArrayList, ConcurrentHashMap, etc.
  • Deadlock : Blocage mutuel entre threads
  • PrĂ©vention : AcquĂ©rir les verrous dans le mĂŞme ordre
Conseil pratique : Utilisez la synchronisation avec parcimonie. Trop de synchronisation peut réduire les performances. Préférez les collections thread-safe du package java.util.concurrent plutôt que de synchroniser manuellement les collections standard.