3. Concurrence d'accès

3.1. Notion d'état

L'état d'un objet est simplement l'ensemble des valeurs prises par toute ou partie des différents champs qui le composent. Il peut arriver que la valeur de certains champs puisse être définie par la valeurs d'autres champs. Stocker cette valeur déduite permet d'éviter d'avoir à la calculer à nouveau à chaque demande, ce mécanisme est une sorte de cache : on cache le résultat d'un calcul dans un champ, dont on sait que sa valeur ne changera que si l'un des champs qui participe à ce calcul voit sa valeur changer. La valeur de certains champs peut aussi dépendre d'objets en relation de cet objet. L'état d'un objet peut-il changer au fil de l'exécution de l'application ? On ne peut que répondre au cas par cas à cette question. Si la classe de cet objet est immutable , la réponse est non. Certains de ces champs peuvent avoir une valeur fixée une fois pour toute à la construction de l'objet. Ces objets ne peuvent donc pas changer d'état au cours de l'application. C'est le cas de la classe String, comme nous l'avons déjà vu. La protection de l'état d'un objet est l'un des enjeux de la programmation concurrente, au centre de la notion de thead-safety .

3.2. Exemple de concurrence d'accès sur un état

L'une des plus grandes difficultés de la programmation concurrente, est de maintenir l'état de nos objets cohérent, même lorsque plusieurs fils d'exécution les partagent. Prenons un exemple simple, qui illustre le fonctionnement de cette notion d'état. Considérons un immeuble, composé de vingt appartements. Cet immeuble possède une unique chaudière, qui permet de chauffer l'ensemble de ces appartements. Chaque appartement est muni d'un thermostat, qui permet aux occupants de cet appartement de demander une température plus chaude à la chaudière. Pour éviter de transformer notre immeuble en étuve, chaque thermostat est équipé d'une sécurité, qui empêche de trop augmenter la température. Nous pouvons modéliser ce problème avec deux classes :
  • une classe Chaudiere, qui possède une temperature ;
  • une classe Thermostat, qui interroge la chaudière, et augmente la température si celle-ci n'est pas déjà trop haute.
Commençons par écrire notre classe Chaudiere.

Exemple 59. Accès concurrent à un état : classe Chaudiere

public  class Chaudiere {

    // un unique champ privé : l'état de notre chaudière
    private  int temperature ;

    public Chaudiere() {
       this.temperature =  0 ;
   }

    public  int getTemperature() {
       return  this.temperature ;
   }

    public  void augmenteTemperature() {
       this.temperature =  this.temperature +  1 ;
   }
}

Écrivons à présent notre classe Thermostat.

Exemple 60. Accès concurrent à un état : classe Thermostat

public  class Thermostat {

    private Chaudiere chaudiere ;

    // un thermostat s'adresse à une unique chaudiere
    public Thermostat(Chaudiere chaudiere) {
       this.chaudiere = chaudiere ;
   }

    public  boolean plusChaud() {

       // la temperature limite est de 25°
       if (chaudiere.getTemperature() <  25) {

          // l'appel à cette méthode indique simplement à la machine Java 
          // qu'elle doit interrompre momentanément l'exécution de ce thread, 
          // et passer la main au thread suivant
         Thread.yield() ;
         chaudiere.augmenteTemperature() ;
          return true ;
      }
   
       return false ;
   }
}

Le fonctionnement de la classe Thermostat est simple. Une instance de cette classe est construite sur une instance de Chaudiere. Une unique méthode est exposée, qui demande l'augmentation de la température à la chaudière. Si la température de la chaudière est inférieure à 25°, alors cette augmentation est acceptée et la méthode plusChaud() retourne true, sinon cette méthode retourne false. Faisons à présent fonctionner notre système dans une méthode main(). Chaque habitant d'un appartement est un thread , qui possède un thermostat.

Exemple 61. Accès concurrent à un état : méthode main()

// construction de notre unique chaudiere
 final Chaudiere chaudiere =  new Chaudiere() ;

 // construction d'un habitant = instance de Runnable
Runnable habitant =  new Runnable() {

    public  void run() {
   
       // chaque habitant possède son propre thermostat
      Thermostat thermostat =  new Thermostat(chaudiere) ;

       int nTry =  0 ;
       do {
         
          // il demande a monter la temperature
         thermostat.plusChaud() ;
         nTry++ ;
         
          // on lui donne le droit de le faire 5 fois
      }  while (nTry <  5) ;
   }
}

 // construction de nos habitants
Thread [] habitants =  new Thread[20] ;

 // création de nos habitants
 for (int i =  0 ; i < habitants.length ; i++) {
   habitants[i] =  new Thread(habitant) ;
}

 // lancement de nos habitants
 for (int i =  0 ; i < habitants.length ; i++) {
   habitants[i].start() ;
}

 // on attend que chaque thread ait fini son exécution
 for (int i =  0 ; i < habitants.length ; i++) {
    // jette InterruptedException
   habitants[i].join() ;
}

 // on affiche la température de notre chaudière
System.out.println("Temperature finale = " + chaudiere.getTemperature()) ;

Heureusement que notre chaudière possède une sécurité, car nos vingt habitants demandent en tout 100 montées de température. On utilise dans ce code la méthode join() de la classe Thread, qui peut jeter une exception de type InterruptedException. Cette méthode a pour but d'attendre l'arrêt de l'exécution de ce thread . Elle rend la main quand ce thread a fini de s'exécuter. On pourra tenter d'exécuter ce code plusieurs fois. Et on a toutes les chances de constater que la température finale de notre chaudière est bien supérieure aux 25° auxquels on pourrait s'attendre ! Même si l'on met la ligne Thread.yield() en commentaires, on peut observer, plus rarement, des températures de 26° et plus. Comment cela est-il possible ?

3.3. Analyse de la concurrence d'accès

Plusieurs principes et bonnes pratiques ont en fait été violés dans cet exemple, volontairement et à titre d'exemple. Comment la température de notre chaudière a-t-elle pu passer au-dessus de la limite que nous avons imposée ? Examinons le morceau de code qui pose problème.

Exemple 62. Accès concurrent à un état : code pathologique

public  boolean plusChaud() {
	
    if (chaudiere.getTemperature() <  25) {
      
      chaudiere.augmenteTemperature() ;
       return true ;
   }

    return false ;
}

Le problème se trouve dans ce code, entre le moment où l'on teste la valeur de la température, et le moment ou l'on demande son augmentation. Rappelons que c'est la machine Java qui décide quel thread peut s'exécuter à un instant donné. Elle a également la responsabilité de veiller à ce que chaque thread puisse s'exécuter à son tour, et à partager équitablement les ressources disponibles. Son rôle est donc aussi d'interrompre les threads en exécution, momentanément, pour passer la main à ceux qui sont attente. C'est ce qui se passe ici, au plus mauvais moment. Supposons que notre température soit de 24°. Un premier thread T1 exécute le test de température. Ce test répond true, T1 entre donc dans le bloc d'appel à l'augmentation de température. Avant que cette incrémentation ait pu avoir lieu, la machine Java suspend l'exécution de T1, et passe la main à T2. Cet autre thread effectue le test de température, qui répond toujours true. Le thread T2 entre donc dans le bloc, et va pouvoir augmenter la température. Mais avant qu'il puisse le faire, la machine Java l'interrompt encore, et passe la main à T3, qui va pouvoir, lui aussi, entrer dans le bloc d'augmentation de la température. Au bout d'un moment, la machine Java va rendre la main à T1, puis T2, puis T3. Chacun de ces threads va augmenter la température, puisqu'il a déjà passé le test. À l'issue de cette exécution, la température sera de 27°. Si l'on poursuit l'analyse un peu plus loin, on se rend compte qu'il existe un autre point sur lequel la machine Java peut interrompre l'exécution de nos threads . Il se trouve entre la lecture du champ temperature et son incrémentation. Pour cette explication, nous nous sommes placé dans le cas où tous les threads sont exécutés sur le même processeur. Sur une machine multiprocesseurs (ou multicœurs, ce qui revient au même), la réalité est un peu plus complexe, du fait que le champ temperature n'est pas volatile.

3.4. Solution au problème

Il n'y a qu'une seule solution à ce problème : empêcher qu'un thread T2 puisse entrer dans le bloc de test et d'augmentation de la température si un thread T1 s'y trouve déjà. On peut parler ici d' atomicité : on souhaite que les deux opérations :
  1. test de la valeur de la température ;
  2. augmentation de la température,
ne soient pas séparables, qu'elles ne puissent s'exécuter qu'en une seule fois. La solution générale proposée par Java pour résoudre ce problème consiste à utiliser la notion de verrou . Il existe plusieurs manières d'utiliser un verrou en Java. Voyons ici la première, et la plus simple, elle consiste à synchroniser la méthode plusChaud().

Exemple 63. Classe Thermostat corrigée

public  class Thermostat {

    private Chaudiere chaudiere ;

    // un thermostat s'adresse à une unique chaudiere
    public Thermostat(Chaudiere chaudiere) {
       this.chaudiere = chaudiere ;
   }

    public  boolean plusChaud() {

       synchronized(chaudiere) {

          if (chaudiere.getTemperature() <  25) {

            chaudiere.augmenteTemperature() ;
             return true ;
         }
      }
   
       return false ;
   }
}

Avec cette classe corrigée, notre problème d'augmentation de la température au-delà de la limite n'est plus observé. Cette façon de synchroniser l'accès à l'augmentation de la température permet de régler notre problème. Cela dit, on peut mieux faire, comme nous le verrons dans la suite.

3.5. Champs volatile

On peut déclarer un champ volatile en Java. Mais ne nous égarons pas, cela ne signifie pas, comme on peut l'entendre parfois, que ce champ est un poulet. Ce mot-clé ne peut se poser que sur un champ, on ne peut pas déclarer une méthode comme étant volatile. Afin de comprendre son utilité, analysons le fonctionnement de deux threads qui partagent le même champ. Supposons que ces deux threads T1 et T2 soient en train de s'exécuter au même moment, sur deux processeurs différents, ou deux cœurs différents d'un même processeur. Cette situation était peut-être rare il y a quelques années, mais avec le temps, elle devient de plus en plus fréquente. Si l'on n'y prend pas garde, et si l'on ne fait rien pour l'empêcher, chacun de nos deux threads peut choisir de copier la valeur de ce champ localement à son processeur, par exemple dans un registre. De cette façon, les accès à la valeur de ce champ seront beaucoup plus rapides. Montrons le problème que cela peut poser. Supposons que T1 fixe une valeur pour ce champ. Dans le contexte que l'on vient de décrire, cette valeur est écrite dans un registre du processeur qui exécute T1, que T2 n'a aucune chance de voir. Il en va de même pour T2, qui peut modifier ce champ, sans que T1 puisse en être informé. T1 et T2 peuvent donc être dans une situation où, au même moment, ils voient chacun une valeur différente pour ce champ, alors qu'il devrait s'agir de la même en permanence. Une solution à ce problème est de synchroniser les blocs dans lesquels le champ en question est modifié. Effectivement, lorsqu'un thread sort d'un bloc synchronisé, alors toutes les variables qu'il a modifiées sont automatiquement publiées auprès de tous les autres threads qui les lisent. Ce comportement fait partie de la sémantique de fonctionnement de la synchronisation. Cette solution est valide, mais coûteuse. Le caractère volatile d'un champ apporte une autre solution, plus légère. Un champ déclaré volatile ne se comporte pas comme un champ normal. Toute écriture sur un de ces champs est appelée lecture volatile , et toute écriture est une écriture volatile . La règle est que toute lecture volatile d'un champ retourne la valeur fixée par la dernière écriture volatile qui a eu lieu sur ce champ. Cette règle s'applique systématiquement, indépendamment du thread dans lequel cette lecture ou cette écriture à eu lieu. En déclarant un champ volatile, on a donc la garantie que la valeur de ce champ, vue de n'importe quel thread est toujours à jour. L'intérêt est que l'accès à un champ volatile n'est pas synchronisé, et donc plus performant que l'accès à un champ qui se ferait au travers d'un bloc synchronisé. Dans la pratique, déclarer un champ volatile fait que la machine Java ne stockera pas ce champ dans un registre, ce qui rendra les lectures de ce champ légèrement moins performantes. Notons deux erreurs qu'il faut éviter de commettre :
  • la lecture ou l'écriture d'un champ volatile n'est pas synchronisée ;
  • les opérations sur un champ volatile ne sont pas atomiques.
On dit que la synchronisation garantit à la fois l'atomicité et la visibilité, alors que la volatilité ne garantit que la visibilité.
Java API avancées
Retour au blog Java le soir
Cours & Tutoriaux
Table des matières
API Collection
1. Introduction
2. Interface Collection
2.1. Notion de Collection
2.2. Détail des méthodes disponibles
2.3. Interface Iterator
2.4. Implémentation, exemples d'utilisation
3. Interface List
3.1. Notion de List
3.2. Détail des méthodes disponibles
3.3. Interface ListIterator
3.4. Implémentations, exemples d'utilisation
4. Interface Set
4.1. Notion de Set
4.2. Implémentations HashSet et LinkedHashSet
4.3. Exemples d'utilisation
5. Interface SortedSet
5.1. Notion de SortedSet
5.2. Détails des méthodes disponibles
5.3. Exemples d'utilisation
6. Interface NavigableSet
6.1. Notion de NavigableSet
6.2. Détails des méthodes disponibles
6.3. Exemple d'utilisation
7. Interfaces Queue et Deque
7.1. Notion de file d'attente
7.2. Détail des méthodes disponibles
7.3. Utilisation des interfaces Queue et Deque
8. Tables de hachage
8.1. Notion de table de hachage
8.2. Interface Map
8.3. Interface Map.Entry
8.4. Interface SortedMap
8.5. Interface NavigableMap
8.6. Implémentations
8.7. Exemples d'utilisation
9. Classes utilitaires Collections et Arrays
9.1. Introduction
9.2. Classe Arrays
9.3. Classe Collections
Génériques
1. Introduction
2. Un premier exemple
2.1. Une première classe générique
2.2. Une première méthode générique
3. Contraindre un type générique
3.1. Problème posé
3.2. Contraindre un type générique
4. Implémentation des génériques
4.1. Type erasure
4.2. Types génériques et casts
4.3. Type générique et exception
4.4. Construction d'une instance générique
4.5. Génériques et membres statiques
4.6. Collisions de méthodes génériques
4.7. Implémentation de plusieurs types identiques
5. Type <?>
5.1. Introduction
5.2. Type ? extension d'un type
5.3. Type ? super-type d'un type
Expressions régulières
1. Introduction
2. Mise en œuvre des expressions régulières
2.1. Fonctionnement d'une regexp
2.2. Fonctionnement de l'API en Java
2.3. Un premier exemple
2.4. Classe Pattern
2.5. Classe Matcher
2.6. Utilisation des méthode find() et group()
2.7. Méthodes de remplacement
2.8. Sélection de régions
3. Syntaxe des expressions régulières
3.1. Notion de classe
3.2. Étude d'un cas réel
3.3. Recherche d'un mot précis
3.4. Recherche de deux mots précis
3.5. Recherche d'un mot commençant par une lettre donnée
3.6. Cas de mots comportant des caractères accentués
3.7. Recherche sur les lignes
Introspection
1. Introduction
2. La classe Class
2.1. Utilisation de Class
2.2. Méthodes disponibles
2.3. Remarque sur la propriété Accessible
2.4. Type d'une classe
2.5. Création d'une instance à partir d'un objet Class
2.6. Cas des énumérations
3. Les classes Method et Constructor
3.1. Utilisation de Method
3.2. Utilisation de Constructor
3.3. Méthodes disponibles
3.4. Invocation d'une méthode par introspection
4. La classe Field
4.1. Utilisation de Field
4.2. Méthodes disponibles
4.3. Accès à un champ par introspection
5. La classe Modifier
Programmation concurrente
1. Introduction
2. Lançons nos premiers threads
2.1. Introduction
2.2. Un premier thread, extension de Thread
2.3. Un deuxième thread, implémentation de Runnable
2.4. Remarque sur la méthode Thread.sleep(long)
2.5. Arrêter un thread
3. Concurrence d'accès
3.1. Notion d'état
3.2. Exemple de concurrence d'accès sur un état
3.3. Analyse de la concurrence d'accès
3.4. Solution au problème
3.5. Champs volatile
4. Synchronisation
4.1. Définition d'un bloc synchronisé
4.2. Fonctionnement d'un bloc synchronisé
4.3. Notion de deadlock
4.4. Bonnes pratiques pour la synchronisation de threads
5. Opérations atomiques
5.1. Atomicité d'une opération
5.2. Solutions disponibles
5.3. Variables atomiques
6. Collections synchronisées et concurrentes
6.1. Introduction
6.2. Position du problème
6.3. Solutions proposées
7. Files d'attente
7.1. Introduction, pattern producteur / consommateur
7.2. Interface BlockingQueue<E>
7.3. Implémentations de BlockingQueue
7.4. Exemple de producteur / consommateur
7.5. Arrêter un producteur / consommateur : pilule empoisonnée
8. Classes utilitaires de l'API Concurrent
8.1. Introduction
8.2. Énumération TimeUnit
8.3. Interface Callable<V>
8.4. Interfaces Future<V> et RunnableFuture<V>
8.5. Interface ScheduledFuture<V> et RunnableScheduledFuture<V>
9. Pattern executor
9.1. Notion de réserve de threads
9.2. Interface Executor
9.3. Interface ExecutorService
9.4. Interface ScheduledExecutorService
9.5. Classe Executors
9.6. Pattern de lancement de tâches
10. Classes de contrôle d'accès
10.1. Introduction
10.2. Interfaces Lock et ReadWriteLock
10.3. Notion de verrou réentrant
10.4. Classe RentrantLock
10.5. Classe ReadWriteRentrantLock
11. Sémaphores, barrières et latches
11.1. Introduction
11.2. Notion de sémaphore, classe Semaphore
11.3. Notion de latch, classe CountDownLatch
11.4. Notion de barrière, classe CyclicBarrier