Aller au contenu

Le mot clé synchronized

Il peut arriver que plusieurs threads aient besoin de modifier quelque chose de commun : un fichier, une base de données, une instance d'une classe, etc. Si plusieurs threads modifient en même temps, il pourrait arriver des résultats non désirables.

Par exemple, si plusieurs threads voulaient lire et écrire dans un fichier simultanément :

graph TB

    fichier[(Fichier)]
    thread1
    thread2
    thread3

    thread1 --> fichier --> thread1
    thread2 --> fichier --> thread2
    thread3 --> fichier --> thread3

Étant donné que les threads fonctionnent simultanément, si un thread tente de lire pendant que l'autre écrire, peut-être qu'il n'aura pas accès. Ou si un thread voulait écrire du contenu pendant qu'un autre le lise, ce dernier n'aurait pas la dernière information disponible. Il faut donc remédier au problème.

Le mot clé synchronized

L'utilisation du mot clé synchronized permet justement d'indiquer à Java que le bout de code ne peut pas être exécuté par plusieurs threads en même temps. Il joue un peu le rôle d'une file d'attente.

flowchart TB

    fichier[(Fichier)]
    thread1
    thread2
    thread3

    s{{synchronized}}

    thread1 --> s
    thread2 --> s
    thread3 --> s

    s --> fichier

Il est possible d'appliquer le mot synchronized sur une méthode entière ou simplement une partie de code.

Méthode synchronisée

Pour synchroniser une méthode, on place le mot synchronized immédiatement après l'accessibilité (public, private, etc.).

public synchronized void bonjour() {
    // Dormir une seconde
    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }

    // Afficher
    System.out.println("Bonjour");
}

Maintenant, si plusieurs threads voulaient exécuter la méthode bonjour() , ils ne pourraient le faire qu'un thread à la fois.

Bonjour
(pause de 1 seconde)
Bonjour
(pause de 1 seconde)
Bonjour
(pause de 1 seconde)
Bonjour
...

Essayez-le

Essayez le code ci-dessous avec et sans le mot clé synchronized

public class App {

    public static void main(String[] args) {
        Message message = new Message();
        for (int i = 0; i < 100; i++) {
            Thread monThread = new Thread(message);
            monThread.start();
        }
    }

    private static class Message implements Runnable {
        public synchronized void bonjour() {    // (1)
            // Dormir une seconde
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            // Afficher
            System.out.println("Bonjour");
        }

        @Override
        public void run() {
            this.bonjour();
        }
    }
}

  1. Enlevez synchronized et comparez les résultats

Il est très important de noter que synchronized fonctionne seulement si tous les threads utilisent la même instance de l'objet. Si la fonction main() était changée pour le code suivant, ça ne fonctionnerait pas :

public static void main(String[] args) {
    for (int i = 0; i < 100; i++) {
        Thread monThread = new Thread(new Message());
        monThread.start();
    }
}

Pourtant, on pourrait croire que le résultat est le même car la méthode bonjour() devrait être exécutée que par un seul thread à la fois. Que se passe-t-il ?

Voici une représentation graphique :

flowchart TB

    subgraph Non Fonctionnel

        tt1[thread1]
        tt2[thread2]
        tt3[thread3]

        mm1{{message}} -- synchronized --> bb1(("bonjour()"))
        mm2{{message}} -- synchronized --> bb2(("bonjour()"))
        mm3{{message}} -- synchronized --> bb3(("bonjour()"))

        tt1 --> mm1
        tt2 --> mm2
        tt3 --> mm3
    end

    subgraph Fonctionnel

        m{{message}}
        b(("bonjour()"))

        t1[thread1]
        t2[thread2]
        t3[thread3]

        m -- synchronized --> b

        t1 --> m
        t2 --> m
        t3 --> m

    end

En créant chaque fois des nouveaux objets dans un thread différent, chaque thread exécutera une méthode provenant d'un objet différent à chaque fois.

Bloc de code synchronisé

Il peut arriver que seulement une partie d'une méthode doive être synchronisée :

public void bonjour() {
    System.out.println("Avant");

    synchronized (this) {
        System.out.println("Bonjour 1");
        System.out.println("Bonjour 2");
        System.out.println("Bonjour 3");
    }

    System.out.println("Après");
}

Dans cet exemple, seulement un thread aura accès à afficher les bonjours à la fois. Les autres threads devront attendre que tout le code soit terminé avant de pouvoir continuer.

À l'intérieur de synchronized, il y a le mot this qui représente l'instance de l'objet. On peut remplacer this par n'importe instance de n'importe quel objet. Cette portion représente un verrou à acquérir avant de continuer. Si celui-ci est disponible, le thread le prend et exécute le code. Si un second thread veut exécuter le même code, le verrou ne sera pas disponible et il devra attendre qu'il se libère.

Verrous multiples

On peut avoir plusieurs verrous si le besoin se fait sentir :

final Object o1 = new Object();
final Object o2 = new Object();
final Object o3 = new Object();

public void bonjour() {
    // Dormir une seconde
    try {
        synchronized (o1) {
            System.out.println("O1");
            Thread.sleep(1000);
        }

        synchronized (o2) {
            System.out.println("O2");
            Thread.sleep(1000);
        }

        synchronized (o3) {
            System.out.println("O3");
            Thread.sleep(1000);
        }
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }

    // Afficher
    System.out.println("Bonjour");
}

Dans ce scénario, le premier thread pourrait acquérir le verrou nommé o1. Il affichera "01" et dormira pendant une seconde. Le second thread devra alors attendre la fin du Thread.sleep(1000) avant de pouvoir se porter acquéreur du verrou o1 et aussi afficher "01". Même principe pour les verrous suivants.

Le résultat sera alors :

01
(pause de 1 seconde)
02
01
(pause de 1 seconde)
03
02
01
(pause de 1 seconde)
...

Imbriquer des verrous

Finalement, on peut imbriquer les verrous l'un dans l'autre. Le même principe d'obtention des verrous s'applique. La différence principale est que pour obtenir un verrou dans un autre, le verrou principal devra être acquis :

final Object o1 = new Object();
final Object o2 = new Object();
final Object o3 = new Object();

public void bonjour() {
    // Dormir une seconde
    try {
        synchronized (o1) {
            System.out.println("O1");
            Thread.sleep(1000);

            synchronized (o2) {
                System.out.println("O2");
                Thread.sleep(1000);

                synchronized (o3) {
                    System.out.println("O3");
                    Thread.sleep(1000);
                }
            }
        }
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }

    // Afficher
    System.out.println("Bonjour");
}
  1. Le premier thread obtient le verrou o1 et affiche "O1".
  2. Le second thread attend.
  3. Le premier thread obtient le verrou o2 et affiche "O2".
  4. Le second thread attend toujours.
  5. Le premier thread obtient le verrou o3 et affiche "O3".
  6. Le second thread attend toujours.
  7. Le premier thread rend le verrou o3, o2 et o1 et affiche "Bonjour".
  8. Le second thread obtient le verrou o1 et affiche "O1".
  9. Ainsi de suite.

Le résultat sera alors un peu différent :

01
(pause de 1 seconde)
02
(pause de 1 seconde)
03
(pause de 1 seconde)
Bonjour
01
(pause de 1 seconde)
02
(pause de 1 seconde)
...

Note

Essayez le code ci-dessus et assuez-vous de bien comprendre non seulement la syntaxe mais également le fonctionnement de la synchronisation des threads.