Les Verrous de Concurrence en Java

Voici une présentation des différents types de verrous de concurrence couramment utilisés en Java.

snychronized

Le mot-clé synchronized en Java garantit qu'une méthode ou un bloc de code modifié ne peut être exécuté que par un seul thread à la fois.

Verrou d'objet : Chaque objet en Java possède un verrou associé. Lorsqu'une méthode marquée synchronized est appelée, le verrou de l'objet est acquis, empêchant d'autres threads d'exécuter d'autres méthodes synchronized sur le même objet simultanément.

Verrou de classe : Chaque classe possède également un verrou associé à son objet Class. Ce verrou s'applique à la classe entière plutôt qu'à une instance spécifique.

Pour obtenir un verrou d'objet :

  • synchronized(this|objet) {}
  • Méthode d'instance non statique marquée avec synchronized

Pour obtenir un verrou de classe :

  • synchronized(NomClasse.class) {}
  • Méthode statique marquée avec synchronized

Les verrous synchronized existent dans quatre états : non verrouillé, verrou biaisé, verrou léger et verrou lourd.

Verrou non verrouillé : L'objet vient d'être créé et n'a subi aucune opération de synchronisation. Les threads peuvent utiliser librement les ressources de cet objet.

Verrou biaisé : Lorsqu'un seul thread acquiert et libère régulièrement le verrou, il passe en mode verrou biaisé. Le verrou stocke l'ID du thread qui le détient, permettant à ce thread d'accéder à nouveau au bloc synchronisé sans passer par le mécanisme de verrouillage complet. Si un autre thread tente d'accéder au bloc, le verrou biaisé est annulé et passe en verrou léger.

Verrou léger : Lorsqu'un deuxième thread tente d'accéder au verrou biaisé, une comparaison et un échange atomique (CAS) sont utilisés pour concurrencer le verrou. Si le thread initial libère rapidement le verrou, le système passe en verrou léger. Ce type de verrou convient aux scénarios où les threads exécutent alternativement des blocs synchronisés. Si plusieurs threads tentent d'accéder simultanément au verrou, il passe en verrou lourd.

Verrou lourd : Lorsqu'un thread ne parvient pas à obtenir le verrou léger après plusieurs tentatives (par défaut 10 en Java), le verrou est promu à verrou lourd. Les verrous lourds s'appuient sur les verrous de mutex du système d'exploitation, ce qui implique un changement de mode utilisateur à noyau, une opération coûteuse en termes de performance.

Exemple simple 1 (acquisition directe d'un verrou d'objet) :

class TacheConcurrente implements Runnable{

    @Override
    public void run(){<br></br>     // Acquisition du verrou d'objet
        synchronized(this){
            try{
                int identifiant = (int)Thread.currentThread().threadId();
                System.out.println("Début de l'exécution par le thread " + identifiant);
                Thread.sleep(1000);
                System.out.println("Fin de l'exécution par le thread " + identifiant);
            }catch(Exception e){
                e.printStackTrace();
            }
        }
    }
}

public class TestSynchronized {

    public static void main(String[] Args){
        Runnable maTache = new TacheConcurrente();
        Thread[] threads = new Thread[5];
        for(int i=0;i<5;++i)threads[i]=new Thread(maTache);
        long debut = System.currentTimeMillis();
        for(int i=0;i<5;++i)threads[i].start();
        try{
            for(int i=0;i<5;++i)threads[i].join();
        }catch(Exception e){

        }
        long fin = System.currentTimeMillis();
        
        System.out.println("Temps total : " + (fin - debut) + " ms");
    }
}

Résultat d'exécution :

Exemple simple 2 (méthode synchronized) :

public class TestSynchronisation {
    private static int compteur = 0;
    private static synchronized void incrementer(){
        compteur++;
        System.out.println("Valeur du compteur : " + compteur);
    }

    public static void main(String[] Args){
        Thread[] threads = new Thread[5];
        for(int i=0;i<5;++i)threads[i]=new Thread(new Runnable() {
            @Override
            public void run(){
                incrementer();
            }
        });
        for(int i=0;i<5;++i)threads[i].start();
    }
}

Résultat d'exécution :

Si le mot-clé synchronized est retiré de la méthode incrementer(), le résultat pourrait être le suivant (non déterministe) :

Cela démontre que le mot-clé synchronized garantit l'atomicité des opérations.

wait, notify, notifyAll

Les méthodes wait, notify et notifyAll de la classe Object sont des méthodes finales publiques.

Ces trois méthodes ne peuvent être appelées que par des threads détenant le verrou de l'objet correspondant, sinon une exception sera levée.

La méthode wait met en pause le thread courant et libère le verrou de l'objet.

La méthode notify réveille aléatoirement un thread mis en pause par la méthode wait.

La méthode notifyAll réveille tous les threads mis en pause par la méthode wait.

Points importants :

  • Le mot-clé synchronized appliqué à une méthode statique acquiert un verrou de classe, tandis qu'appliqué à une méthode d'instance, il acquiert un verrou d'objet.
  • Le caractère synchronized d'une méthode n'est pas hérité par les sous-classes.
  • Les méthodes d'interface et les constructeurs ne peuvent pas être modifiés avec synchronized.
  • synchronized est un verrou réentrant.

Interface Lock

L'interface Lock du package java.util.concurrent fournit des méthodes de verrouillage plus flexibles que synchronized :

  1. lock() : Acquiert le verrou. Si le verrou n'est pas disponible, le thread est désactivé pour l'ordonnancement et entre en état d'attente jusqu'à l'acquisition du verrou.
  2. tryLock() : Tente d'acquérir le verrou et retourne true en cas de succès, sans bloquer le thread.
  3. tryLock(long temps, TimeUnit unit) : Similaire à tryLock mais avec un délai d'attente maximal.
  4. lockInterruptiblement() : Tente d'acquérir le verrou, mais peut être interrompu par un autre thread.
  5. unlock() : Libère le verrou.

AQS (AbstractQueuedSynchronizer)

AQS est un cadre de synchronisation qui maintient un état de ressource partagée (volatile int) et une file d'attente FIFO de threads pour gérer l'accès concurrent à des ressources partagées.

AQS est la base de nombreux composants du package JUC comme Lock, CountDownLatch et Semaphore. Il définit deux modes de partage de ressources : exclusif (un seul thread à la fois) et partagé (plusieurs threads simultanément).

Les synchronisateurs personnalisés implémentent simplement la logique d'acquisition et de libération de la ressource state, tandis que la gestion de la file d'attente est assurée par AQS.

ReentrantLock (verrou réentrant)

ReentrantLock est un verrou mutuel réentrant, fonctionnellement similaire à synchronized mais plus flexible.

ReentrantLock propose deux modes : équitable (fair) et non équitable (nonfair). Le mode non équitable (par défaut) permet aux nouveaux threads de concurrencer directement les ressources, même s'il y a des threads en attente. Le mode équitable garantit que les threads sont servis dans l'ordre d'arrivée.

Caractéristiques principales :

  • Réentrance : Un thread peut acquérir plusieurs fois le même verrou sans risque de deadlock.
  • Interrompable : lockInterruptiblement() permet d'interrompre un thread en attente de verrou.
  • Non-blocant : tryLock() permet d'essayer d'acquérir le verrou sans se bloquer.

Exemple d'utilisation :

public class CompteBancaire {
    private final ReentrantLock verrou = new ReentrantLock();
    private double solde = 0;

    public void deposer(double montant) {
        verrou.lock();
        try {
            solde += montant;
            System.out.println("Déposé: " + montant + ", Solde: " + solde);
        } finally {
            verrou.unlock();
        }
    }

    public void retirer(double montant) {
        verrou.lock();
        try {
            if (solde >= montant) {
                solde -= montant;
                System.out.println("Retiré: " + montant + ", Solde: " + solde);
            } else {
                System.out.println("Fonds insuffisants");
            }
        } finally {
            verrou.unlock();
        }
    }
}

ReentrantReadWriteLock (verrou lecture/écriture)

ReentrantReadWriteLock implémente l'interface ReadWriteLock avec deux verrous distincts :

  • readLock() : Verrou en lecture, partagé entre plusieurs lecteurs.
  • writeLock() : Verrou en écriture, exclusif.

Ce type de verrou est particulièrement efficace dans les scénarios de lecture fréquente et d'écriture rare.

public class RessourcePartagee {
    private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
    private final ReentrantReadWriteLock.ReadLock lecture = rwLock.readLock();
    private final ReentrantReadWriteLock.WriteLock ecriture = rwLock.writeLock();
    private String donnee = "";

    public void modifierDonnee(String nouvelleDonnee) {
        ecriture.lock();
        try {
            donnee = nouvelleDonnee;
            System.out.println("Donnée modifiée: " + donnee);
        } finally {
            ecriture.unlock();
        }
    }

    public String lireDonnee() {
        lecture.lock();
        try {
            System.out.println("Lecture de la donnée: " + donnee);
            return donnee;
        } finally {
            lecture.unlock();
        }
    }
}

L'implémentation utilise un entier de 32 bits où les 16 bits de poids fort comptent le nombre de verrous de lecture, et les 16 bits de poids faible comptent les verrous d'écriture.

Étiquettes: Java Concurrence Verrous synchronized AQS

Publié le 27 juin à 23h53