IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

 

Développons en Java   2.30  
Copyright (C) 1999-2022 Jean-Michel DOUDOUX    (date de publication : 15/06/2022)

[ Précédent ] [ Sommaire ] [ Suivant ] [Télécharger ]      [Accueil ]

 

39. La gestion des accès concurrents

 

chapitre    3 9

 

Niveau : niveau 4 Supérieur 

 

Les différents threads d'une application peuvent accéder à toutes les données pour lesquelles ils possèdent une référence. Cela inclut des données partagées par ces différents threads : les différents threads peuvent lire et modifier ces données en parallèle et potentiellement en concurrence notamment sur des machines multicoeurs. Ces mises à jour peuvent laisser les données dans un état incohérent si celles-ci ne sont pas atomiques.

Lors de la parallélisation de traitements, il est fréquent de devoir gérer des accès concurrents à certaines données. Les deux points principaux à prendre en compte lors d'accès concurrents sont :

  • la visibilité : à quel moment une modification par un thread est visible par les autres ? C'est d'autant plus important que les opérations sont généralement faites sur des copies des valeurs (caches et registres CPU par exemple) avant d'être recopiées en mémoire
  • la cohérence : comment garantir que les données ne sont pas corrompues une fois les opérations concurrentes terminées ? Pour garantir la cohérence, une opération doit être atomique

L'accès concurrent pour la mise jour d'une même donnée par plusieurs threads est désigné par race condition. Pour empêcher les accès concurrents à une même donnée, il est possible d'utiliser un mécanisme de verrouillage qui va bloquer l'accès à une donnée le temps qu'un autre thread accède déjà à la donnée. Ainsi si plusieurs threads tentent d'accéder à une même donnée en même temps, ces accès se feront les uns après les autres.

Java propose deux mécanismes pour poser des verrous :

  • les moniteurs qui s'utilisent avec le mot clé synchronised
  • les verrous qui sont définis dans l'interface java.util.concurrent.locks.Lock

Chaque objet possède un moniteur (monitor). Il n'est pas possible d'utiliser directement le moniteur d'un objet : la façon de l'utiliser est de passer l'objet en paramètre de l'instruction synchronized() explicitement ou de l'utiliser implicitement quand il s'agit de synchroniser l'accès à l'objet courant.

L'utilisation du mot clé synchronized garantit l'atomicité du bloc de code mais aussi la visibilité des modifications effectuées dans ce bloc. S'il est utilisé, un bloc synchronized doit être mis en oeuvre pour la modification et la lecture d'une donnée partagée par plusieurs threads.

Si une classe possède plusieurs méthodes définies avec le mot clé synchronized, alors une seule de ces méthodes pourra être exécutée en même temps par plusieurs threads, même si chaque thread exécute une méthode différente.

Il est possible d'imbriquer dans le même bloc de code plusieurs instructions synchronized sur des moniteurs différents. Attention cependant, l'ajout de verrous pour gérer des cas de gestion d'accès concurrents peut augmenter, sous certaines circonstances, le risque d'introduire des situations de deadlocks.

Plusieurs règles devraient être appliquées lors de la mise en oeuvre de verrous :

  • un objet immuable est thread-safe : il est donc inutile d'utiliser un verrou
  • le verrou doit être maintenu pour une durée la plus courte possible
  • il ne faut utiliser le mot clé synchronized que sur les méthodes qui le requiert pour ne pas ajouter de la contention inutile

Une difficulté supplémentaire relative à l'utilisation de plusieurs threads concerne la visibilité par les autres threads des modifications faites par un thread.

Le code Java n'est pas exécuté directement : il est transformé par le compilateur en un langage intermédiaire : le bytecode. Ce bytecode est lui-même interprété, voire compilé en code natif par le compilateur JIT de la JVM. La JVM effectue des optimisations avancées qui peuvent réordonner les opérations des traitements, stocker des données en cache, ... Ceci implique qu'il n'y par défaut aucune garantie sur la visibilité des modifications.

Le mot clé volatile peut être utilisé pour garantir cette visibilité : il ne garantit cependant rien concernant l'atomicité. Le mot clé volatile peut être utilisé sur des champs dont les opérations sont atomiques : par exemple un champ de type booléen qui est lu et simplement modifié sans tenir compte de sa valeur courante.

Ce chapitre contient plusieurs sections :

 

39.1. Le mot clé volatile

L'utilisation du mot clé volatile force l'écriture de la valeur d'une variable en mémoire ainsi que sa relecture : cela permet de garantir que la lecture de la donnée par un thread retournera la valeur la plus récente en mémoire. Le mot clé volatile ne réalise aucune opération pour garantir la gestion des accès concurrents : elle offre juste une garantie sur la visibilité.

Le mot clé volatile s'utilise sur la déclaration d'une variable. Il n'est pas possible de l'utiliser sur une méthode ou une classe.

Les processeurs multicoeurs utilisent massivement des caches de plusieurs niveaux pour améliorer leur performance.

Dans une application multithread, les threads qui utilisent des variables non volatiles peuvent les copier de la mémoire centrale dans un cache CPU lorsqu'ils les utilisent pour améliorer les performances. Sur des machines multicoeurs, les threads peuvent s'exécuter sur différents coeurs : dans ce cas, plusieurs copies de la variable peuvent exister dans les caches de ces coeurs.

Si la donnée est partagée par plusieurs threads, sa valeur peut être dupliquée dans différents caches des processeurs qui exécutent les threads et y être modifiée. Il y a donc un risque que les valeurs utilisées par les différents threads soient différentes à un instant donné.

Si la variable n'est pas volatile, il n'y a aucune garantie sur les moments où la valeur sera lue de la mémoire pour être mise en cache ou encore lue du cache pour être écrite en mémoire.

La déclaration d'une variable avec le mot clé volatile permet de garantir que chaque lecture se fera de la mémoire centrale et que chaque écriture se fera dans celle-ci. L'intérêt est donc de permettre à chaque thread d'obtenir la valeur la plus fraîche possible.

Attention : le mot clé volatile ne garantit pas la gestion des accès concurrents mais uniquement une meilleure fraicheur de la valeur de la variable. Cependant, dans certains cas, son utilisation peut permettre de s'assurer que la lecture de sa valeur par plusieurs threads est la plus fraîche possible.

Si un seul thread modifie la valeur d'une variable volatile alors les autres threads ont la garantie que lors de leurs lectures, c'est la dernière valeur écrite qui sera lue. Si la variable n'est pas volatile, cette garantie n'est pas assurée.

Si plusieurs threads peuvent lire et écrire une variable partagée alors l'utilisation du mot clé volatile n'est pas suffisante, par exemple :

  • la valeur de la variable volatile est 0
  • le thread1 lit la variable et met sa valeur dans le cache du CPU1
  • le thread2 lit la variable et met sa valeur dans le cache du CPU2
  • le thread1 incrémente la valeur dans le cache et écrit la valeur en mémoire
  • le thread2 incrémente la valeur dans le cache et écrit la valeur en mémoire

Au final, bien que chaque thread ait incrémenté la valeur, celle en mémoire est 1 alors qu'elle devrait être 2. Il faut impérativement dans ce cas utiliser un mécanisme de synchronisation pour s'assurer que ces opérations soient atomiques.

Depuis Java 5 qui applique le nouveau modèle de gestion de la mémoire, le mot clé volatile offre des garanties supplémentaires en plus de la lecture/écriture de/vers la mémoire :

  • si un thread modifie une variable volatile alors toutes les autres variables visibles du thread seront aussi visibles par les autres threads lorsqu'ils effectueront une lecture sur la variable volatile en application de la notion d'happens before
  • les opérations de lecture et d'écriture d'une variable volatile ne peuvent pas être réordonnées par la JVM pour optimiser l'exécution et améliorer les performances

Voici un exemple pour illustrer un cas : l'instance monObjet est partagée par plusieurs threads. Sa classe contient deux variables :

  • varVolatile de type booléen, déclarée volatile, initialisée à false
  • varNonVolatile de type int, initialisée à 0

Deux threads effectuent des opérations sur cette instance :

Thread-1

Thread-2

monObjet.varNonVolatile = 123;
monObjet.varVolatile = true;

 
 

if (monObjet.varVolatile) {
int valeur = monObjet.varNonVolatile ;
}


Il est tout à fait possible que cela fonctionne dans une majorité de cas sans le mot clé volatile mais avec le mot clé volatile, ce comportement est systématiquement garanti.

En application de la relation happens-before :

  • dans le thread-1, comme la variable varNonVolatile est modifiée avant la variable volatile varVolatile alors l'écriture de la valeur des deux variables est faite en mémoire
  • dans le thread-2, comme la variable volatile varVolatile est lue de la mémoire alors la variable varNonVolatile est lue aussi de la mémoire

Ceci permet de garantir que les modifications faites par Thread-1 sont vues par Thread-2.

Cette relation nommée happens-before définie dans le JMM garantit que l'état des variables modifiées par Thread-1 avant l'écriture de la variable volatile sera vu par le Thread-2 dès que celui-ci aura lu la variable volatile. Cette visibilité s'applique aussi aux modifications faites par d'autres threads avant leur écriture de la variable varVolatile.

Si la variable varVolatile n'est pas déclarée volatile, alors il n'y aucun moyen sûr pour Thread-2 de voir les modifications faites par Thread-1 notamment celle concernant la variable non volatile.

L'utilisation du mot clé volatile n'est pas sans conséquence sur les performances : les lectures/écritures dans la mémoire sont moins rapides que dans les caches CPU. De plus, le mot clé volatile force la mise en oeuvre de ces opérations de lectures/écritures dans la mémoire et peut inhiber certaines optimisations. Il ne faut donc pas abuser du mot clé volatile mais l'utiliser à bon escient pour renforcer la visibilité d'une variable.

Il existe plusieurs cas où l'utilisation du mot clé volatile est fortement recommandée voire obligatoire pour s'éviter des ennuis notamment lors de :

  • la déclaration d'un booléen qui permet de sortir de la boucle d'exécution d'un thread
Exemple ( code Java 6 ) :
package fr.jmdoudoux.dej.thread;
 
public class MonThread extends Thread {
 
  private volatile boolean running = true;
 
  public void arreter() {
    this.running = false;
  }
 
  @Override
  public void run() {
    while (running) {
      // traitements du thread

      try {
        Thread.sleep(500);
      } catch (InterruptedException ex) {
        ex.printStackTrace();
      }
    }
  }
}
  • la déclaration d'une variable de type long ou double partagée

Comme précisé dans le chapitre « threads and locks » de la JLS (Java Language Specification), l'écriture de la valeur d'une variable de type double ou long n'est pas atomique. Ces valeurs sont stockées sur 64 bits et requièrent deux opérations d'écriture pour les deux moitiés de 32 bits. Il est possible qu'un thread fasse une lecture entre les deux opérations qui ne sont pas atomiques et obtiennent potentiellement une valeur erronée.

Les lectures et les écritures de variables de type long et double déclarées volatile sont toujours atomiques. Il est donc fortement recommandé de déclarer volatile les variables partagées de type double et long.

Remarque : les lectures et les écritures de références, qu'elles soient implémentées sur 32 ou 64 bits sont toujours atomiques.

Lors de l'utilisation du mot clé volatile sur un tableau, cela définit une référence volatile sur un tableau et non pas une référence sur un tableau de variables volatile. Si le mot clé volatile est utilisé sur un tableau, c'est la lecture et la modification de la référence à ce tableau qui est volatile. Lors de la lecture d'un élément du tableau, la lecture de la référence du tableau est volatile mais pas la lecture de la valeur de l'élément concerné. Lors de la modification d'un élément du tableau, la lecture de la référence du tableau est volatile mais pas la modification de la valeur de l'élément. Il n'est pas possible de déclarer volatile les éléments d'un tableau. Ainsi, la modification de la valeur d'un élément du tableau n'a pas les garanties offertes par le mot clé volatile.

Le langage ne permet pas d'avoir les garanties offertes par le mot clé volatile sur les éléments d'un tableau. Pour obtenir ces garanties, il faut utiliser les classes AtomicIntegerArray, AtomicLongArray et AtomicReferenceArray du package java.util.concurrent. Elles offrent une sémantique similaire à volatile lors des opérations de lecture/écriture sur les éléments du tableau.

Comme le mot clé volatile n'implique pas l'utilisation de verrous, sa mise en oeuvre est généralement plus performante. Par contre, si un champ volatile est très fréquemment utilisé dans une méthode, les performances peuvent être moins bonnes que d'utiliser un verrou.

L'utilisation d'un champ volatile se justifie pleinement lorsqu'il est admis qu'un seul thread peut mettre à jour le champ pendant que d'autres peuvent le lire.

 

39.2. Les races conditions

Dans une application multithread, un des problèmes les plus courants rencontrés est la race condition.

Une race condition est une situation où au moins deux threads exécutent la même portion de code sans qu'aucune mesure de synchronisation de ces accès ne soit faite. Ceci peut engendrer des comportements inattendus liés à l'exécution de traitements en concurrence par plusieurs threads :

  • tenter de modifier une donnée partagée de manière concurrente
  • obtenir des résultats incohérents

Une race condition est une situation risquée dans laquelle plusieurs threads effectuent des lectures et des écritures sur une donnée partagée. L'ordre imprévisible des ces opérations peut induire ces comportements inattendus et aléatoires.

Exemple :
package fr.jmdoudoux.dej.thread;

public class MaClasseAvecRaceCondition {
  private volatile boolean flag = true;

  public void executer() {
    flag = true;
    if (flag != true) {
      System.out.println("arffff");
    }
    flag = false;
  }
}

L'invocation de la méthode executer() par un seul thread ne permettra jamais de voir afficher le message dans la console.

Par contre, si la méthode executer() d'une même instance partagée est invoquée par plusieurs il y a un risque selon l'ordonnancement des opérations de voir le message s'afficher. C'est notamment le cas si un thread modifie la valeur du flag à false entre son affectation à true par le thread courant et le test de sa valeur.

Exemple :
package fr.jmdoudoux.dej.thread;

public class MaClasseAvecRaceCondition {
  private volatile boolean flag = true;

  public void executer() {
    flag = true;
    if (flag != true) {
      System.out.println("arffff");
    }
    flag = false;
  }

  public static void main(String[] args) {
    final MaClasseAvecRaceCondition maClasse = new MaClasseAvecRaceCondition();

    for (int i = 0; i < 10; i++) {
      Thread thread = new Thread(new Runnable() {

        @Override
        public void run() {
          for (int i = 0; i < 10000; i++) {
            maClasse.executer();
          }
        }
      });
      thread.setName("monThread-" + i);
      thread.start();
    }
  }
}

A l'exécution de cet exemple, le message est affiché un nombre aléatoire de fois.

Pour garantir la bonne exécution, il est nécessaire de garantir l'ordre des opérations par exemple en garantissant que les opérations de la méthode executer() soient atomiques.

Un autre exemple classique de race condition est l'incrémentation d'un compteur par plusieurs threads : comme l'opérateur d'incrémentation n'est pas atomique et nécessite plusieurs opérations, il est possible que ces opérations exécutées par plusieurs threads s'intercroisent. Pour simplifier les exemples ci-dessous, l'opérateur ++ sera composé de trois opérations :

  • lecture de la valeur en mémoire
  • incrémentation de la valeur
  • mise à jour de la valeur en mémoire

Imaginons que deux threads tentent d'incrémenter le compteur de manière concomitante.

Sans mesure particulière, l'ordre d'exécution des opérations n'est pas garanti, par exemple :

Thread 1

Thread2

Valeur dans le registre 1

Valeur dans le registre 2

Valeur en mémoire

       

1

Lecture

 

1

1

1

 

Lecture

1

1

1

Incrémentation

 

2

2

1

Incrémentation

Incrémentation

2

2

1

Mise à jour

     

2

 

Mise à jour

   

2


D'autant moins s'il y a plusieurs coeurs ou plusieurs processeurs sur la machine d'exécution.

Thread 1

Thread2

Valeur dans le registre 1

Valeur dans le registre 2

Valeur en mémoire

       

1

Lecture

Lecture

1

1

1

Incrémentation

Incrémentation

2

2

1

Mise à jour

Mise à jour

   

2


Dans ce cas, il y a un risque car avec une valeur initiale de 1, l'incrémentation par deux threads devrait permettre d'avoir la valeur 3.

Pour permettre d'avoir le résultat attendu, il est nécessaire que les opérations liées à l'incrémentation soient atomiques : toutes les opérations doivent s'exécuter de manière atomique.

Thread 1

Thread2

Valeur dans le registre 1

Valeur dans le registre 2

Valeur en mémoire

       

1

Lecture

 

1

 

1

Incrémentation

 

2

 

1

Mise à jour

     

2

 

Lecture

2

 

2

 

Incrémentation

3

 

2

 

Mise à jour

   

3


Remarque : Une opération atomique ne peut pas entraîner de situation de race condition.

 

39.2.1. La détection des race conditions

Généralement, il ne faut pas tirer de conclusion relative à la gestion des accès concurrents basée sur des expérimentations. Cela est dû au fait qu'il y a de nombreux points, pour certains aléatoires ou variables, qui interviennent dans l'apparition d'un problème d'accès concurrent : ordonnancement des instructions exécutées, version de la JVM, plateforme utilisée, ... Le fait de ne pas voir de problèmes lors des expérimentations ne prouve pas que ceux-ci ne puissent pas survenir.

La découverte de situations de race conditions grâce à des tests unitaires n'est donc pas garantie à cause de la nature aléatoire du déclenchement de cette situation. La meilleure façon de découvrir des cas potentiels de race condition est de faire de la revue de code. La découverte de race condition n'est pas triviale car elle requiert d'avoir une bonne connaissance des mécanismes sous-jacents de la programmation concurrente.

La première approche pour détecter des race conditions est de faire de la relecture de code : ce n'est cependant pas facile car il n'est pas naturel de raisonner de manière concurrente. Il est aussi nécessaire d'occulter certaines présomptions. Par exemple, une ligne de code n'est pas forcément atomique : un bon exemple est l'opérateur d'incrémentation ++.

Plusieurs patterns peuvent être à l'origine d'une race condition :

  • Check and act :

Exemple : la méthode getInstance() d'une classe de type Singleton dont les traitements vérifient si l'instance est null pour déterminer s'il faut en créer une nouvelle instance.

Exemple :
public MonSingleton getInstance(){
  if( instance == null){   // race condition si deux 

    // threads testent cette condition en même temps

    instance = new MonSingleton();
  }
}

Le but de cette méthode est de toujours renvoyer la même instance. Or sans précaution particulière, si deux threads invoquent la méthode en même temps, il est possible que chacun obtiennent une nouvelle instance après avoir vérifié que l'instance n'existe pas encore.

Cette situation peut aussi survenir lors de la combinaison de deux opérations atomiques. Le fait qu'elles soient chacune atomique n'empêche pas que leur combinaison ne l'est pas.

Exemple :
if(!monHashMap.contains(key)){  // race condition

  monHashMap.put(key, value);
}

Deux threads peuvent exécuter la méthode contains() en même temps et obtenir false tous les deux impliquant que tous les deux vont exécuter la méthode put().

  • Read modify write : ce pattern implique que les opérations de lecture/modification/écriture ne sont pas atomiques.

Exemple : un compteur qui utilise l'opérateur d'incrémentation. Cela fonctionne parfaitement en monothread mais cela ne fonctionne pas en multithread car l'opérateur d'incrémentation n'est pas une opération atomique.

Cependant cela n'est pas toujours suffisant car, la JVM elle-même peut se permettre à des fins d'optimisations de réordonner des instructions. Pour limiter les effets de bord, les blocs de code synchronized sont exclus de ce type d'optimisation.

Différents outils open source ou commerciaux peuvent aider à détecter des cas de race condition dans le code en réalisant une analyse statique ou dynamique.

 

39.3. La synchronisation avec les verrous

L'exécution simultanée par plusieurs threads d'une même portion de code peut engendrer des problèmes d'accès concurrents sur certains objets. La synchronisation est un mécanisme qui permet de limiter l'exécution d'une portion de code à un seul thread.

Pour gérer les cas de race condition, il faut utiliser des mécanismes de verrouillage qui vont restreindre l'exécution d'une portion de code critique à un seul thread. Ceci permet d'inhiber les accès concurrents qui sont réalisés dans cette portion de code.

La synchronisation n'est nécessaire que pour des données mutables.

Java propose deux types de verrous :

  • les moniteurs : c'est un mécanisme implicite dont la mise en oeuvre est intégrée au langage et à la JVM
  • les Locks : c'est un mécanisme explicite dont la mise en oeuvre utilise des classes de l'API du JDK

 

39.3.1. Les verrous avec des moniteurs

La synchronisation permet de protéger l'exécution de portions de code critiques lorsque celle-ci est faite par plusieurs threads. Sans synchronisation, les accès concurrents à des données partagées par plusieurs threads engendrent des inconsistances sur ces données.

Pour mettre en oeuvre cette synchronisation, il faut un thread et un moniteur. En Java, le thread est toujours le thread courant. Le moniteur est précisé en utilisant l'instance d'un objet concerné. Un moniteur permet la mise en oeuvre de verrous implicites.

Chaque objet Java possède un moniteur qui permet de réaliser des opérations basiques de synchronisation d'accès entre threads : il n'est pas possible d'accéder directement à un moniteur.

La synchronisation en Java garantit que deux threads ne peuvent exécuter en parallèle une même portion de code synchronisée qui requiert le même verrou.

La synchronisation en Java se fait obligatoirement sur une portion de code :

  • une méthode statique
  • une méthode non statique
  • un bloc de code

 

39.3.1.0.1. Le mot clé synchronized

Le mot clé synchronized permet de poser un verrou exclusif sur une portion de code : ceci permet de garantir que les accès à une ressource partagée ne se feront pas en concurrence.

La JVM garantit qu'un bloc de code déclaré synchronized ne sera exécuté que par un seul thread à un instant T sous réserve que le moniteur utilisé pour le verrou soit le même pour tous les threads.

L'utilisation du mot clé synchronized implique l'obtention d'un verrou avant l'exécution de la première instruction du bloc de code et sa libération une fois l'exécution du bloc de code terminée.

Lors de l'exécution d'une portion de code déclarée synchronized, le thread courant pose un verrou sur le moniteur d'un objet. Tant que le verrou est posé par un thread, les autres threads qui souhaitent exécuter la portion de code doivent attendre de pouvoir obtenir le verrou. A la fin de l'exécution de la portion de code, le verrou est libéré et peut ainsi être posé par un autre thread.

L'instruction synchronized permet de demander l'obtention exclusive du moniteur pour le thread courant. Les autres threads qui tenteront d'acquérir le moniteur devront attendre leur tour tant qu'un thread le possède déjà. Le thread conserve le moniteur jusqu'à la fin de l'exécution du bloc de code. Ce bloc de code est défini par la façon dont le mot clé synchronized est utilisé.

Le mot clé synchronized peut s'utiliser sur une méthode ou un bloc de code qu'ils soient static ou non. Le mot clé synchronized peut attendre en paramètre un objet dont le moniteur sera utilisé pour verrouiller le bloc de code concerné.

Lors de la compilation, le compilateur va utiliser les instructions du bytecode MonitorEnter et MonitorExit lors de la traduction de l'utilisation du mot clé synchronized. Le compilateur assure qu'une instruction MonitorExit sera toujours exécutée à la suite d'un MonitorEnter quel que soit le chemin des traitements (exécution jusqu'à la fin du bloc de code ou sortie prématurée en cas d'exception).

L'utilisation du mot clé synchronized peut aussi permettre d'éviter des bugs subtils liés aux possibilités du compilateur ou de la JVM d'effectuer des optimisations dans la génération et l'exécution du bytecode tels que changer l'ordonnancement des instructions ou utiliser un cache par exemple. Les blocs de code synchronized sont exclus de ces optimisations : l'ordre des traitements d'une portion de code déclarée synchronized ne sera pas modifié par le compilateur.

 

39.3.1.1. Les moniteurs

Le mot clé synchronized permet de garantir un accès exclusif à une portion de code pour un seul thread : le verrou est posé sur le moniteur d'une instance d'un objet. L'instance utilisé dépend de la façon dont le mot clé synchronized est utilisé dans le code.

Le verrou sur le moniteur peut se faire de deux manières :

  • une instance : cela permet de poser un verrou sur l'instance concernée
  • l'instance de type Class d'une classe : cela permet de poser un verrou quel que soit le nombre d'instances créés de la classe. Cette solution est à utiliser pour protéger des accès à des données static.

Le choix de l'un ou de l'autre dépend des besoins et de la façon dont le mot clé synchronized est utilisé.

Pour synchroniser toute une méthode, il suffit de définir la méthode avec le modificateur synchronized. Dans ce cas, le moniteur utilisé est l'instance courante (this).

Exemple :
  public synchronized void maMethode() {
    // ...

  }

Dans ce cas, le moniteur utilisé est l'instance de la classe elle-même. Le moniteur est acquis durant toute l'exécution de la méthode.

Pour synchroniser une méthode statique, il suffit de définir la méthode avec le modificateur synchronized. Dans ce cas, le moniteur utilisé est l'objet de type Class de la classe de la méthode.

Exemple :
  public static synchronized void maMethode() {
    // ...

  }

Attention : il est tout à fait possible qu'une méthode statique et une méthode non statique toutes les deux déclarées synchronized soient exécutées en même temps par deux threads différents puisque ce sont deux moniteurs différents qui sont utilisés.

Il est possible d'utiliser le mot clé synchronized sur un bloc de code. Dans ce cas, il faut préciser l'instance dont le moniteur sera utilisé. N'importe quel objet peut être utilisé comme moniteur. Il est possible d'utiliser l'instance courante.

Exemple :
  public void maMethode() {
    synchronized(this) {
      // ...

    }
  }

Pour une bonne séparation des rôles, il est préférable de déclarer un objet, généralement du type Object, avec les modificateurs private et final et de lui donner un nom de variable qui décrive son rôle, par exemple en le suffixant avec Monitor.

Exemple :
private final Object monMonitor = new Object();
  
  // ...

  
  public void maMethode() {
    synchronized(monMonitor) {
    // ...

  }
}

Il est possible d'utiliser la classe de type Class pour protéger des accès à des ressources static par exemple.

Exemple :
  public void maMethode() {
    synchronized(MaClasse.class) {
      // ...

    }
  }

Il est possible de préciser n'importe quelle instance comme moniteur à utiliser en paramètre du mot clé synchronized. Cela inclut aussi une variable locale ce qui est une très mauvaise idée car le moniteur utilisé doit être accessible par tous les threads qui tenteront d'exécuter la portion de code. A chaque invocation, une nouvelle instance sera créée et donc le moniteur utilisé sera différent pour chaque thread ce qui ne permettra pas de limiter l'exécution à un seul thread.

A l'entrée du bloc de code synchronized, le thread obtient le verrou sur le moniteur correspondant. Le verrou est libéré à la sortie de l'exécution du bloc de code. Cette libération est garantie par le compilateur et la JVM que la sortie intervienne à la fin de l'exécution du bloc de code ou qu'une exception soit levée durant son exécution.

Le mécanisme utilisant le mot clé synchronized est par nature réentrant : si une portion de code synchronized est exécutée et qu'elle requière l'exécution d'une autre portion de code avec le même moniteur alors le thread courant n'a pas besoin d'acquérir de nouveau le verrou puisqu'il le possède déjà.

Exemple :
public class MaClasse {

  public synchronized methodeA(){
    methodeB();
  }

  public synchronized methodeB(){
    // traitements

  }
}

Comme les deux méthodes utilisent le même moniteur, celui de l'instance courante, le thread peut sans problème invoquer la méthode methodeA() qui elle-même va invoquer la méthode methodeB(). Le verrou n'a pas besoin d'être obtenu de nouveau puisque le thread le possède déjà.

 

39.3.1.2. Les contraintes d'utilisation

Il n'est pas possible d'utiliser le mot clé synchronized sur des variables : une telle action provoque une erreur à la compilation.

Il n'est pas possible d'utiliser le mot clé synchronized sur un constructeur : le compilateur lève une erreur puisque l'objet en cours de création n'est pas encore accessible par les autres threads.

Exemple :
package fr.jmdoudoux.dej.thread;

public class TestSynchronized {

  public synchronized TestSynchronized() {
  }
}
Résultat :
C:\java\TestThreads\src>javac com/jmdoudoux/test/thread/TestSynchronized.java
com\jmdoudoux\test\thread\TestSynchronized.java:5: modifier synchronized not allowed here
  public synchronized TestSynchronized() {
                      ^
1 error

Si l'objet utilisé comme moniteur est null alors une exception de type NullPointerException est levée.

Attention : le mot clé synchronized ne peut être utilisé que pour limiter les accès à des ressources de la JVM dans laquelle le code s'exécute. Pour verrouiller les accès dans plusieurs JVM, il faut développer sa propre solution en utilisant une ressource commune comme un fichier ou une base de données ou utiliser une solution tierce comme Terracota.

 

39.3.1.3. Des recommandations d'utilisation

L'utilisation du mot clé synchronized sur plusieurs méthodes d'une même classe peut dégrader les performances. Ce mécanisme est lent et coûteux. Il est préférable d'utiliser le mot clé synchronized sur les portions de code critiques plutôt que sur toutes les méthodes. Ceci aura pour effet de limiter le temps ou le verrou est posé.

Il est donc généralement préférable d'effectuer la synchronisation sur un bloc de code plutôt que sur toute une méthode pour limiter la durée du verrou à la portion de code qui le requiert.

Il ne faut pas utiliser une instance qui ne soit pas déclarée final comme moniteur pour un bloc de code synchronized car il se pourrait que la référence à cet objet soit modifiée et qu'ainsi deux threads puissent exécuter la portion de code en concurrence puisque le verrou ne serait pas posé sur le même moniteur.

Exemple :
package fr.jmdoudoux.dej.thread;

public class TestSynchronized {

  // mauvaise pratique : le champ devrait etre declare final

  private Object verrou = new Object();

  public void maMethode() {
    synchronized (verrou) {
      // ...

    }
  }
}

Il n'est pas recommandé d'utiliser un objet de type String comme instance en paramètre du mot clé synchronized() car les objets de type String sont gérés en interne de la JVM dans un pool. Ainsi si ailleurs dans le code de l'application ou d'une bibliothèque tierce, la même chaîne est utilisée comme moniteur, alors le même moniteur sera utilisé dans des classes différentes alors qu'elles devraient utiliser des moniteurs distincts. Ceci pourrait entraîner une dégradation des performances subtiles à identifier.

Exemple :
package fr.jmdoudoux.dej.thread;

public class TestSynchronized {

  // mauvaise pratique

  private static final String verrou = "verrou";

  public void maMethode() {
    synchronized (verrou) {
      // ...

    }
  }
}

Il est préférable d'utiliser une instance dédiée de type Object.

Exemple :
package fr.jmdoudoux.dej.thread;

public class TestSynchronized {

  private static final Object verrou = new Object();

  public void maMethode() {
    synchronized (verrou) {
      // ...

    }
  }
}

Il est recommandé de synchroniser avec le même moniteur les accès en lecture et en écriture à une même donnée.

Exemple :
package fr.jmdoudoux.dej.thread;

public class MonCompteur {

  private final Object monitor = new Object();

  private int          valeur = 0;

  public int get() {
    synchronized (monitor) {
      return valeur;
    }
  }

  public int incrementer() {
    synchronized (monitor) {
      return ++valeur;
    }
  }
}

L'utilisation incorrecte du mot clé synchronized peut conduire à des situations de deadlock dans la JVM.

 

39.3.1.4. Les avantages et les inconvénients

L'utilisation des moniteurs possède plusieurs avantages :

  • ils sont faciles à utiliser
  • leur libération est automatiquement garantie par le compilateur et la JVM à la fin du bloc de code : il n'y a pas de risque d'oublier de le libérer

Mais elle présente aussi plusieurs inconvénients :

  • ils sont bloquants : par exemple, il est inutile de faire exécuter en boucle par plusieurs threads une même méthode déclarée synchronized. Il est plus performant de la faire exécuter par un seul thread car on s'épargne la gestion du moniteur.
  • la simplicité d'utilisation peut masquer des soucis
  • il n'est pas possible de vérifier l'état du moniteur avant de demander la pose d'un verrou
  • le niveau de verrouillage est le même que les traitements fassent de la lecture ou de l'écriture. Hors la lecture d'une donnée est thread-safe : synchronized peut donc introduire de la contention si plusieurs threads lisent en même temps
  • l'attente de l'obtention du verrou est potentiellement illimité : il n'est pas possible de préciser un timeout ni d'interrompre le thread qui attend le verrou. Cela peut engendrer sous certaines circonstances des deadlocks

Pour répondre à ces limitations, Java propose les classes de type Lock : ReadWriteLock et ReentrantLock

 

39.3.2. Les classes Lock et Condition

Java 5 propose de nouvelles fonctionnalités, regroupées dans le package java .util.concurrent.locks, pour gérer les accès concurrents grâce à des verrous.

Ces verrous reposent sur l'utilisation d'objets : il est donc nécessaire d'en créer une instance et d'invoquer des méthodes, ce qui rend le code à produire plus verbeux par rapport au mot clé synchronized qui est intégré dans le langage.

L'interface java.util.concurrent.locks.Lock définit les fonctionnalités d'un mécanisme de verrous permettant de contrôler l'accès par plusieurs threads à une portion de code.

 

39.3.2.1. L'interface Lock

Un Lock est un mécanisme de verrou qui permet un accès exclusif à une portion de code par un seul thread. L'utilisation d'un objet de type Lock permet de mettre en place des mécanismes de synchronisation similaires à ceux proposés par le mot clé synchronized mais avec la possibilité d'utiliser des fonctionnalités avancées.

Le grand avantage d'utiliser un Lock est sa flexibilité pour obtenir ou non un verrou sans que cela soit obligatoirement bloquant comme dans le cas de l'utilisation du mot clé synchronized. L'utilisation d'un Lock est donc plus souple que l'utilisation du mot clé synchronized :

  • attente bloquante, non bloquante avec prise en compte possible de l'interruption du thread ou avec timeout, ...
  • distinguer les accès concurrents en lecture et mise à jour
  • support de conditions
  • les verrous peuvent être acquis et libérés dans n'importe quel ordre

Elle définit plusieurs méthodes :

Méthode

Rôle

void lock()

Obtenir le verrou : attente indéfinie si celui-ci est déjà pris

void lockInterruptibly()

Obtenir le verrou : attente jusqu'à son obtention ou si le thread courant est interrompu

Condition newCondition()

Obtenir une instance de type Condition associée à l'instance

boolean tryLock()

Obtenir le verrou immédiatement : pas d'attente. Elle renvoie un booléen qui indique si le verrou est obtenu

boolean tryLock(long time, TimeUnit unit)

Obtenir le verrou : attente maximale pour la durée précisée en paramètre ou si le thread courant est interrompu

void unlock()

Libérer le verrou


Le JDK fournit trois implémentations de l'interface Lock :

  • ReentrantLock
  • ReentrantReadWriteLock.ReadLock
  • ReentrantReadWriteLock.WriteLock

De manière générale, un Lock s'utilise avec plusieurs opérations :

  • créer une instance de type Lock
  • en début de section critique, poser le verrou en invoquant la méthode lock() sur l'instance
  • en fin de section critique, libérer le verrou en invoquant la méthode unlock()

La liberté d'acquérir et de libérer un verrou à sa guise implique qu'il est de la responsabilité du développeur de s'assurer que quoi qu'il arrive le verrou sera libéré. Le plus simple est d'exécuter la section critique dans un bloc try et invoquer la méthode unlock() du Lock dans le bloc finally correspondant.

Exemple ( code Java 5.0 ) :
     Lock verrou = new ReentrantLock();
     verrou.lock();
     try {
       // section critique protegee par le verrou

     } finally {
       verrou.unlock();
     }

Toutes les implémentations de Lock doivent appliquer la même sémantique de synchronisation de la mémoire telle qu'appliquée par le verrouillage intégré utilisant les moniteurs. Les variables partagées utilisées pendant que le verrou est posé n'ont donc pas besoin d'être définies avec le mot clé volatile car l'utilisation d'un Lock doit offrir les mêmes garanties de visibilité des modifications des variables que le mot clé synchronized.

L'implémentation des différentes formes d'acquisitions du verrou n'a pas de contraintes concernant :

  • un support identique de la sémantiques et des garanties offertes
  • le support de l'interruption de l'attente du verrou
  • l'ordre d'acquisition du verrou par différents threads
  • la performance

Chaque implémentation devrait décrire dans sa documentation la sémantique et les garanties offertes par chaque méthode.

Les blocs de code synchronized n'offrent aucune garantie sur l'ordre d'acquisition du verrou par les threads qui sont en son attente. Si de nombreux threads tentent constamment d'obtenir le verrou, il est possible qu'un ou plusieurs threads n'arrivent pas à l'obtenir. Ce phénomène est nommé starvation. Pour l'éviter, une implémentation de type Lock peut prendre en compte le support de l'équité (fairness).

Les instances de Lock sont des objets Java : il est donc possible d'utiliser leur moniteur avec le mot clé synchronized. Dans ce cas, il n'y a aucun lien entre l'obtention du verrou lors de l'invocation de la méthode lock() et l'obtention du verrou sur son moniteur par le mot clé synchronized. Pour éviter toute confusion, il est préférable d'éviter d'utiliser une instance de Lock comme moniteur pour une instruction synchronized.

 

39.3.2.2. La classe ReentrantLock

La classe ReentrantLock est une implémentation de l'interface Lock qui permet d'utiliser des verrous de manière réentrante.

Le verrou est obtenu par un thread si aucun autre thread ne le possède ou si le verrou est déjà détenu par le thread lui-même.

Elle possède plusieurs méthodes :

Méthode

Rôle

int getHoldCount()

Obtenir le nombre d'obtentions du verrou par le thread courant

protected Thread getOwner()

Renvoyer le thread qui possède le verrou ou null si aucun ne le possède

protected Collection<Thread> getQueuedThreads()

Renvoyer une collection des threads qui sont potentiellement en attente du verrou

int getQueueLength()

Renvoyer un nombre estimé de threads qui sont en attente du verrou

protected Collection<Thread> getWaitingThreads(Condition condition)

Renvoyer une collection des threads qui sont potentiellement en attente de la Condition passée en paramètre

int getWaitQueueLength(Condition condition)

Renvoyer un nombre estimé de threads qui sont en attente de la Condition passée en paramètre

boolean hasQueuedThread(Thread thread)

Vérifier si le thread passé en paramètre est en attente de l'obtention du verrou

boolean hasQueuedThreads()

Vérifier si au moins un thread est en attente de l'obtention du verrou

boolean hasWaiters(Condition condition)

Vérifier si au moins un thread est en attente de la Condition passée en paramètre

boolean isFair()

Renvoyer un booléen qui précise si les demandes d'obtention du verrou sont gérée de manière équitable

boolean isHeldByCurrentThread()

Renvoyer un booléen qui précise si le thread courant possède le verrou

boolean isLocked()

Renvoyer un booléen qui précise le verrou est détenu par un thread

void lock()

Obtenir le verrou : attente indéfinie si celui-ci est déjà pris

void lockInterruptibly()

Obtenir le verrou : attente jusqu'à son obtention ou si le thread courant est interrompu

Condition newCondition()

Obtenir une instance de type Condition associé à l'instance

boolean tryLock()

Obtenir le verrou immédiatement : pas d'attente. Elle renvoie un booléen qui indique si le verrou est obtenu

boolean tryLock(long timeout, TimeUnit unit)

Obtenir le verrou : attente maximale pour la durée précisée en paramètre ou si le thread courant est interrompu

void unlock()

Libérer le verrou


Certaines méthodes sont protected : leur utilisation est à réserver pour de l'instrumentation ou du monitoring.

La classe ReentrantLock utilise en interne des opérations de type CAS et des variables atomiques ou volatiles pour limiter les temps de contention.

Exemple ( code Java 5.0 ) :
package fr.jmdoudoux.dej.thread;
 
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
 
public class TestReentrantLock {
 
  private final Lock verrou = new ReentrantLock();
 
  public void methodeA() throws InterruptedException {
    verrou.lock();
    try {
      System.out.println("MethodeA : " + Thread.currentThread().getName());
      Thread.sleep(2000);
      methodeB();
      Thread.sleep(5000);
    } finally {
      verrou.unlock();
    }
  }
 
  public void methodeB() {
    verrou.lock();
    try {
      System.out.println("MethodeB : " + Thread.currentThread().getName());
    } finally {
      verrou.unlock();
    }
  }
 
  public static void main(String[] args) {
 
    final TestReentrantLock sut = new TestReentrantLock();
 
    Thread[] threads = new Thread[2];
 
    threads[0] = new Thread(new Runnable() {
      @Override
      public void run() {
        try {
          System.out.println("Debut thread 0");
          sut.methodeA();
          System.out.println("fin thread 0");
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
      }
    }, "Thread 0");
 
    threads[1] = new Thread(new Runnable() {
      @Override
      public void run() {
        System.out.println("Debut thread 1");
        sut.methodeB();
        System.out.println("Fin thread 1");
      }
    }, "Thread 1");
 
    threads[0].start();
    threads[1].start();
 
  }
}
Résultat :
Debut thread 0
MethodeA : Thread 0
Debut thread 1
MethodeB : Thread 0
fin thread 0
MethodeB : Thread 1
Fin thread 1

L'implémentation de la classe ReentrantLock permet la pose d'un verrou de manière réentrante.

Exemple ( code Java 5.0 ) :
package fr.jmdoudoux.dej.thread;
 
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
 
public class TestReentrantLock {
 
  private final Lock lock = new ReentrantLock();
 
  public void methodeA() {
    lock.lock();
 
    try {
      System.out.println("MethodeA");
    } finally {
      lock.unlock();
    }
  }
 
  public void methodeB() {
    lock.lock();
    try {
      System.out.println("MethodeB");
    } finally {
      lock.unlock();
    }
  }
}

Ce code est équivalent à celui ci-dessous qui utilise le mot clé synchronized.

Exemple ( code Java 5.0 ) :
package fr.jmdoudoux.dej.thread;
      
public class TestReentrantLock {
  private final Object lock = new Object();
  public void methodeA() {
    synchronized(lock) {
      System.out.println("MethodeA");
    }
  }
  
  public void methodeB() {
    synchronized(lock) {
      System.out.println("MethodeB");
    }
  }
}

Dans un contexte simple, le code utilisant un objet de type Lock est beaucoup plus verbeux et plus risqué puisque la libération du verrou est à la charge du développeur. Cependant, l'utilisation d'un Lock offre des fonctionnalités avancées qui permettent entre autre de pallier certaines contraintes liées à l'utilisation du mot clé synchronized.

La classe ReentrantLock possède deux constructeurs :

Constructeur

Rôle

ReentrantLock()

Constructeur par défaut

ReentrantLock(boolean)

Constructeur qui permet de préciser si le verrou doit prendre en compte l'équité envers les threads qui tentent de poser le verrou


Si le paramètre vaut true, alors le verrou sera prioritairement donné au thread qui l'attend depuis le plus longtemps. Par défaut, aucun ordre n'est garanti quant à l'obtention du verrou par les threads qui attendent de l'avoir.

Le support de l'équité est plus coûteux : il ne faut l'utiliser que lorsque les circonstances l'obligent, généralement sous très fortes conditions de charge. La méthode tryLock() ne prend pas en compte l'équité.

La classe ReentrantLock est Serializable. Attention cependant, lorsqu'une instance est désérialisée, le verrou est libre même si celui-ci était détenu par un thread au moment de la sérialisation de l'objet.

La classe supporte un maximum de Integer.MAX appels réentrants : si cette limite est dépassée, une exception de type java.lang.Error avec le message «Maximum lock count exceeded» est levée.

L'utilisation de verrous dans une application multithread peut entrainer dans certaines circonstances des situations d'interblocage entre au moins deux threads. Cette situation nommée deadlock est dramatique car pour en sortir la seule solution est de relancer la JVM.

Le mot clé synchronized ne propose rien pour éviter cette situation lorsque les conditions sont réunies pour quelle survienne car l'utilisation du mot clé synchronised est bloquante jusqu'à l'obtention du verrou.

Pour tenter de pallier cette problématique, l'interface Lock propose la méthode tryLock().

Une surcharge de la méthode tryLock() de l'interface Lock qui attend en paramètre un entier long pour la quantité et un TimeUnit qui précise l'unité temporelle permet de tenter d'obtenir le verrou avant un certain timeout. Elle renvoie un booléen qui précise si le verrou a été obtenu avant le timeout. Il est alors de la responsabilité de l'appelant de gérer le cas où le verrou n'est pas obtenu par exemple en faisant un nombre limité de nouvelles tentatives éventuellement après un certain délai d'attente.

Remarque : la méthode tryLock() ne tient jamais compte du fait que le ReentrantLock est configuré pour être équitable. Elle tente d'acquérir le verrou immédiatement sans tenir compte du fait que d'autres threads sont déjà en attente pour l'obtention du verrou. Si l'équitabilité doit être respectée alors il faut utiliser la surcharge de la méthode tryLock() en lui passant en paramètre 0 et TimeUnit.SECONDS.

Cette surcharge prend en compte :

  • l'équitabilité si l'instance est configurée pour être équitable
  • la détection d'une interruption

La méthode tryLock() tente d'obtenir immédiatement le verrou :

  • si elle obtient le verrou alors elle initialise le compteur holdCount à 1 et renvoie true
  • si le verrou est déjà détenu par l'instance alors elle incrémente le compteur holdCount et renvoie true
  • sinon elle renvoie false

L'utilisation d'un Lock offre une plus grande souplesse dans l'obtention et la libération du verrou. Il est possible d'acquérir le verrou dans une méthode et de le libérer dans une autre. Cependant cette liberté est dangereuse car le développeur doit s'assurer que le verrou est correctement libéré dans tous les cas, sinon le verrou restera indéfiniment posé et ne pourra plus être obtenu par d'autres threads. Aucun contrôle n'est effectué par le compilateur.

L'utilisation de Lock peut permettre d'améliorer les performances et de diminuer les situations de deadlocks. Leur mise en oeuvre est cependant plus verbeuse et plus risquée que d'utiliser le mot clé synchronized.

 

39.3.2.3. L'interface Condition

Une instance de type Condition permet de mettre en attente un thread jusqu'à ce qu'il reçoive une notification lorsque la condition est remplie.

Lorsqu'un objet de type Lock est utilisé pour poser un verrou à la place du mot clé synchronized, alors un objet de type Condition est utilisé à la place des méthodes wait(), notify() et notifyAll().

Remarque : une implémentation de l'interface Condition peut avoir une sémantique et un comportement différents de celui des méthodes de la classe Object liées au moniteur.

Elle définit plusieurs méthodes :

Méthode

Rôle

void await()

Le thread courant suspend son exécution et attend de recevoir un signal ou d'être interrompu

boolean await(long time, TimeUnit unit)

Le thread courant suspend son exécution et attend de recevoir un signal ou d'être interrompu ou que le timeout précisé en paramètre soit atteint

long awaitNanos(long nanosTimeout)

Le thread courant suspend son exécution et attend de recevoir un signal ou d'être interrompu ou que le timeout précisé en paramètre soit atteint

void awaitUninterruptibly()

Le thread courant suspend son exécution et attend de recevoir un signal : il ne peut pas être interrompu

boolean awaitUntil(Date deadline)

Le thread courant suspend son exécution et attend de recevoir un signal ou d'être interrompu ou que la date/heure limite précisée en paramètre soit atteinte

void signal()

Envoyer un signal à un thread qui est en attente d'un signal de cette condition

void signalAll()

Envoyer un signal à tous les threads qui sont en attentes d'un signal de cette condition


La méthode await() et ses surcharges permettent de mettre le thread courant en attente d'un signal. Le fonctionnement de la méthode await() est similaire à celui de la méthode wait() de la classe Object qui met le thread courant en attente et libère le lock. Cette attente dure jusqu'à ce qu'un autre thread invoque la méthode signal() ou signalAll() sur l'instance de type Condition ou interrompe le thread courant. Avant que la méthode ne se termine elle réacquiert le lock.

Les méthodes signal() et signalAll() permettent de notifier le ou les threads en attente du fait que la condition est atteinte.

Une instance de type Condition permet de suspendre l'exécution d'un thread jusqu'à ce qu'un autre thread lui envoie une notification. Comme cela implique un accès concurrent, une Condition est toujours associée à un Lock. Pour obtenir une nouvelle instance de type Condition, il faut invoquer la méthode newInstance() du Lock à laquelle elle sera liée.

Exemple ( code Java 5.0 ) :
package fr.jmdoudoux.dej.thread;
 
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.RejectedExecutionHandler;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
 
public class PausableThreadPoolExecutor extends ThreadPoolExecutor {
  private boolean       enPause;
  private ReentrantLock pauseLock = new ReentrantLock();
  private Condition     reactive  = pauseLock.newCondition();
 
  public PausableThreadPoolExecutor(int corePoolSize, int maximumPoolSize,
      long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue,
      RejectedExecutionHandler handler) {
    super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
        handler);
  }
 
  public PausableThreadPoolExecutor(int corePoolSize, int maximumPoolSize,
      long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue,
      ThreadFactory threadFactory, RejectedExecutionHandler handler) {
    super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
        threadFactory, handler);
  }
 
  public PausableThreadPoolExecutor(int corePoolSize, int maximumPoolSize,
      long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue,
      ThreadFactory threadFactory) {
    super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
        threadFactory);
  }
 
  public PausableThreadPoolExecutor(int corePoolSize, int maximumPoolSize,
      long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue) {
    super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
  }
 
  @Override
  protected void beforeExecute(Thread thread, Runnable runnable) {
    super.beforeExecute(thread, runnable);
    pauseLock.lock();
    try {
      while (enPause) {
        reactive.await();
      }
    } catch (InterruptedException ie) {
      thread.interrupt();
    } finally {
      pauseLock.unlock();
    }
  }
 
  public void pause() {
    pauseLock.lock();
    try {
      enPause = true;
    } finally {
      pauseLock.unlock();
    }
  }
 
  public void resume() {
    pauseLock.lock();
    try {
      enPause = false;
      reactive.signalAll();
    } finally {
      pauseLock.unlock();
    }
  }
}

L'exemple ci-dessus implémente un ThreadPoolExecutor qui peut être mis en pause. La mise en pause se fait à la fin de l'exécution de la tâche en cours ou juste avant l'exécution de la tâche suivante. La gestion de la mise en pause gère les accès concurrents en utilisant un verrou de type ReentrantLock. La mise en attente utilise une condition.

Le moniteur d'une instance de type Condition peut être utilisé avec le mot clé synchronized puisque c'est un objet comme un autre. Dans cas, il n'y pas de lien avec le Lock associé à l'instance ni avec ses méthodes await(), signal() et signalAll(). Il est cependant recommandé d'éviter cette pratique pour limiter les risques de confusion.

 

39.3.2.4. L'interface ReadWriteLock et la classe ReentrantReadWriteLock

L'un des inconvénients du mot clé synchronized est qu'il ne permet pas de différencier d'accès à la portion de code pour de la lecture uniquement. Quelque soit les traitements réalisés par la portion de code, un seul thread peut l'exécuter. Si cette portion de code ne fait que de la lecture, alors la scalabilité est restreinte.

Pour remedier à cela la classe java.util.concurrent.locks.ReentrantReadWriteLock permet de poser des verrous différenciés pour la lecture seule et pour les modifications. Son utilisation permet à plusieurs threads d'exécuter une portion de code qui ne fait que de la lecture mais n'autorise qu'un seul thread à exécuter une portion de code qui fait des mises à jour. Dans certaines situations de forte concurrence effectuant beaucoup de lectures, cela peut limiter la contention et donc améliorer les performances.

Plusieurs threads peuvent lire une ressource partagée sans poser de soucis d'accès concurrents. Ceux-ci surviennent dans deux circonstances :

  • lorsqu'une lecture et une modification surviennent de manière concurrente
  • lorsque plusieurs modifications surviennent de manière concurrente

Ces fonctionnalités sont définies dans l'interface java.util.concurrent.locks.ReadWriteLock.

Un ReadWriteLock est un type de Lock qui permet de distinguer un verrou en lecture et un autre en écriture. Ceci est particulièrement intéressant si les lectures sont plus nombreuses que les modifications. Plusieurs threads peuvent lire de manière concurrente une donnée tant que durant ces lectures, aucune modification n'est apportée. Par contre, si un thread veut effectuer une mise à jour il doit avoir un verrou exclusif et aucune lecture ne doit être en cours.

Un ReadWriteLock permet d'avoir plusieurs threads en lecture mais un seul en modification. Lors de son utilisation, plusieurs conditions doivent être remplies pour avoir le verrou :

  • en lecture : le verrou de modification ne doit pas être obtenu et aucune demande de pose du verrou en modification ne doit être en cours d'obtention. Plusieurs threads peuvent obtenir ce verrou
  • en modification : aucun verrou en lecture ou modification ne doit être posé. Un seul thread peut poser le verrou en modification

Les demandes de pose du verrou en modification doivent être prioritaires sur celles des verrous en lecture car partant du principe qu'il y a beaucoup plus de lecture que de modifications, il se pourrait que l'obtention de ses verrous soit long voir même impossible si les lectures sont ininterrompues.

L'interface ReadWriteLock ne définit que deux méthodes :

Méthode

Rôle

Lock readLock()

Obtenir l'instance de type Lock pour la lecture

Lock writeLock()

Obtenir l'instance de type Lock pour la modification


Le JDK fournit en standard une implémentation avec la classe ReentrantReadWriteLock.

L'implémentation de la classe ReentrantReadWriteLock utilise deux instances de type Lock : une pour la gestion des verrous en lecture (readLock) et une autre pour la gestion des verrous en modification (writeLock).

Lors de l'utilisation du mot clé synchronized, le verrou est automatiquement libéré à la sortie du bloc de code quelque soit les raisons de cette sortie, notamment si une exception est levée.

La lecture seule d'une donnée ne peut pas engendrer de problème d'accès concurrents tant que cette donnée n'est pas modifiée. La pose d'un verrou exclusif pour réaliser cette lecture est donc pénalisante et inutile tant que la donnée n'est pas modifiée. La modification de la donnée doit elle être faite avec un verrou exclusif et la lecture de la donnée par un autre thread doit être bloquée.

Exemple ( code Java 5.0 ) :
package fr.jmdoudoux.dej.thread;
 
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
 
public class MonCompteur {
  private long                valeur;
 
  private final ReadWriteLock verrouRW = new ReentrantReadWriteLock();
 
  public long get() {
    Lock verrouR = verrouRW.readLock();
    verrouR.lock();
    try {
      return valeur;
    } finally {
      verrouR.unlock();
    }
  }
 
  public long incrementer() {
    Lock verrouW = verrouRW.writeLock();
    verrouW.lock();
    try {
      return ++valeur;
    } finally {
      verrouW.unlock();
    }
  }
 
  public static void main(String[] args) {
    MonCompteur compteur = new MonCompteur();
    System.out.println(compteur.get());
    System.out.println(compteur.incrementer());
    System.out.println(compteur.incrementer());
  }
}

Remarque : cet exemple a plus une vocation pédagogique que pratique.

Il est préférable d'utiliser un ReentrantReadWriteLock plutôt que le mot clé synchronized pour avoir un contrôle plus précis du verrou et obtenir de meilleures performances surtout si les opérations concernent majoritairement des lectures.

La mise en oeuvre d'un ReadWriteLock est surtout intéressante si ce sont majoritairement des opérations en lecture seule qui sont effectuées : un cas d'utilisation classique est un cache.

Exemple ( code Java 5.0 ) :
package fr.jmdoudoux.dej.thread;
 
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
 
public class MonBean {
  private final ReentrantReadWriteLock rrwLock   = new ReentrantReadWriteLock();
  private final Lock                   readLock  = rrwLock.readLock();
  private final Lock                   writeLock = rrwLock.writeLock();
 
  private String                       valeur;
 
  public String getValeur() {
    readLock.lock();
    try {
      return valeur;
    } finally {
      readLock.unlock();
    }
  }
 
  public void setValeur(String Valeur) {
    writeLock.lock();
    try {
      this.valeur = Valeur;
    } finally {
      writeLock.unlock();
    }
  }
}

 

39.4. Les opérations atomiques

Une opération atomique est une opération qui ne peut pas être exécutée partiellement : toutes ses instructions ont la garantie d'être exécutées sans interruption.

En Java, seules les opérations de lecture et d'écriture d'une variable sont atomiques sauf si celles-ci sont de type long ou double.

Par exemple, l'opérateur ++ n'est pas atomique puisqu'il requiert au moins trois opérations (en réalité, il en faut plus) :

  • la lecture en mémoire de la valeur courante
  • son incrémentation
  • l'écriture en mémoire de la nouvelle valeur

Il est possible que deux threads réalisent la lecture en même temps puis l'incrémentation. Dans ce cas, la valeur ne sera incrémentée qu'une seule fois puisque la lecture par les deux threads donne la même valeur. Si cette incrémentation doit permettre d'obtenir une valeur unique alors cela ne sera pas le cas.

Exemple :
package fr.jmdoudoux.dej.thread;

public class MonCompteur {
  private int valeur;

  public int getValeur() {
    return valeur;
  }

  public int getNextValeur() {
    return ++valeur;
  }
}

Cette classe fonctionne très bien dans un contexte monothread. Par contre, elle ne fonctionne pas correctement dans un contexte multithread.

Exemple :
package fr.jmdoudoux.dej.thread;
      
  public class TestMonCompteur {
    public static void main(String[] args) throws InterruptedException {
      final MonCompteur compteur = new MonCompteur();
      Thread[] threads = new Thread[20];
      Runnable thread = new Runnable() {
        @Override
        public void run() {
          for (int i = 0; i < 10000; i++) {
            compteur.getNextValue();
          }
        }
      };
      
      for (int i = 0; i < 20; i++) {
        threads[i] = new Thread(thread);
        threads[i].start();
      }
      
      for (int i = 0; i < 20; i++) {
        threads[i].join();
      }

      System.out.println(compteur.getValue());
    }
  }

Les résultats de plusieurs exécutions sont différents et complètement aléatoires. La seule chose qui est vraie est que les résultats sont toujours faux.

Résultat :
184782
198318
195141

Avant Java 5, il était nécessaire de protéger les opérations en utilisant des verrous sur un moniteur.

Exemple :
package fr.jmdoudoux.dej.thread;

public class MonCompteur {
  private int valeur;

  public synchronized int get() {
    return valeur;
  }

  public synchronized int incrementer() {
    return ++valeur;
  }
}
Résultat :
200000
200000
200000

L'utilisation de verrous par un moniteur est une opération coûteuse et surtout bloquante. Si la section critique est petite le surcoût est important et l'overhead peut devenir conséquent si elle est invoquée de nombreuses fois. De plus, si de nombreux threads attendent pour avoir le verrou cela peut induire une forte contention.

Il est possible d'utiliser des algorithmes qui ne sont pas bloquants par opposition à l'utilisation de verrous avec un moniteur qui est bloquante. Le fait de ne pas être bloquant améliore les performances et la scalabilité notamment lorsque le nombre de threads augmentent.

Les algorithmes bloquants utilisent une approche pessimiste. Les algorithmes non bloquants utiliser une approche optimiste : ils sont plus difficile à écrire. Ils reposent sur un principe :

  • réaliser une opération
  • si celle-ci échoue alors elle effectue une autre opération généralement une retentative de l'opération elle-même

Le principe est donc de retenter l'opération jusqu'à ce qu'elle soit réalisée. Cette implémentation nécessite plus de code mais c'est le coût à payer pour mettre en oeuvre un algorithme non bloquant. Les algorithmes de type CAS sont généralement plus performants que d'utiliser les moniteurs qui sont bloquants.

Certains processeurs proposent des instructions qui facilitent la mise en oeuvre de ces algorithmes non bloquants. L'opération de ce type la plus couramment utilisée est l'opération CAS (Compare And Swap). Elle requiert généralement trois paramètres : l'adresse mémoire de la donnée, sa valeur courante et sa valeur souhaitée. Elle modifie la valeur à l'adresse passée en mémoire si la valeur est celle souhaitée sinon elle ne fait rien. L'opération renvoie toujours la valeur courante.

Si plusieurs threads invoquent cette opération, un des threads met à jour la valeur mais pas les autres. Généralement les autres threads vont retenter l'exécution de l'opération jusqu'à ce que la mise à jour soit faite.

Une opération de type compare and set repose sur le même principe mais au lieu de renvoyer la valeur, elle retourne un booléen qui précise si la mise à jour a été effectuée ou non.

A partir de Java 5, plusieurs classes dans le package java.util.concurrent.atomic permettent de proposer cette fonctionnalité pour différents types. Ces classes proposent différentes méthodes atomiques. Leur implémentation ne repose pas sur l'utilisation d'un moniteur mais sur le principe CAS (Compare And Set).

Classe

Rôle

AtomicBoolean

Encapsule une valeur booléenne qui peut être mise à jour de manière atomique

AtomicInteger

Encapsule une valeur entière qui peut être mise à jour de manière atomique

AtomicIntegerArray

Encapsule un tableau de valeurs entières qui peuvent être mises à jour de manière atomique

AtomicIntegerFieldUpdater<T>

Classe utilitaire qui permet de modifier un champs volatile

AtomicLong

Encapsule un entier long qui peut être mis à jour de manière atomique

AtomicLongArray

Encapsule un tableau d'entiers long qui peuvent être mis à jour de manière atomique

AtomicLongFieldUpdater<T>

Encapsule une valeur entière longue qui peut être mise à jour de manière atomique

AtomicMarkableReference<V>

Encapsule un booléen et une référence sur un objet de type V qui peuvent être modifiés de manière atomique

AtomicReference<V>

Encapsule une référence sur un objet qui peut être mise à jour de manière atomique

AtomicReferenceArray<E>

Encapsule un tableau de références sur des objets qui peuvent être mises à jour de manière atomique

AtomicReferenceFieldUpdater<T,V>

Encapsule une référence qui peut être mise à jour de manière atomique

AtomicStampedReference<V>

Encapsule un entier et une référence sur un objet de type V qui peuvent être modifiés de manière atomique


Toutes ces classes possèdent différentes méthodes qui permettent de modifier la valeur encapsulée en utilisant des opérations de type CAS : compareAndSet(), getAndSet(), get(), set(), ...

Les classes AtomicBoolean, AtomicInteger, AtomicLong et AtomicReference permettent d'obtenir et de mettre à jour de manière atomique la valeur qu'elles encapsulent. Elles proposent aussi des méthodes qui facilitent certaines opérations de mise à jour comme l'incrémentation.

Exemple ( code Java 5.0 ) :
package fr.jmdoudoux.dej.thread;
 
import java.util.concurrent.atomic.AtomicInteger;
 
public class MonCompteur {
 
  private AtomicInteger valeur = new AtomicInteger(0);
 
  public int get() {
    return valeur.get();
  }
 
  public int incrementer() {
    return valeur.incrementAndGet();
  }
}
Résultat :
200000
200000
200000

La méthode compareAndSet() attend en paramètre la valeur attendue et la nouvelle valeur : si la valeur courante est celle souhaitée alors elle met à jour avec la nouvelle valeur fournie. Elle renvoie un booléen qui précise si la mise à jour a été effectuée.

L'implémentation de ces méthodes peut utiliser des instructions spécifiques au processeur pour réaliser ces traitements de manière optimum. Si ce type d'opération n'est pas disponible sur la plateforme d'exécution, alors l'implémentation va utiliser en interne un mécanisme de verrous pour garantir l'atomicité de l'opération. Dans ce cas, les traitements ne sont plus non bloquants.

Attention : la méthode compareAndSet() ne peut pas être utilisée comme un remplacement pour gérer des verrous : son utilisation se limite à la mise à jour de la valeur encapsulée dans l'objet.

La méthode weakCompareAndSet() peut être utilisée dans certaines circonstances particulières. Sur certaines plateformes, la méthode weakCompareAndSet() peut être plus efficace que la méthode compareAndSet(). Cependant son utilisation présente plusieurs inconvénients :

  • son comportement n'est pas identique sur toutes les plateformes
  • elle peut renvoyer false sans raison évidente (spurious failure). C'est notamment le cas sur des plateformes qui ne propose pas d'instructions unitaires pour réaliser une opération de type CAS. L'opération doit alors être retentée
  • une partie de ses performances s'explique par le fait qu'elle ne permet pas de définir une relation de type happens-before. Elle ne permet donc pas obligatoirement aux autres threads de voir les modifications effectuées sur d'autres données avant son appel ni de garantir un ordre d'exécution

Les cas d'utilisation sont donc relativement rares : par exemple pour mettre à jour des compteurs ou des données statistiques relatives à la performance sous réserve qu'il n'y a pas de relation happens-before. Elle garantit uniquement l'atomicité de l'opération.

La méthode weakCompareAndSwap() n'offre pas de garantie d'être plus rapide mais peut être plus rapide selon la plateforme et la JVM utilisée.

Par exemple, en Java 6, l'implémentation du JDK de Sun des méthodes compareAndSet() et weakCompareAndSet() sont identiques : elles invoquent toutes les deux compareAndSwapXXX() de la classe sun.misc.Unsafe. Leurs performances sont donc identiques.

De plus, par exemple, les processeurs Intel x86 (à partir des architectures 80486 et Itanium) proposent l'instruction LOCK CMPXCHG qui met en oeuvre le compare and exchange avec la mise en oeuvre d'une barrière de mémoire. Ils ne possèdent pas (encore) une instruction qui ne met pas en oeuvre de barrière de mémoire. Du coup pour une JVM HotSpot, sur une architecture x86, les performances des méthodes compareAndSet() et weakCompareAndSet() sont identiques. Sur d'autres architectures, le comportement peut être différent : notamment sur les plateformes qui ne possèdent pas d'instructions dédiées, il est fréquent d'utiliser une séquence d'instructions LL/SC (Load-Link et Store-Conditional).

Le package contient plusieurs classes AtomicXXXFiledUpdater qui sont des utilitaires permettant de modifier par introspection la valeur d'un champ volatile du type XXX de n'importe quelle instance.

Le package contient plusieurs classes AtomicXXXArray (AtomicIntegerArray, AtomicLongArray et AtomicReferenceArray) qui proposent des opérations atomiques réalisables sur le tableau de type XXX qu'elles encapsulent. Elles permettent notamment une sémantique similaire à volatile sur les éléments du tableau.

La classe AtomicMarkableReference encapsule un booléen et une référence qui peuvent être modifiés de manière atomique.

La classe AtomicStampedReference encapsule un entier de type int et une référence qui peuvent être modifiés de manière atomique.

Remarque : les classes AtomicXXX ne sont pas des classes de remplacement de classes de type wrapper java.lang.XXX. Elles ne redéfinissent pas la méthode hashCode() et comme elles sont mutables, il n'est pas recommandé de les utiliser comme clés dans une collection de type Map.

L'utilisation de ces classes permet d'avoir une meilleure scalabilité par rapport à l'utilisation de verrous avec des moniteurs. Cependant, sous forte contention, les performances des opérations de type CAS peuvent se dégrader fortement.

L'utilisation de certaines méthodes de ces classes offrent des garanties particulières : elles sont pour certaines similaires à l'utilisation du mot clé volatile telle que définie par la section 17.4 de la JLS.

Méthode

Effets sur la mémoire

get()

Similaire à la lecture d'une variable volatile

set()

Similaire à la modification d'une variable volatile

lazySet()

La sémantique garantit que l'écriture ne sera pas réordonnée vis à vis d'écritures précédentes mais pourra être réordonnée avec les écritures suivantes. Du coup, il n'y a pas de garantie sur la visibilité de la modification par les autres threads.

En terme de barrières de mémoire, la méthode lazySet() exécute une barrière de type store-store qui est peu coûteuse mais ne fait pas de barrière de type store-load.

Parmi les cas d'utilisation, il y a par exemple la remise à null d'une référence puisque cette référence ne sera plus utilisée par ailleurs.

weakCompareAndSet()

Lecture et écriture conditionnelle sans relation de type happens-before

compareAndSet() et les autres méthodes telles que incrementAndSet()

Similaire à la lecture et la modification d'une variable volatile

 

39.4.1. La classe AtomicBoolean

La classe java.util.concurrent.atomic.AtomicBoolean encapsule une valeur booléenne qui peut être lue et mise à jour de manière atomique.

Elle possède deux constructeurs :

Constructeur

Rôle

AtomicBoolean()

La valeur encapsulée est false

AtomicBoolean(boolean initialValue)

La valeur encapsulée est celle fournie en paramètre


Elle possède plusieurs méthodes :

Méthode

Rôle

boolean compareAndSet(boolean expect, boolean update)

Tenter de mettre à jour de manière atomique la valeur avec celle du paramètre update si la valeur courante est égale à celle du paramètre expect. Renvoie un booléen qui précise si la valeur a été modifiée lors des traitements.

boolean get()

Renvoyer la valeur courante

boolean getAndSet(boolean newValue)

Modifier la valeur avec celle en paramètre et renvoie la valeur avant modification

void lazySet(boolean newValue)

Depuis Java 6

void set(boolean newValue)

Modifier la valeur

String toString()

Renvoyer la valeur sous la forme d'une chaîne de caractères

boolean weakCompareAndSet(boolean expect, boolean update)

 

Exemple ( code Java 5.0 ) :
package fr.jmdoudoux.dej.thread;
 
import java.util.concurrent.atomic.AtomicBoolean;
 
public class TestAtomicBoolean {
 
  public static void main(String[] args) {
    AtomicBoolean atomicBoolean = new AtomicBoolean(true);
    System.out.println("1 valeur=" + atomicBoolean.get());
    
    boolean valeur = atomicBoolean.getAndSet(false);
    System.out.println("2 valeur=" + valeur);
    System.out.println("3 valeur=" + atomicBoolean.get());
    
    boolean isOk = atomicBoolean.compareAndSet(true, false);
    System.out.println("isOk=" + isOk);
    System.out.println("4 valeur=" + atomicBoolean.get());
    
    isOk = atomicBoolean.compareAndSet(false, true);
    System.out.println("isOk=" + isOk);
    System.out.println("5 valeur=" + atomicBoolean.get());
  }
}

 

 

39.4.2. La classe AtomicInteger

La classe java.util.concurrent.atomic.AtomicInteger encapsule une valeur entière qui peut être mise à jour de manière atomique.

La classe AtomicInteger hérite de la classe Number et implémente l'interface Serializable. Elle ne doit cependant pas être utilisée en remplacement de la classe Integer.

Elle possède deux constructeurs :

Constructeur

Rôle

AtomicInteger()

La valeur encapsulée est 0

AtomicInteger(int initialValue)

La valeur encapsulée est celle fournie en paramètre


Elle possède plusieurs méthodes :

Méthode

Rôle

int accumulateAndGet(int x, IntBinaryOperator accumulatorFunction)

Modifier la valeur avec celle retournée par l'expression Lambda passée en paramètre qui sera invoquée avec la valeur courante et celle fournie. Renvoie la valeur après modification

Depuis Java 8

int addAndGet(int delta)

Ajouter delta à la valeur et la renvoyer

boolean compareAndSet(int expect, int update)

Mettre à jour de manière atomique la valeur courante avec celle du paramètre update si la valeur courante est égale à la valeur du paramètre expect. Le booléen indique si la mise à jour a été effectuée

int decrementAndGet()

Décrémenter la valeur et la renvoyer

double doubleValue()

Renvoyer la valeur sous la forme d'un double

float floatValue()

Renvoyer la valeur sous la forme d'un float

int get()

Obtenir la valeur courante

int getAndAccumulate(int x, IntBinaryOperator accumulatorFunction)

Modifier la valeur avec celle retournée par l'expression Lambda passée en paramètre qui sera invoquée avec la valeur courante et celle fournie. Renvoie la valeur avant la modification

Depuis Java 8

int getAndAdd(int delta)

Ajouter delta à la valeur et renvoyer la valeur avant modification

int getAndDecrement()

Décrémenter la valeur et renvoyer la valeur avant incrémentation

int getAndIncrement()

Incrémenter la valeur et renvoyer la valeur avant incrémentation

int getAndSet(int newValue)

Mettre à jour la valeur et renvoyer la valeur avant modification

int getAndUpdate(IntUnaryOperator updateFunction)

Modifier la valeur avec celle retournée par l'expression Lambda passée en paramètre. Renvoie la valeur avant la modification

Depuis Java 8

int incrementAndGet()

Incrémenter la valeur et la renvoyer

int intValue()

Renvoyer la valeur sous la forme d'un int

void lazySet(int newValue)

Depuis Java 6

long longValue()

Renvoyer la valeur sous la forme d'un entier long

void set(int newValue)

Mettre à jour la valeur

int updateAndGet(IntUnaryOperator updateFunction)

Modifier la valeur avec celle retournée par l'expression Lambda passée en paramètre. Renvoie la valeur après modification

Depuis Java 8

boolean weakCompareAndSet(int expect, int update)

 

Exemple ( code Java 5.0 ) :
package fr.jmdoudoux.dej.thread;
 
import java.util.concurrent.atomic.AtomicInteger;
 
public class MonCompteur {
 
  private AtomicInteger valeur = new AtomicInteger(0);
 
  public int get() {
    return valeur.get();
  }
 
  public int incrementer() {
    return valeur.incrementAndGet();
  }
}

Il est possible d'utiliser la méthode compareAndSet() pour modifier la valeur.

Exemple ( code Java 5.0 ) :
package fr.jmdoudoux.dej.thread;
 
import java.util.concurrent.atomic.AtomicInteger;
 
public class MonCompteur {
 
  private AtomicInteger valeur = new AtomicInteger(0);
 
  public int get() {
    return valeur.get();
  }
 
  public int incrementer() {
    int courante = valeur.get();
    while (!valeur.compareAndSet(courante, courante + 1)) {
      courante = valeur.get();
    }
    return valeur.get();
  }
}

L'implémentation de la méthode incrementAndGet() est d'ailleurs assez similaire à celle de l'implémentation de la méthode incrementer() ci-dessus.

 

39.4.3. La classe AtomicLong

La classe java.util.concurrent.atomic.AtomicLong encapsule une valeur entière longue qui peut être mise à jour de manière atomique

La classe AtomicLong hérite de la classe Number et implémente l'interface Serializable. Elle ne doit cependant pas être utilisée en remplacement de la classe Long.

Elle possède deux constructeurs :

Constructeur

Rôle

AtomicLong()

La valeur encapsulée est 0

AtomicLong(long initialValue)

La valeur encapsulée est celle fournie en paramètre


Elle possède plusieurs méthodes :

Méthode

Rôle

long accumulateAndGet(long x, LongBinaryOperator accumulatorFunction)

Modifier la valeur avec celle retournée par l'expression Lambda passée en paramètre qui sera invoquée avec la valeur courante et celle fournie. Renvoie la valeur après modification

Depuis Java 8

long addAndGet(long delta)

Ajouter delta à la valeur et la renvoyer

boolean compareAndSet(long expect, long update)

Mettre à jour de manière atomique la valeur courante avec celle du paramètre update si la valeur courante est égale à la valeur du paramètre expect. Le booléen indique si la mise à jour a été effectuée

long decrementAndGet()

Décrémenter la valeur et la renvoyer

double doubleValue()

Renvoyer la valeur sous la forme d'un double

float floatValue()

Renvoyer la valeur sous la forme d'un float

long get()

Obtenir la valeur courante

long getAndAccumulate(int x, LongBinaryOperator accumulatorFunction)

Modifier la valeur avec celle retournée par l'expression Lambda passée en paramètre qui sera invoquée avec la valeur courante et celle fournie. Renvoie la valeur avant la modification

Depuis Java 8

long getAndAdd(int delta)

Ajouter delta à la valeur et renvoyer la valeur avant modification

long getAndDecrement()

Décrémenter la valeur et renvoyer la valeur avant incrémentation

long getAndIncrement()

Incrémenter la valeur et renvoyer la valeur avant incrémentation

long getAndSet(long newValue)

Mettre à jour la valeur et renvoyer la valeur avant modification

long getAndUpdate(LongUnaryOperator updateFunction)

Modifier la valeur avec celle retournée par l'expression Lambda passée en paramètre. Renvoie la valeur avant la modification

Depuis Java 8

long incrementAndGet()

Incrémenter la valeur et la renvoyer

int intValue()

Renvoyer la valeur sous la forme d'un int

void lazySet(long newValue)

Depuis Java 6

long longValue()

Renvoyer la valeur sous la forme d'un entier long

void set(long newValue)

Mettre à jour la valeur

long updateAndGet(LongUnaryOperator updateFunction)

Modifier la valeur avec celle retournée par l'expression Lambda passée en paramètre. Renvoie la valeur après modification

Depuis Java 8

boolean weakCompareAndSet(long expect, long update)

 

Exemple ( code Java 5.0 ) :
package fr.jmdoudoux.dej.thread;
 
import java.util.concurrent.atomic.AtomicLong;
 
public class MonCompteur {
 
  private AtomicLong valeur = new AtomicLong(0);
 
  public long get() {
    return valeur.get();
  }
 
  public long incrementer() {
    return valeur.incrementAndGet();
  }
}

Il est possible d'utiliser la méthode compareAndSet() pour modifier la valeur.

Exemple ( code Java 5.0 ) :
package fr.jmdoudoux.dej.thread;
 
import java.util.concurrent.atomic.AtomicLong;
 
public class MonCompteur {
 
  private AtomicLong valeur = new AtomicLong();
 
  public long get() {
    return valeur.get();
  }
 
  public long incrementer() {
    long courante = valeur.get();
    while (!valeur.compareAndSet(courante, courante + 1)) {
      courante = valeur.get();
    }
    return valeur.get();
  }
}

L'implémentation de la méthode incrementAndGet() est d'ailleurs assez similaire à celle de l'implémentation de la méthode incrementer() ci-dessus.

 

39.4.4. Les classes AtomicIntegerArray, AtomicLongArray et AtomicReferenceArray<E>

Les classes AtomicIntegerArray, AtomicLongArray et AtomicReferenceArray<E> implémentent des tableaux atomiques respectivement de type int, long et Object.

Ces classes sont utiles car le fait de déclarer un tableau avec le mot clé volatile ne concerne que les opérations sur la référence du tableau mais ne concerne pas celles sur les références des éléments du tableau.

La plupart des opérations qui réalisent un traitement atomique attendent en premier paramètre l'index de l'élément du tableau sur lequel elles doivent opérer.

Le JDK ne propose pas de classe pour gérer un tableau de booléen atomique : il est possible d'utiliser la classe AtomicIntegerArray avec des valeurs 0 ou 1.

 

39.4.4.1. La classe AtomicIntegerArray

La classe java.util.concurrent.atomic.AtomicIntegerArray encapsule un tableau de valeurs entières qui peuvent être mises à jour de manière atomique.

Il est possible d'utiliser un tableau d'objets de type AtomicInteger pour obtenir des fonctionnalités similaires mais l'utilisation d'un AtomicIntegerArray consomme moins de ressources mémoire.

Elle possède deux constructeurs :

Constructeur

Rôle

AtomicIntegerArray(int length)

Créer une nouvelle instance qui encapsule un tableau dont la taille est fournie en paramètres

AtomicIntegerArray(int[] array)

Créer une nouvelle instance qui encapsule un tableau initialisé avec une copie des éléments du tableau fourni en paramètre


Elle possède de nombreuses méthodes :

Méthode

Rôle

int accumulateAndGet(int i, int x, IntBinaryOperator accumulatorFunction)

Modifier la valeur de l'index précisé avec celle retournée par l'expression Lambda passée en paramètre qui sera invoquée avec la valeur courante et celle fournie. Renvoie la valeur après modification

Depuis Java 8

int addAndGet(int i, int delta)

Ajouter delta à la valeur de l'index précisé et la renvoyer

boolean compareAndSet(int i, int expect, int update)

Mettre à jour de manière atomique la valeur courante de l'index précisé avec celle du paramètre update si la valeur courante est égale à la valeur du paramètre expect. Le booléen indique si la mise à jour a été effectuée

int decrementAndGet(int i)

Décrémenter la valeur de l'index précisé et la renvoyer

int get(int i)

Obtenir la valeur courante de l'index précisé

int getAndAccumulate(int i, int x, IntBinaryOperator accumulatorFunction)

Modifier la valeur de l'index précisé avec celle retournée par l'expression Lambda passée en paramètre qui sera invoquée avec la valeur courante et celle fournie. Renvoie la valeur avant la modification

Depuis Java 8

int getAndAdd(int i, int delta)

Ajouter delta à la valeur de l'index précisé et renvoyer la valeur avant modification

int getAndDecrement(int i)

Décrémenter la valeur de l'index précisé et renvoyer la valeur avant incrémentation

int getAndIncrement(int i)

Incrémenter la valeur de l'index précisé et renvoyer la valeur avant incrémentation

int getAndSet(int i, int newValue)

Mettre à jour la valeur de l'index précisé et renvoyer la valeur avant modification

int getAndUpdate(int i, IntUnaryOperator updateFunction)

Modifier la valeur de l'index précisé avec celle retournée par l'expression Lambda passée en paramètre. Renvoie la valeur avant la modification

Depuis Java 8

int incrementAndGet(int i)

Incrémenter la valeur de l'index précisé et la renvoyer

void lazySet(int i, int newValue)

Depuis Java 6

int length()

Renvoyer la taille du tableau

void set(int i, int newValue)

Remplacer la valeur de l'index précisé avec celle fournie

int updateAndGet(int i, IntUnaryOperator updateFunction)

Modifier la valeur de l'index précisé avec celle retournée par l'expression Lambda passée en paramètre. Renvoie la valeur après modification

boolean weakCompareAndSet(int i, int expect, int update)

 

 

39.4.4.2. La classe AtomicLongArray

La classe java.util.concurrent.atomic.AtomicLongArray encapsule un tableau de valeurs entières longues qui peuvent être mises à jour de manière atomique

Elle possède deux constructeurs :

Constructeur

Rôle

AtomicLongArray(int length)

Créer une nouvelle instance qui encapsule un tableau dont la taille est fournie en paramètres

AtomicLongArray(long[] array)

Créer une nouvelle instance qui encapsule un tableau initialisé avec une copie des éléments du tableau fourni en paramètre


Elle possède de nombreuses méthodes :

Méthode

Rôle

long accumulateAndGet(int i, long x, LongBinaryOperator accumulatorFunction)

Modifier la valeur de l'index précisé avec celle retournée par l'expression Lambda passée en paramètre qui sera invoquée avec la valeur courante et celle fournie. Renvoie la valeur après modification

Depuis Java 8

long addAndGet(int i, long delta)

Ajouter delta à la valeur de l'index précisé et la renvoyer

boolean compareAndSet(int i, long expect, long update)

Mettre à jour de manière atomique la valeur courante de l'index précisé avec celle du paramètre update si la valeur courante est égale à la valeur du paramètre expect. Le booléen indique si la mise à jour a été effectuée

long decrementAndGet(int i)

Décrémenter la valeur de l'index précisé et la renvoyer

long get(int i)

Obtenir la valeur courante de l'index précisé

long getAndAccumulate(int i, long x, LongBinaryOperator accumulatorFunction)

Modifier la valeur de l'index précisé avec celle retournée par l'expression Lambda passée en paramètre qui sera invoquée avec la valeur courante et celle fournie. Renvoie la valeur avant la modification

Depuis Java 8

long getAndAdd(int i, long delta)

Ajouter delta à la valeur de l'index précisé et renvoyer la valeur avant modification

long getAndDecrement(int i)

Décrémenter la valeur de l'index précisé et renvoyer la valeur avant incrémentation

long getAndIncrement(int i)

Incrémenter la valeur de l'index précisé et renvoyer la valeur avant incrémentation

long getAndSet(int i, long newValue)

Mettre à jour la valeur de l'index précisé et renvoyer la valeur avant modification

long getAndUpdate(int i, LongUnaryOperator updateFunction)

Modifier la valeur de l'index précisé avec celle retournée par l'expression Lambda passée en paramètre. Renvoie la valeur avant la modification

Depuis Java 8

long incrementAndGet(int i)

Incrémenter la valeur de l'index précisé et la renvoyer

void lazySet(int i, long newValue)

Depuis Java 6

int length()

Renvoyer la taille du tableau

void set(int i, long newValue)

Remplacer la valeur de l'index précisé avec celle fournie

long updateAndGet(int i, LongUnaryOperator updateFunction)

Modifier la valeur de l'index précisé avec celle retournée par l'expression Lambda passée en paramètre. Renvoie la valeur après modification

boolean weakCompareAndSet(int i, long expect, long update)

 

 

39.4.4.3. La classe AtomicReferenceArray<E>

La classe java.util.concurrent.atomic.AtomicReferenceArray encapsule un tableau de références sur des objets qui peuvent être mises à jour de manière atomique.

Elle possède deux constructeurs :

Constructeur

Rôle

AtomicReferenceArray(E[] array)

Créer une nouvelle instance qui encapsule un tableau initialisé avec une copie des éléments du tableau fourni en paramètre

AtomicReferenceArray(int length)

Créer une nouvelle instance qui encapsule un tableau dont la taille est fournie en paramètres


Elle possède de nombreuses méthodes :

Méthode

Rôle

E accumulateAndGet(int i, E x, BinaryOperator<E> accumulatorFunction)

Modifier la valeur de l'index précisé avec celle retournée par l'expression Lambda passée en paramètre qui sera invoquée avec la valeur courante et celle fournie. Renvoie la valeur après modification

Depuis Java 8

boolean compareAndSet(int i, E expect, E update)

Mettre à jour de manière atomique la valeur courante de l'index précisé avec celle du paramètre update si la valeur courante est égale à la valeur du paramètre expect. Le booléen indique si la mise à jour a été effectuée

E get(int i)

Obtenir la valeur courante de l'index précisé

E getAndAccumulate(int i, E x, BinaryOperator<E> accumulatorFunction)

Modifier la valeur de l'index précisé avec celle retournée par l'expression Lambda passée en paramètre qui sera invoquée avec la valeur courante et celle fournie. Renvoie la valeur avant la modification

Depuis Java 8

E getAndSet(int i, E newValue)

Mettre à jour la valeur de l'index précisé et renvoyer la valeur avant modification

E getAndUpdate(int i, UnaryOperator<E> updateFunction)

Modifier la valeur de l'index précisé avec celle retournée par l'expression Lambda passée en paramètre. Renvoie la valeur avant la modification

Depuis Java 8

void lazySet(int i, E newValue)

Depuis Java 6

int length()

Renvoyer la taille du tableau

void set(int i, E newValue)

Remplacer la valeur de l'index précisé avec celle fournie

E updateAndGet(int i, UnaryOperator<E> updateFunction)

Modifier la valeur de l'index précisé avec celle retournée par l'expression Lambda passée en paramètre. Renvoie la valeur après modification

boolean weakCompareAndSet(int i, E expect, E update)

 

 

39.4.5. Les classes AtomicReference, AtomicMarkableReference et AtomicStampedReference

Les classes AtomicMarkableReference et AtomicStampedReference permettent de gérer de manière atomique respectivement une référence associée à un booléen ou à un entier.

 

39.4.5.1. La classe AtomicReference<V>

La classe java.util.concurrent.atomic.AtomicReference encapsule une référence sur un objet qui peut être mise à jour de manière atomique.

Elle possède deux constructeurs :

Constructeur

Rôle

AtomicReference ()

La valeur encapsulée est null

AtomicReference (V initialValue)

La valeur encapsulée est celle fournie en paramètre


Elle possède plusieurs méthodes :

Méthode

Rôle

V accumulateAndGet(V x, BinaryOperator<V> accumulatorFunction)

Modifier la valeur avec celle retournée par l'expression Lambda passée en paramètre qui sera invoquée avec la valeur courante et celle fournie. Renvoie la valeur après modification

Depuis Java 8

boolean compareAndSet(V expect, V update)

Tenter de mettre à jour de manière atomique la valeur avec celle du paramètre update si la valeur courante est égale à celle du paramètre expect. Renvoie un booléen qui précise si la valeur a été modifiée lors des traitements.

V get()

Renvoyer la valeur courante

boolean getAndSet(boolean newValue)

Modifier la valeur avec celle en paramètre et renvoie la valeur avant modification

void lazySet(boolean newValue)

Depuis Java 6

void set(boolean newValue)

Modifier la valeur

String toString()

Renvoyer la valeur sous la forme d'une chaîne de caractères

boolean weakCompareAndSet(boolean expect, boolean update)

 

 

39.4.5.2. La classe AtomicMarkableReference

La classe AtomicMarkableReference encapsule un booléen et une référence sur un objet de type V qui peuvent être modifiés de manière atomique.

Elle ne possède qu'un seul constructeur.

Constructeur

Rôle

AtomicMarkableReference (V initialRef, boolean initialMark)

Créer une nouvelle instance qui encapsule l'objet et la valeur booléenne fournis en paramètres


Elle possède plusieurs méthodes :

Méthode

Rôle

boolean attemptMark(V expectedReference, new newMark)

Modifier la valeur booléenne si la référence de l'instance de l'objet passé en paramètre est égale à celle encapsulée. Renvoie un booléen qui précise si l'opération a été effectuée

boolean compareAndSet(V expectedReference, V newReference, boolean expectedMark, boolean newMark)

Modifier l'instance encapsulée et la valeur entière si l'instance de l'objet (expectedReference) et la valeur entière (expectedMark ) passées en paramètres sont égales à celles encapsulées. Renvoie un booléen qui précise si l'opération a été effectuée

V get(boolean[] markHolder)

 

V getReference()

Renvoyer l'objet encapsulé

boolean isMark()

Renvoyer la valeur booléenne

void set(V newReference, boolean newMark)

Modifier de manière inconditionnelle l'instance et la valeur booléenne avec les valeurs fournies en paramètre

boolean weakCompareAndSet(V expectedReference, V newReference, boolean expectedMark, boolean newMark)

 

Exemple ( code Java 5.0 ) :
package fr.jmdoudoux.dej.thread;
 
import java.util.concurrent.atomic.AtomicMarkableReference;
 
public class TestAtomicMarkableReference {
 
  AtomicMarkableReference<String> amr = new AtomicMarkableReference<String>(
                                          "chaine1", false);
 
  class MonRunnable implements Runnable {
 
    @Override
    public void run() {
 
      amr.attemptMark("chaine1", true);
      System.out.println(Thread.currentThread().getName() + " : "
          + amr.getReference() + " : " + amr.isMarked());
 
      amr.compareAndSet("chaine1", "chaine2", true, false);
      System.out.println(Thread.currentThread().getName() + " : "
          + amr.getReference() + " : " + amr.isMarked());
    }
  }
 
  public static void main(String... args) {
    for (int i = 0; i < 5; i++)
      new Thread(new TestAtomicMarkableReference().new MonRunnable()).start();
  }
}
Résultat :
Thread-0 : chaine1 : true
Thread-0 : chaine2 : false
Thread-1 : chaine1 : true
Thread-1 : chaine2 : false
Thread-2 : chaine1 : true
Thread-2 : chaine2 : false
Thread-3 : chaine1 : true
Thread-3 : chaine2 : false
Thread-4 : chaine1 : true
Thread-4 : chaine2 : false

 

39.4.5.3. La classe AtomicStampedReference

La classe AtomicStampedReference encapsule un entier de type int et une référence sur un objet de type V qui peuvent être modifiées de manière atomique.

Elle ne possède qu'un seul constructeur.

Constructeur

Rôle

AtomicStampedReference(V initialRef, int initialStamp)

Créer une nouvelle instance qui encapsule l'objet et la valeur entière fournis en paramètre


Elle possède plusieurs méthodes :

Méthode

Rôle

boolean attemptStamp(V expectedReference, int newStamp)

Modifier la valeur entière si l'instance de l'objet passé en paramètre est égale à celle encapsulée. Renvoie un booléen qui précise si l'opération a été effectuée

boolean compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp)

Modifier l'instance encapsulée et la valeur entière si l'instance de l'objet (expectedReference) et la valeur entière (expectedStamp) passées en paramètres sont égales à celles encapsulées. Renvoie un booléen qui précise si l'opération a été effectuée

V get(int[] stampHolder)

 

V getReference()

Renvoyer l'objet encapsulé

int getStamp()

Renvoyer la valeur entière

void set(V newReference, int newStamp)

Modifier de manière inconditionnelle l'instance et la valeur entière avec les valeurs fournies en paramètre

boolean weakCompareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp)

 

Exemple ( code Java 5.0 ) :
package fr.jmdoudoux.dej.thread;
 
import java.util.concurrent.atomic.AtomicStampedReference;
 
public class TestAtomicStampedReference {
 
  AtomicStampedReference<String> asr = new AtomicStampedReference<String>(
                                         "chaine1", 1);
 
  class MonRunnable implements Runnable {
 
    @Override
    public void run() {
 
      asr.attemptStamp("chaine1", 2);
      System.out.println(Thread.currentThread().getName() + " : "
          + asr.getReference() + " : " + asr.getStamp());
 
      asr.compareAndSet("chaine1", "chaine2", 2, 3);
      System.out.println(Thread.currentThread().getName() + " : "
          + asr.getReference() + " : " + asr.getStamp());
    }
  }
 
  public static void main(String... args) {
    for (int i = 0; i < 5; i++)
      new Thread(new TestAtomicStampedReference().new MonRunnable()).start();
  }
}
Résultat :
Thread-0 : chaine1 : 2
Thread-1 : chaine1 : 2
Thread-2 : chaine1 : 2
Thread-0 : chaine2 : 3
Thread-2 : chaine2 : 3
Thread-1 : chaine2 : 3
Thread-3 : chaine1 : 2
Thread-3 : chaine2 : 3
Thread-4 : chaine1 : 2

Thread-4 : chaine2 : 3

 

39.4.6. Les classes AtomicIntegerFieldUpdater, AtomicLongFieldUpdater, AtomicReferenceFieldUpdater

 

en construction
La suite de cette section sera développée dans une version future de ce document

 

 

39.4.7. Les classes DoubleAccumulator et LongAccumulator

 

en construction
La suite de cette section sera développée dans une version future de ce document

 

 

39.4.8. Les classes DoubleAdder et LongAdder

 

en construction
La suite de cette section sera développée dans une version future de ce document

 

 

39.5. L'immutabilité et la copie défensive

La façon la plus sûre pour éviter des problèmes de concurrences d'accès est de ne partager entre plusieurs threads que des objets immuables. Un objet immuable est un objet dont l'état ne peut pas être modifié après son initialisation.

Certains objets peuvent rester immuables durant toute leur durée de vie mais l'état de la plupart des objets partagés changent au cours du temps. Chacune de ces modifications nécessite alors la création d'une nouvelle instance qui encapsulera de manière immuable le nouvel état.

Il ne reste plus qu'a remplacer la référence précédente par celle de la nouvelle instance : cette opération est garantie d'être atomique par la JVM.

Cette technique requiert donc potentiellement de nombreuses instances selon le nombre d'objets concernés et de modifications de leur état.

Ce principe de fonctionnement est le fondement de certains frameworks notamment Akka.

 

39.5.1. L'immutabilité

L'état d'un objet immuable ne peut pas être modifié après son initialisation. Chaque changement de son état implique la création d'une nouvelle instance qui encapsulera le nouvel état.

Un bon exemple dans l'API Java Core est la classe java.lang.String.

La section L'écriture d'une classe dont les instances seront immuables du chapitre Les techniques de développement spécifiques à Java en détaille la mise en oeuvre.

 

39.5.2. La copie défensive

Lorsqu'un objet est passé en paramètre ou en retour d'une méthode, celle-ci n'a aucun moyen :

  • de connaitre les autres objets qui possèdent une référence sur l'instance
  • si l'objet est mutable, de savoir qu'elles modifications seront faites sur l'objet à partir de ces références

Un objet mutable est un objet dont l'état peut être modifié après sa construction.

Exemples d'objets mutables : Date, StringBuilder, les collections, les tableaux, ...

Exemples d'objets immuables : String, Integer, ...

Une classe peut avoir un champ qui est un objet mutable. Il y a alors deux cas de figure pour modifier l'état de cet objet :

  • l'état est uniquement modifié par la classe qui encapsule l'objet car elle protège son accès
  • l'objet peut être renvoyé à un appelant qui peut alors modifier son état

Une solution pour mettre en oeuvre le premier cas est d'utiliser la copie défensive.

Pour préserver les règles de l'encapsulation, la copie défensive doit être mise en oeuvre lorsque :

  • un objet mutable est passé en paramètre d'un setter ou d'un constructeur
  • un objet mutable est retourné par un getter

Si ce n'est pas le cas, il est possible que l'objet encapsulé soit modifié en dehors de la classe.

La seule façon de garantir que seule la classe aura une référence sur un objet passé en paramètre ou retourné est d'utiliser la copie défensive. Elle consiste à renvoyer une copie d'un objet à son appelant ou à créer une copie d'un objet reçu en paramètre.

Le but de la copie défensive est de travailler sur une copie d'un objet plutôt que sur l'objet original pour éviter que l'état de cet objet ne soit modifié en dehors de la classe de manière volontaire ou non.

La copie défensive d'un paramètre peut ne pas être utilisée que pour permettre l'immutabilité de la classe. Lorsqu'un objet est passé en paramètre d'une méthode ou d'un constructeur, il faut savoir si l'objet est mutable et si c'est le cas si c'est acceptable que l'objet puisse être modifié en dehors de la classe. Si la réponse est non, alors il faut stocker une copie défensive plutôt qu'une référence sur l'objet original passé en paramètre.

Lorsqu'un objet est retournée d'une méthode, il faut se poser les mêmes questions et en fonctions des réponses utiliser ou non la copie défensive.

L'exemple ci-dessous est un bean qui stocke son état dans une collection. Il ne propose que deux méthodes pour ajouter un élément et obtenir une collection qui contient les éléments.

Exemple :
package fr.jmdoudoux.dej.thread;

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

public class TestMaListe {

  List<String> list = new ArrayList<String>();

  public void ajouter(String s) {
    list.add(s);
  }

  public List<String> get() {
    return list;
  }

  public static void main(String[] args) {
    TestMaListe maListe = new TestMaListe();
    maListe.ajouter("1");
    maListe.ajouter("2");
    System.out.println("liste : "
        + Arrays.deepToString(maListe.get().toArray()));
    List<String> liste = maListe.get();
    liste.add("3");
    System.out.println("liste : "
        + Arrays.deepToString(maListe.get().toArray()));
  }
}
Résultat :
liste : [1, 2]
liste : [1, 2, 3]

L'état de l'objet de type List peut être modifié à l'extérieur de la classe puisque c'est l'instance de type List qui est directement retournée : rien n'empêche d'invoquer ses méthodes pour ajouter un élément comme dans l'exemple ci-dessus.

Pour garder un bon contrôle sur l'état de la collection, il est possible de renvoyer une copie de l'instance de type Liste. Dans l'exemple, ci-dessous c'est une copie non modifiable qui est retournée. L'instance retournée peut être utilisée pour obtenir un élément ou parcourir tout ou partie des éléments mais il n'est pas possible d'ajouter, de modifier ou de supprimer un élément.

Exemple :
package fr.jmdoudoux.dej.thread;

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

public class TestMaListe {

  List<String> list = new ArrayList<String>();

  public void ajouter(String s) {
    list.add(s);
  }

  public List<String> get() {
    return Collections.unmodifiableList(list);
  }

  public static void main(String[] args) {
    TestMaListe maListe = new TestMaListe();
    maListe.ajouter("1");
    maListe.ajouter("2");
    System.out.println("liste : "
        + Arrays.deepToString(maListe.get().toArray()));
    List<String> liste = maListe.get();
    liste.add("3");
    System.out.println("liste : "
        + Arrays.deepToString(maListe.get().toArray()));
  }
}
Résultat :
liste : [1, 2]
Exception in thread "main" java.lang.UnsupportedOperationException
      at java.util.Collections$UnmodifiableCollection.add(Collections.java:1016)
      at fr.jmdoudoux.dej.thread.TestMaListe.main(TestMaListe.java:27)

L'exemple suivant utilise un bean qui possède une propriété de type Date.

Exemple :
package fr.jmdoudoux.dej.thread;

import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;

public class Personne {

  private String nom;
  private Date   dateNaissance;

  public Personne(String nom, Date dateNaissance) {
    super();
    this.nom = nom;
    this.dateNaissance = dateNaissance;
  }

  public String getNom() {
    return nom;
  }

  public Date getDateNaissance() {
    return dateNaissance;
  }

  public static void main(String[] args) {

    Calendar calendar = new GregorianCalendar(2015, 11, 25);
    Date dateNaiss = calendar.getTime();

    Personne personne = new Personne("Nom1", dateNaiss);
    System.out.println("date de naissance = " + personne.getDateNaissance());
    dateNaiss.setYear(114);
    System.out.println("date de naissance = " + personne.getDateNaissance());
  }
}
Résultat :
date de naissance = Fri Dec 25 00:00:00 CET 2015
date de naissance = Fri Dec 25 00:00:00 CET 2014

Pour éviter cette situation, il faut créer une copie défensive des objets mutables passés en paramètres des constructeurs et des setters.

Exemple :
package fr.jmdoudoux.dej.thread;

import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;

public class Personne {

  private String nom;
  private Date   dateNaissance;

  public Personne(String nom, Date dateNaissance) {
    super();
    this.nom = nom;
    this.dateNaissance = new Date(dateNaissance.getTime());
  }

  public String getNom() {
    return nom;
  }

  public Date getDateNaissance() {
    return dateNaissance;
  }

  public static void main(String[] args) {

    Calendar calendar = new GregorianCalendar(2015, 11, 25);
    Date dateNaiss = calendar.getTime();

    Personne personne = new Personne("Nom1", dateNaiss);
    System.out.println("date de naissance = " + personne.getDateNaissance());
    dateNaiss.setYear(114);
    System.out.println("date de naissance = " + personne.getDateNaissance());
  }
}
Résultat :
date de naissance = Fri Dec 25 00:00:00 CET 2015
date de naissance = Fri Dec 25 00:00:00 CET 2015

Remarque : si des contrôles doivent être faits sur ces paramètres, il est nécessaire de faire les copies défensives puis les contrôles sur ces copies. Ceci afin d'éviter qu'un autre thread ne vienne modifier l'état des objets pendant la réalisation des copies. Ce phénomène est désigné par l'acronyme TOCTOU : Time Of Check to Time of Use.

Cela n'empêche pas un appelant de modifier l'objet mutable si c'est directement lui qui est retourné par un getter.

Exemple :
package fr.jmdoudoux.dej.thread;

import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;

public class Personne {

  private String nom;
  private Date   dateNaissance;

  public Personne(String nom, Date dateNaissance) {
    super();
    this.nom = nom;
    this.dateNaissance = new Date(dateNaissance.getTime());
  }

  public String getNom() {
    return nom;
  }

  public Date getDateNaissance() {
    return dateNaissance;
  }

  public static void main(String[] args) {

    Calendar calendar = new GregorianCalendar(2015, 11, 25);
    Date dateNaiss = calendar.getTime();

    Personne personne = new Personne("Nom1", dateNaiss);
    System.out.println("date de naissance = " + personne.getDateNaissance());
    dateNaiss.setYear(114);
    System.out.println("date de naissance = " + personne.getDateNaissance());
    dateNaiss = personne.getDateNaissance();
    dateNaiss.setYear(114);
    System.out.println("date de naissance = " + personne.getDateNaissance());
  }
}
Résultat :
date de naissance = Fri Dec 25 00:00:00 CET 2015
date de naissance = Fri Dec 25 00:00:00 CET 2015
date de naissance = Thu Dec 25 00:00:00 CET 2014

Pour éviter cela, il faut aussi que les getters renvoient une copie défensive de l'objet mutable.

Exemple :
package fr.jmdoudoux.dej.thread;

import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;

public class Personne {

  private String nom;
  private Date   dateNaissance;

  public Personne(String nom, Date dateNaissance) {
    super();
    this.nom = nom;
    this.dateNaissance = new Date(dateNaissance.getTime());
  }

  public String getNom() {
    return nom;
  }

  public Date getDateNaissance() {
    return new Date(dateNaissance.getTime());
  }

  public static void main(String[] args) {

    Calendar calendar = new GregorianCalendar(2015, 11, 25);
    Date dateNaiss = calendar.getTime();

    Personne personne = new Personne("Nom1", dateNaiss);
    System.out.println("date de naissance = " + personne.getDateNaissance());
    dateNaiss.setYear(114);
    System.out.println("date de naissance = " + personne.getDateNaissance());
    dateNaiss = personne.getDateNaissance();
    dateNaiss.setYear(114);
    System.out.println("date de naissance = " + personne.getDateNaissance());
  }
}
Résultat :
date de naissance = Fri Dec 25 00:00:00 CET 2015
date de naissance = Fri Dec 25 00:00:00 CET 2015
date de naissance = Fri Dec 25 00:00:00 CET 2015

Il est aussi important de bien choisir la façon dont la copie défensive va être effectuée.

Par exemple, dans le cas de classe Date, trois solutions sont envisageables :

  • invoquer un constructeur (comme utilisé dans l'exemple ci-dessus)
  • cloner l'objet. Cette solution ne devrait être mise en oeuvre que pour des objets dont le type est final pour éviter que l'instance obtenue soit un sous-type.
  • stocker la date en interne sous la forme d'un entier long obtenu par un appel à la méthode getTime() de la classe Date et retourner une nouvelle instance de la Date créée avec le constructeur qui attend en paramètre un entier long

Comme les tableaux sont des objets et qu'un tableau dont la taille est différente de zéro est mutable, ils peuvent aussi être concernés par la mise en oeuvre de la copie défensive.

La méthode copyOf() de la classe Arrays permet d'obtenir une copie d'un tableau.

Exemple :
package fr.jmdoudoux.dej.thread;

import java.util.Arrays;

public class Personne {

  private String nom;
  private int[]  valeurs;

  public Personne(String nom, int[] valeurs) {
    super();
    this.nom = nom;
    this.valeurs = Arrays.copyOf(valeurs, valeurs.length);
  }

  public String getNom() {
    return nom;
  }

  public int[] getValeurs() {
    return Arrays.copyOf(valeurs, valeurs.length);
  }
}

Il ne faut pas utiliser systématiquement la copie défensive :

  • il y a des cas où l'objet peut être partagé
  • elle ne concerne que des objets qui sont mutables 

L'utilisation de la copie défensive peut aussi avoir un coût en termes de performance car cela peut engendrer la création de nombreux objets, dont le coût peut être plus ou moins important, qui devront de surcroît être récupérés par le ramasse-miettes.

 


[ Précédent ] [ Sommaire ] [ Suivant ] [Télécharger ]      [Accueil ]

78 commentaires Donner une note à l´article (5)

 

Copyright (C) 1999-2022 Jean-Michel DOUDOUX. Vous pouvez copier, redistribuer et/ou modifier ce document selon les termes de la Licence de Documentation Libre GNU, Version 1.1 ou toute autre version ultérieure publiée par la Free Software Foundation; les Sections Invariantes étant constitués du chapitre Préambule, aucun Texte de Première de Couverture, et aucun Texte de Quatrième de Couverture. Une copie de la licence est incluse dans la section GNU FreeDocumentation Licence. La version la plus récente de cette licence est disponible à l'adresse : GNU Free Documentation Licence.