• 6 heures
  • Difficile

Ce cours est visible gratuitement en ligne.

course.header.alt.is_certifying

J'ai tout compris !

Mis à jour le 30/11/2016

Protégeons nos variables

Connectez-vous ou inscrivez-vous gratuitement pour bénéficier de toutes les fonctionnalités de ce cours !

Dans le chapitre de rappels précédent, nous avons pu voir que les programmes multithreads posent des problèmes de cohérence de données. Ceux-ci pouvant être mis en sommeil par le système à tout moment, toutes les opérations non atomiques peuvent être compromises et avoir un résultat incohérent. On dit que les données ne sont pas thread-safe. Ce terme est souvent utilisé lorsqu'on parle de certaines collections présentes dans le langage Java.

Dans ce chapitre, nous allons voir comment protéger l'accès à nos données pour n'autoriser qu'un seul thread à y accéder, que ce soit en lecture ou en écriture.

Eh mais je connais déjà le mot clé synchronyzed !

Tout à fait, je sais bien que vous êtes au taquet ! ^^
Mais un petit rappel ne vous fera pas de mal et nous verrons aussi d'autres choses importantes au passage…

Atomicité

Nous l'avons vu dans le chapitre précédent, les problèmes d'incohérence de données sont principalement dues au découpage des tâches Java par le processeur en un ensemble de tâches non atomiques.

L'une des manières de protéger vos données serait donc de vous assurer que ces actions sont atomiques. Nous allons voir plusieurs façons de faire des actions atomiques et nous allons commencer par la plus simple à comprendre…

Le mot clé volatile

La première méthode que nous allons aborder revient à utiliser le mot clé volatile pour déclarer vos variables.
Ce mot clé est censé informer la JVM qu'elle doit rafraîchir le contenu de la variable avant de la donner en lecture ou en écriture à un thread. On devrait donc être certain de la valeur que la variable contient.

Mais "est censé", ça ne garantit rien ?

Malheureusement non… Je vous montre ce mot clé mais toutes les JVM ne le prennent pas en compte et, au moment ou j'écris ces lignes, le mot clé volatile ne fonctionne pas sur ma JVM. Heureusement, il existe un autre moyen pour rendre des actions simples atomiques.

Le package java.util.concurrent.atomic

Comme son nom l'indique, ce package contient des classes dont les méthodes seront considérées comme atomiques. Il contient les classes suivantes : AtomicBoolean, AtomicInteger, AtomicLong, AtomicReference<E>…

Le principal intérêt de ces objets réside dans leurs méthodes qui garantissent donc l'atomicité des actions. Dans tous ces objets, vous pourrez retrouver les méthodes get() et set() qui permettent de modifier votre objet.
Voici deux façons de faire pour pallier à notre problème.

Avec l'objet AtomicInteger
import java.util.concurrent.atomic.AtomicInteger;

public class TestProcessusThread {
   //On déclare donc notre objet
   public static AtomicInteger entier = new AtomicInteger();
   
   public static void main(String[] args) { 
      
      Thread t1 = new Thread(new Test());
      Thread t2 = new Thread(new Test());
      Thread t3 = new Thread(new Test());
      Thread t4 = new Thread(new Test());
      
      t1.start();
      t2.start();
      t3.start();
      t4.start();
   }
}
class Test implements Runnable {
   public void run() {
      for (int i = 0; i < 10; i++) {
         //Cette méthode permet d'incrémenter la valeur
         //Et de retourner la valeur modifée de façon atomique
         TestProcessusThread.entier.incrementAndGet();
         
         /**
          * il existe encore beaucoup de méthodes intéressantes dans cet objet
          * decrementAndGet() : décrémente et retourne la valeur
          * addAndGet(int delta) : ajoute delta à la variable et la retourne
          * getAndAdd(int delta) : ajoute delta à la variable mais retourne l'ancienne valeur
          * getAndSet(int newValue) : définit la nouvelle valeur mais retourne l'ancienne
          * …
          */         
         System.out.println(Thread.currentThread().getName() + " - " + TestProcessusThread.entier);         
         try {
            Thread.sleep(2000);
         } catch (InterruptedException e) {}
      }
   }
}

Avec cette méthode le problème est définitivement réglé. :magicien:

Avec un objet perso
package com.sdz.atomic;

import java.util.concurrent.atomic.AtomicInteger;

public class Increment {
   private AtomicInteger entier = new AtomicInteger(0);
   public void incremente(){
      entier.incrementAndGet();
   }
   public Integer get(){
      return entier.get();
   }
}
package com.sdz.atomic;
import java.util.concurrent.atomic.AtomicReference;

public class TestProcessusThread {
   //On déclare donc notre objet
   public static Increment entier = new Increment();
   
   public static void main(String[] args) { 
      
      Thread t1 = new Thread(new Test());
      Thread t2 = new Thread(new Test());
      Thread t3 = new Thread(new Test());
      Thread t4 = new Thread(new Test());
      
      t1.start();
      t2.start();
      t3.start();
      t4.start();
   }
}
package com.sdz.atomic;

class Test implements Runnable {
   public void run() {
      for (int i = 0; i < 10; i++) {
         TestProcessusThread.entier.incremente();         
         System.out.println(Thread.currentThread().getName() + " - " + TestProcessusThread.entier.get());         
         try {
            Thread.sleep(2000);
         } catch (InterruptedException e) {}
      }
   }
}

Ces objets étant très faciles à utiliser, je ne m'attarderai pas dessus.
Voyons maintenant une autre façon de rendre nos programmes thread-safe.

Synchronisation !

Nous voilà donc dans un contexte que vous connaissez bien puisque nous allons nous servir du mot clé synchronized.
Ce dernier permet de poser un verrou sur une méthode ou une portion de code, devant être utilisée par plusieurs threads, afin de s'assurer qu'un seul thread ne traite ce morceau de code.

Avant de commencer, voici le code que nous allons utiliser :

package com.sdz.synchro;

public class Increment {
   private int entier = 0;
   public void incremente(){
      entier++;
   }
   public Integer get(){
      return entier;
   }
}
package com.sdz.synchro;

class Test implements Runnable {
   public void run() {
      for (int i = 0; i < 10; i++) {
         TestProcessusThread.entier.incremente();         
         System.out.println(Thread.currentThread().getName() + " - " + TestProcessusThread.entier.get());         
         try {
            Thread.sleep(2000);
         } catch (InterruptedException e) {}
      }
   }
}
package com.sdz.synchro;

public class TestProcessusThread {
   
   public static Increment entier = new Increment();
   
   public static void main(String[] args) { 
      
      Thread t1 = new Thread(new Test());
      Thread t2 = new Thread(new Test());
      Thread t3 = new Thread(new Test());
      Thread t4 = new Thread(new Test());
      
      t1.start();
      t2.start();
      t3.start();
      t4.start();
   }
}

Pourquoi ne pas avoir utilisé le mot clé synchronized sur la méthode run() ?

C'est simple, synchronized garantit qu'un morceau de code, utilisé par plusieurs threads, soit bloqué par un verrou afin d'en interdire l'accès à d'autres threads qui en font la demande. La méthode run() est propre à chaque thread, donc il est inutile de la synchroniser : chaque thread utilise sa propre méthode. C'est le contenu de la méthode qu'il faut synchroniser.
Au lieu de créer un objet dédié à l'incrémentation, j'aurais très bien pu créer une méthode statique dans la classe TestProcessusThread : le résultat aurait été le même. ^^

Il existe deux façons de poser un verrou avec le mot clé synchronized. Vous devez connaître cette façon de faire :

package com.sdz.synchro;

public class Increment {
   private int entier = 0;
   public synchronized void incremente(){
      entier++;
   }
   public synchronized Integer get(){
      return entier;
   }
}

Mais connaissez-vous celle-ci ci-dessous ?

package com.sdz.synchro;

public class Increment {
   private int entier = 0;
   public void incremente(){
      synchronized(this){
         entier++;
      }
   }
   public Integer get(){
      synchronized (this){
         return entier;
      }
   }
}

En fait, ces deux codes font strictement la même chose : ils posent un verrou interne. La particularité de ce genre de verrou est que lorsqu'un thread arrive sur le mot clé synchronized, il pose automatiquement un verrou sur la méthode (ou le morceau de code) et il le retire de la même manière après avoir traité le code : automatiquement, même si une interruption est survenue sur le thread !

Vous l'aurez donc compris, les morceaux de codes protégés avec le mot clé synchronized sont considérés comme atomiques, vu qu’un seul thread peut l'exécuter.

D'accord, mais à quoi sert this en paramètre du mot clé dans la seconde syntaxe ?

En fait, ce mot clé pose un verrou sur l'objet contenant le code à protéger. La première syntaxe pose le verrou sur l'objet this de façon tacite mais le verrou est bel et bien sur l'objet.

La première syntaxe utilisée est un raccourci qui permet de mettre la synchronisation dans la signature de la méthode lorsque tout le code d'une méthode doit être protégé, mais nous aurions pu avoir ce genre de choses :

public void incremente(){
   //Fait quelques chose sans protection
   //Cela peu être n'importe quoi et tout ceci sera accessible 
   //a tous les threads sans blocage
   synchronized(this){
      //seule cette instruction sera bloquante
      entier++;
   }
   //Nous pourrions très bien avoir du code aussi après
   //le bloc de synchronisation
}

Ce mécanisme permet de s'assurer qu'un thread A ayant posé un verrou puisse mettre à jour et travailler avec des variables, et qu'au moment où le verrou sera relâché, un thread B aura accès aux données modifiées par le thread A. Vu que le verrou est l'objet et que deux threads ne peuvent pas avoir le même verrou au même moment, on s'assure ainsi de la cohérence des données et de la synchronisation des threads. C'est pourquoi il faut que le verrou soit commun à tous les threads.

Bref, avec l'une ou l'autre de ces deux syntaxes, votre code est maintenant utilisable en environnement multithread. Je vous propose maintenant de voir une autre façon de poser des verrous dans votre code.

Lock! Lock! Lock!

L'interface Lock

Depuis Java 5, une nouvelle interface et un nouvel objet ont été ajoutés au langage Java : il s'agit de l'interface Lock et de l'objet ReentrantLock. Ces éléments permettent de poser des verrous internes mais en affinant leur gestion.
L'interface Lock est très simple car elle ne contient que deux méthodes principales :

  • lock() : pose un verrou ;

  • unlock() : retire le verrou précédemment posé.

Voici comment ceci fonctionne, toujours avec notre objet Increment

package com.sdz.lock;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Increment {
   private int entier = 0;
   //Nous créons notre objet qui va nous fournir un verrou
   private Lock verrou = new ReentrantLock();
   public void incremente(){
      
      //Nous verrouillons le code qui suit cette instruction
      verrou.lock();
      
      //Nous utilisons un bloc try surtout
      //pour pouvoir avoir un bloc finally
      try{
         
         //tout ce code est maintenant considéré comme atomique !
         entier++;
      }finally{
         //ainsi, même s'il y a eu une interruption sur notre thread
         //le verrou sera relâché, dans le cas contraire
         //tous les autres threads ne pourraient plus travailler ! 
         verrou.unlock();
      }
   }
   public synchronized Integer get(){
       return entier;
   }
}

Quand tu dis que le code est maintenant atomique, tu veux dire que les threads ne se mettront plus en sommeil pendant son exécution ?

Non. Ceci est un abus de langage. Les threads peuvent se mettre en sommeil pendant l'exécution de ce morceau de code mais, vu que ce code est maintenant verrouillé, il agit comme s'il était atomique mais il ne l'est effectivement pas du tout. :-°

Il existe d'autres méthodes qui peuvent être utiles pour la gestion des verrous, par exemple la méthode tryLock() qui retourne un booléen si le verrou est mis. Voici un code d'exemple :

public void incremente(){
   
   //S'il est possible de poser un verrou
   if(verrou.tryLock()){
      try{
         entier++;
      }finally{ 
         verrou.unlock();
      }
   }
   else{
      //Dans le cas contraire, c'est qu'il y a risque d'interblocage
      //donc on ne prend pas le risque de traiter les données
   }
}

Cette méthode peut aussi prendre deux paramètres qui sont un délai et une métrique de temps. Ainsi, vous pouvez gérer des threads qui doivent travailler avec une contrainte de temps, comme interroger un WebService par exemple. Voici à quoi ressemble la syntaxe de ce genre de verrou :

public void incremente(){
   
   //S'il est possible de poser un verrou
   try {
      //Cette instruction peut lever une InterruptedException
      if(verrou.tryLock(1000L, TimeUnit.MILLISECONDS)){
         try{
            entier++;
         }finally{ 
            verrou.unlock();
         }
      }
      else{
         //Dans le cas contraire, c'est qu'il y a risque d'interblocage
         //donc on ne prend pas le risque de traiter les données
      }
   } catch (InterruptedException e) {
      e.printStackTrace();
   }
}

Ceci peut être très pratique. :)
Ce qui se passe est très simple : si le thread obtient le verrou, rien de particulier (la méthode retourne true) mais s'il ne l'obtient pas, il est mis en sommeil jusqu'à ce l'un des trois événements suivants surviennent :

  • le verrou est enfin obtenu ;

  • le thread est interrompu ;

  • le délai d'attente est dépassé.

Par contre, ce genre de verrou pose un problème : lorsqu'un thread obtient un verrou, nous ne pouvons plus l'interrompre… Ceci peut être très ennuyeux dans certains cas. Heureusement, l'interface Lock possède une méthode qui permet de créer un verrou interruptible : lockInterruptibly(). Grâce à cette méthode, les threads ayant un verrou peuvent être stoppés et leurs ressources libérées.

Mais attendez, il y a encore mieux : vous pouvez utiliser des conditions pour vos verrous.

Comment ça ?

Dans notre exemple, nous verrouillons sans condition le code sensible mais, dans d'autres cas, vous devrez peut-être verrouiller un morceau de code suivant une certaine condition. Afin de mieux comprendre ce concept, nous allons prendre un autre exemple qui sera beaucoup plus compréhensible : un compte en banque où nous allons faire des retraits et des dépôts, mais où nous ne devrons pas pouvoir faire de retrait si le solde de ce compte est inférieur à un certain montant. :-°

Voici le code source que nous allons utiliser :

package com.sdz.condition;

import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class CompteEnBanque {
   private AtomicLong solde = new AtomicLong(1_000L);
   private final long decouvert = -130L;

   private Lock verrou = new ReentrantLock();
   private Condition condition = verrou.newCondition();
   
   /**
    * C'est sur cette méthode que nous allons devoir travailler
    * dans nos threads et vérifier le solde avant de retirer de l'argent
    */
   public void retrait(long montant){
      verrou.lock();
      try{
         long avant = solde.get();
         solde.set((avant - montant));
         solde();
      }finally{
         verrou.unlock();
      }
   }
   
   //Puisqu’on utilise un objet AtomicLong
   //Inutile de synchroniser. ^^
   public void depot(long montant){
      synchronized(this){
         long result = solde.addAndGet(montant);
         solde();
      }
   }

   
   public synchronized void solde(){
      System.out.println("Solde actuel, dans " + Thread.currentThread().getName()
                           + " : " +  solde.longValue());
   }  
   
   public synchronized long getSolde(){
      return solde.longValue();
   }
   
   public long getDecouvert(){
      return decouvert;
   }
}
package com.sdz.condition;

import java.util.Random;

public class ThreadDepot extends Thread{
   
   private CompteEnBanque ceb;
   private Random rand = new Random();
   
   public ThreadDepot(CompteEnBanque c){
      ceb = c;
      this.setName("Dépôt");
   }
   
   public void run() {
       while(true){
          
          int nb = rand.nextInt(100);
          long montant = Integer.valueOf(nb).longValue();
          ceb.depot(montant);
          try {
            Thread.sleep(2000);
          } catch (InterruptedException e) {}
       }
   }
}
package com.sdz.condition;

import java.util.Random;

public class ThreadRetrait extends Thread {

   private CompteEnBanque ceb;
   private Random rand = new Random();
   private static int nbThread = 1;
   
   public ThreadRetrait(CompteEnBanque c){
      ceb = c;
      this.setName("Retrait" + nbThread++);
   }
   
   public void run() {
       while(true){
          int nb = rand.nextInt(300);
          long montant = Integer.valueOf(nb).longValue();   
          ceb.retrait(montant);
       
          try {
             Thread.sleep(1000);
           } catch (InterruptedException e) {}          
       }
   }
}
package com.sdz.condition;

public class Main {

   public static void main(String[] args) {
      CompteEnBanque ceb = new CompteEnBanque();
      
      //On crée deux threads de retrait
      Thread t1 = new ThreadRetrait(ceb);
      t1.start();
      
      Thread t2 = new ThreadRetrait(ceb);
      t2.start();
      
      //et un thread de dépôt
      Thread t3 = new ThreadDepot(ceb);
      t3.start();
   }
}

En l'état actuel des choses, si vous exécutez ce code, au bout de quelques secondes vous devriez être à découvert sur votre compte...

Vous êtes à découvert
Vous êtes à découvert

Nous allons maintenant modifier notre classe qui se charge de faire des retraits pour n'autoriser des retraits uniquement si le compte aura toujours un solde supérieur à l'autorisation de découvert après TOUS LES DÉBITS. Voici donc notre code avec un objet Condition. Attention les yeux :

package com.sdz.condition;

import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class CompteEnBanque {
   private AtomicLong solde = new AtomicLong(1_000L);
   private final long decouvert = -130L;
   //cette variable va nous servir à savoir 
   //le nombre de tentatives de retrait successives
   private AtomicLong tentativeDeRetrait = new AtomicLong(0);
   
   //notre verrou
   private Lock verrou = new ReentrantLock();
   //notre objet condition
   private Condition condition = verrou.newCondition();
   
   /**
    * C'est sur cette méthode que nous allons devoir travailler
    * dans nos threads et vérifier le solde avant de retirer de l'argent
    */
   public void retrait(long montant){
      verrou.lock();
      String threadName = Thread.currentThread().getName();
      try{
         //On met en attente les threads tant que la condition n'est pas remplie
         //Le thread étant mis en attente si cette condition est remplie
         //on aurait pu utiliser un simple "if" mais on ne sait jamais
         while((solde.get() - montant) < decouvert){
            
            //dans ce cas, le thread qui tente de retirer ce montant
            //mettra notre solde en deçà du découvert autorisé
            System.err.println(threadName + " tente de retirer " + montant);
            
            //on stock le cumul des tentatives de retrait car
            //lorsque le verrou sera levé, tous les threads en attente
            //seront autorisés à faire leur retrait, il faut donc contrôler le cumul
            //de toutes les tentatives de retrait
            tentativeDeRetrait.addAndGet(montant);
            
            //on pose un verrou via la condition
            //cette instruction rend le thread inéligible
            //à travailler
            condition.await();
         }
        
         //Si nous sommes ici, c'est que le montant du retrait
         //est autorisé ou que la condition a libéré le verrou sur le thread
         solde.set((solde.get() - montant));
         solde();         
      }
      //L'ajout d'un verrou via une condition peut lever ce genre d'exception
      catch (Exception e) {e.printStackTrace();}
      finally{
         //On oublie pas le libéré le verrou général
         verrou.unlock();
      }
   }
   
   //Puisqu’on utilise un objet AtomicLong
   //Inutile de synchroniser, mais on utilisera tout de même un verrou. ^^
   //C'est dans cette méthode que la condition sera libérée
   public void depot(long montant){
      
      //On utilise le même verrou que celui qui a engendré la condition
      //sans cela, la condition créée à partir de ce verrou
      //lèvera une exception si nous tentons de la libérer
      verrou.lock();
      
      try{
         
         //Nous faisons notre traitement
         long result = solde.addAndGet(montant);
         solde();
         
         //Nous vérifions si le solde après les tentatives de retraits
         //sera toujours au dessus de l'autorisation de découvert
         long soldeApresRetrait = getSolde() - tentativeDeRetrait.get();
         
         //Si tel est le cas, libération du verrou
         if(soldeApresRetrait > decouvert){
            //on réinitialise notre variable de contrôle à 0
            tentativeDeRetrait.set(0);
            //on libère le verrou posé par la condition
            //cette instruction va libérer tous les threads mis en attente
            condition.signalAll();
            System.err.println("\n Montant après retrait (" + soldeApresRetrait + ") < découvert \n");
         }
      
      }finally{
         //on n’oublie pas de libérer le verrou général
         verrou.unlock();
      }
   }
   
   public void solde(){
      System.out.println("Solde actuel, dans " + Thread.currentThread().getName()
                           + " : " +  solde.longValue());
   }  
   
   public long getSolde(){
      return solde.longValue();
   }
   
   public long getDecouvert(){
      return decouvert;
   }
}

Voici un extrait du rendu de ce code :

Utilisation d'une condition
Utilisation d'une condition

Je vous propose maintenant de voir comment ça fonctionne et ce qu'il se passe. Dans ce code, nous avons deux types de threads : un premier qui effectue des retraits et un second qui effectue des dépôts. Le principe ici est donc de ne pas autoriser un retrait qui engendrerait un dépassement de notre autorisation de découvert mais, et c'est la différence avec un bête rejet de la demande, nous l'autorisons dès lors que le solde redevient suffisant pour accepter toutes les demandes de retraits en attente (c'est à ça que sert notre variable tentativeDeRetrait, à stocker le montant total des tentatives).
J'ai donc mis une boucle pour contrôler le solde après retrait et, si le montant dépasse le découvert, on invoque la méthode await() de notre objet de condition, ce qui a pour effet de mettre le thread demandeur en attente.

Maintenant, nous devons débloquer les threads en attente lorsque le solde redevient suffisant pour accepter toutes les demandes de retraits. C'est ici qu'intervient la méthode depot(), utilisée par un autre thread que ceux qui sont bloqués et ceci est un point capital !

Pourquoi ?

Tout simplement parce qu'un thread mis en attente avec un objet de condition ne peut être remis en travail que si l'objet condition est déverrouillé depuis un autre thread que celui qui est en attente et uniquement si celui-ci utilise le même verrou !
Il faut donc que notre méthode utilise le même verrou que celui utilisé pour bloquer les retraits et que cette méthode soit utilisée par un autre thread que ceux qui sont bloqués. Heureusement, c'est le cas ici.

Pour simplifier, si nous avons trois threads (t1, t2 et t3), que t1 est bloqué via une condition, seul les threads t2 et t3 peuvent débloquer t1.

J'attire maintenant votre attention sur les risques d'erreurs et là, il faut être très vigilant !
Je n'utilise pas de variable pour stocker le montant du solde dans la méthode retrait() et ceci pour une bonne raison : un thread mis en sommeil avec un condition reprendra exactement là où il s'en est arrêté lorsque le verrou sera libéré. Imaginons que nous stockons un solde à un instant T, que le thread est mis en sommeil, qu'un autre thread modifie le solde et libère le verrou. Le thread maintenant libéré va reprendre son traitement en utilisant la variable qu'il avait utilisée pour stocker le solde à un moment antérieur. Hors, cette variable contient un solde erroné et le traitement sera faussé. Faites l'essai avec cette méthode retrait():

public void retrait(long montant){
   verrou.lock();
   String threadName = Thread.currentThread().getName();
   try{
      long avant = solde.get();
      //On met en attente les threads tant que la condition n'est pas remplie
      //Le thread étant mis en attente si cette condition est remplie
      //on aurait pu utiliser un simple "if" mais on ne sait jamais
      while((avant - montant) < decouvert){
         
         //dans ce cas, le thread qui tente de retirer ce montant
         //mettra notre solde en deçà du découvert autorisé
         System.err.println(threadName + " tente de retirer " + montant 
                           + " du solde " + avant);
         
         //on stocke le cumul des tentatives de retrait car
         //lorsque le verrou sera levé, tous les threads en attente
         //seront autorisés à faire leur retrait, il faut donc contrôler le cumul
         //de toutes les tentatives de retrait
         tentativeDeRetrait.addAndGet(montant);
         
         //on pose un verrou via la condition
         //cette instruction rend le thread inéligible
         //à travailler
         condition.await();
      }
     
      System.err.println("Variable avant : " + avant + " - solde réel : " + solde.get());
      //Si nous sommes ici, c'est que le montant du retrait
      //est autorisé ou que la condition a libéré le verrou sur le thread
      solde.set((avant - montant));
      solde();         
   }
   //L'ajout d'un verrou via une condition peut lever ce genre d'exception
   catch (Exception e) {e.printStackTrace();}
   finally{
      //On oublie pas de libérer le verrou général
      verrou.unlock();
   }
}

Voici ce que nous donnerait ce code :

Problème de valeur erronée
Problème de valeur erronée

L'autre point d'erreur récurrent provient d'une non utilisation du verrou pour débloquer la condition. Je vous ai dit plus tôt que les threads mis en sommeil avec une condition ne peuvent être débloqués que depuis un autre thread, mais aussi que ce dernier doit utiliser le verrou qui a engendré la condition. De ce fait, si vous synchronisez la méthode depot() de cette façon :

public synchronized void depot(long montant){
     
   //Nous faisons notre traitement
   long result = solde.addAndGet(montant);
   solde();
   
   //Nous vérifions si le solde après les tentatives de retraits
   //sera toujours au dessus de l'autorisation de découvert
   long soldeApresRetrait = getSolde() - tentativeDeRetrait.get();
   
   //Si tel est le cas, libération du verrou
   if(soldeApresRetrait > decouvert){
      //on réinitialise notre variable de contrôle à 0
      tentativeDeRetrait.set(0);
      //on libère le verrou posé par la condition
      //cette instruction va libérer tous les threads mis en attente
      condition.signalAll();
      System.err.println("\n Montant après retrait (" + soldeApresRetrait + ") < découvert \n");
   }   
}

... votre code lèvera une exception :

Exception due à la non utilisation du verrou
Exception due à la non utilisation du verrou

Vous devez maintenant comprendre que ce type de verrou est très pratique mais demande beaucoup de rigueur dans la façon de développer… Alors faites bien attention à comment vous choisissez vos verrous. ^^

L'interface ReadWriteLock

Il existe encore une autre interface qui peut s'avérer utile dans certains cas : ReadWriteLock.
Comme vous l'aurez compris, lorsqu'un verrou est posé, un seul thread peu utiliser le code protégé. Dans le cas où nous sommes dans un contexte de lecture/écriture, il peut s'avérer utile d'autoriser la lecture des données à plusieurs threads, sans verrou, mais de bloquer tous ces threads lorsqu'une écriture survient. C'est dans ce cadre que cette interface intervient.
Elle permet de créer deux objets Lock différents via ses méthodes readLock() et writeLock(). L'implémentation de base de cette interface correspond à l'objet ReentrantReadWriteLock, c'est donc ce dernier que nous allons utiliser.

Voici un code d'exemple utilisant ce type de verrou :

package com.sdz.readwrite;

import java.util.Map;
import java.util.Random;
import java.util.TreeMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class Dictionnaire {

   private Map<Integer, String> dico = new TreeMap<>();
   private String[] listMot = {"abc", "bcd", "cde", "def", "efg"};
   private Random rand = new Random();
   private static AtomicInteger indiceCollection = new AtomicInteger(0);
   
   ReadWriteLock rwl = new ReentrantReadWriteLock();
   //Le verrou en écriture
   Lock writeLock = rwl.writeLock();
   //Le verrou en lecture
   Lock readLock = rwl.readLock();
   
   public void ajouter(){
      //On pose le verrou en écriture, ce qui va bloquer tous les autres threads
      //en écriture mais aussi en lecture
      writeLock.lock();
      try{
         //On fait notre traitement
         String mot = listMot[rand.nextInt(5)];
         int indice = indiceCollection.getAndIncrement();
         String motAjouter = mot + indice;
         dico.put(indice, motAjouter);
         System.err.println(Thread.currentThread().getName() + " : indice = "
               + indice + " ; mot = " + motAjouter
            );
      }finally{
         //On n’oublie surtout pas de libérer le verrou !
         writeLock.unlock();
      }
   }
   
   public void lire(){
      //On pose le verrou en lecture, ce qui n'a pas d'effet pour les threads 
      //qui ne font que lire mais qui va permettre
      //aux threads en écriture de bloquer l'accès lorsque ceux-ci invoque un verrou
      readLock.lock();
      try{
         //On fait note traitement
         if(dico.keySet().size() > 0){
            
            int length = dico.keySet().size();
            
            int indiceMot = rand.nextInt(length);
            System.out.println(Thread.currentThread().getName() + " : indice = "
                                 + indiceMot + " ; mot = " + dico.get(indiceMot)
                              );
         }
      }finally{
         //On n’oublie surtout pas de libérer le verrou !
         readLock.unlock();
      }
   }   
}
package com.sdz.readwrite;

public class ThreadWriter extends Thread {
   private Dictionnaire dico;
   public ThreadWriter(String nom, Dictionnaire pDico){
      setName(nom);
      dico = pDico;
   }
   
   public void run(){
      while(true){
         dico.ajouter();
         try {
            Thread.sleep(1000);
         } catch (InterruptedException e) {
            e.printStackTrace();
         }
      }
   }   
}
package com.sdz.readwrite;

public class ThreadReader extends Thread {
   private Dictionnaire dico;
   public ThreadReader(String nom, Dictionnaire pDico){
      setName(nom);
      dico = pDico;
   }
   
   public void run(){
      while(true){
         dico.lire();
         try {
            Thread.sleep(500);
         } catch (InterruptedException e) {
            e.printStackTrace();
         }
      }
   }   
}
package com.sdz.readwrite;

public class Main {
   public static void main(String[] args){
      Dictionnaire dico = new Dictionnaire();
      
      ThreadWriter tw1 = new ThreadWriter("Writer 1", dico);
      tw1.start();
      ThreadWriter tw2 = new ThreadWriter("Writer 2", dico);
      tw2.start();
      
      for(int i = 0; i < 6; i++){
         ThreadReader tr = new ThreadReader("Reader "+ i , dico);
         tr.start();
      }
   }
}

Alors que se passe-t-il concrètement ? Les threads qui ne font que lire le dictionnaire ont un accès concurrent à ce dernier, sans verrous, donc deux, trois, vingt threads peuvent lire le contenu du dictionnaire jusqu'à ce qu'un thread d'écriture invoque la méthode lock(). À ce moment, ce thread attend que la dernière lecture s'achève puis bloque tous les threads en lecture, par le biais du verrou posé dans le code de lecture : ce bloque sera retiré lorsque la méthode unlock() du verrou d'écriture sera invoquée.
Ceci permet, lorsque le cas est bien maîtrisé, de gagner en temps de traitement et donc en performance mais encore faut-il que le cadre d'exécution de votre programme le permette : il faut donc des threads qui ne font que lire des données et des threads pour utiliser ce type de verrou.

Que choisir ?

Il va de soi que le critère principal qui va vous permettre de choisir un verrou est sa fonction : que doit-il verrouiller et est-il soumis à des conditions ?
Si le verrou est conditionnel, utilisez des conditions et donc un verrou de type Lock.

Maintenant, si le verrou n'est pas conditionnel, doit-on privilégier des méthodes (ou un bloc de code) utilisant synchronized ou des objets atomiques ? Ceci va dépendre des actions que vous devrez entreprendre. S'il s'agit d'une simple affectation de valeur sans autre traitement, un objet atomique peut suffire : c'est d'ailleurs ce que j'ai fait dans la méthode getSolde() du code vu précédemment.
L'avantage du mot clé synchronized vient du fait qu'il est très visuel, connu du plus grand nombre et travail de façon automatique sur la mise en place du verrou et sur sa libération.

Il revient donc à vous de bien choisir en fonction des cas, mais, par souci de sécurité et de lisibilité, vous pouvez vous orienté vers le mot clé synchronized.

Voilà, vous avez appris à protéger vos données des accès concurrents. C'est l'aspect primordial de la programmation concurrente !
Si vous avez bien compris ces notions de sécurité, vous ne devriez pas avoir trop de surprises dans vos prochains programmes... mais bon, ce type de programmation réserve toujours des surprises, donc ne baissez pas la garde ! :-°

Je vous propose maintenant un petit TP afin de vous entraîner car mine de rien, vous venez de voir beaucoup de choses, et il n'y a rien de mieux qu'un peu de pratique pour bien digérer !

Exemple de certificat de réussite
Exemple de certificat de réussite