5. Opérations atomiques

5.1. Atomicité d'une opération

5.1.1. Exemple de cas pathologique

Comme nous l'avons déjà vu, une opération atomique est une opération composée éventuellement de plusieurs actions, qui se déroule sans pouvoir être interrompue, en particulier par un autre thread . Nous avons vu l'exemple de notre chaudière, qui, sans synchronization pouvait se mettre rapidement en surchauffe. Voyons un autre exemple, plus simple que celui-ci. Créons une classe Compteur, qui ne fait qu'incrémenter une valeur.

Exemple 68. Atomicité d'une opération : classe SimpleCompteur

public  class SimpleCompteur {

    private  static  int compte ;

    public  void compte() {
      compte++ ;
   }

    public  static  int getCompte() {
       return compte ;
   }
}

Utilisons cette classe dans une méthode main().

Exemple 69. Atomicité d'une opération : utilisation de SimpleCompteur

// dans une méthode main()

 // définition d'un thread de comptage
Runnable compte =  new Runnable() {

    public  void run() {

       // chaque thread possède son propre compteur
      SimpleCompteur compteur =  new SimpleCompteur() ;
       for (int i =  0 ; i <  100 ; i++) { 
         compteur.compte() ;
      }
   }
} ;

 // on lance 5 threads, qui comptent chacun jusqu'à 100
Thread [] compteurs =  new Thread[5] ;

 // initialisation des threads
 for (int i =  0 ; i < compteurs.length ; i++) {
   compteurs[i] =  new Thread(compte) ;
}

 // lancement des threads
 for (int i =  0 ; i < compteurs.length ; i++) {
   compteurs[i].start() ;
}

 for (int i =  0 ; i < compteurs.length ; i++) {
    // jette une InterruptedException
   compteurs[i].join() ;
}

 // vérification de la valeur de compte
System.out.println("Compteur = " + SimpleCompteur.getCompte()) ;

Si l'on lance cette application, on a toutes les chances d'avoir un résultat qui ressemble à celui-ci :
 Compteur = 352
La valeur 352 n'est pas stable : si l'on lance cette application, plusieurs fois, on verra qu'elle peut changer. De temps en temps, si l'on a de la chance, il se peut même qu'elle soit égale à 500 ! Pourtant c'est bien à cette valeur que l'on s'attend, qu'y a-t-il d'incorrect dans notre application ?

5.1.2. Analyse du problème

Le problème vient de ce que l'opération d'incrémentation compte++ n'est pas atomique. Quand bien elle en a l'air, ce qui se passe réellement est la chose suivante :
  1. lecture de la valeur de compteur ;
  2. incrémentation de la valeur de compteur ;
  3. enregistrement de la nouvelle valeur compteur.
Ce que l'on observe vient du fait qu'à un moment (et ce moment arrive souvent dans notre exemple !), un thread T1 lit la valeur de compteur et la stocke. À cet instant précis il est interrompu par un thread T2 qui fait la même chose. Les deux threads T1 et T2 ont donc à cet instant la même valeur numérique pour le champ compteur. Ils l'incrémentent tous les deux, et recopient cette valeur dans la classe SimpleCompteur ( compteur est un champ statique). Notre champ compteur a donc subi deux opérations d'incrémentation, mais la seconde possédait une valeur obsolète. Cela donne l'impression qu'il n'a été incrémenté qu'une seule fois.

5.2. Solutions disponibles

5.2.1. Synchronisation de l'accès

La première solution que l'on peut envisager, et qui est parfaitement correcte, consiste à synchroniser l'accès au champ compteur. Modifions donc notre classe SimpleCompteur.

Exemple 70. Classe SimpleCompteur synchronisée

public  class SimpleCompteur {

    private  static  int compte ;
   
    // objet de synchronization
    private  static Object key =  new Object() ;

    public  void compte() {
       synchronized(key) {
         compte++ ;
      }
   }

    public  static  int getCompte() {
       return compte ;
   }
}

Si l'on n'avait pas déclaré notre objet de synchronisation static, alors la synchronisation n'aurait pas fonctionné. Pourquoi ? Chaque thread lancé possède sa propre instance de la classe SimpleCompteur. L'objet qui garde un bloc synchronisé doit obligatoirement être le même pour tous les threads qui tentent d'exécuter ce bloc. Si l'on ne déclare pas ce champ static, il sera propre à chaque thread , et sa clé sera donc toujours disponible. Cette version de la classe SimpleCompteur fonctionne, mais pose les problèmes que nous avons déjà évoqués : perte de performance du fait d'un goulet d'étranglement. Si le nombre de threads est très grand, ce problème peut devenir important.

5.2.2. Utilisation d'un champ atomique

L'API Java Concurrent expose des objets particuliers pour traiter précisément ce genre de problèmes. Ici nous allons utiliser la classe AtomicInteger.

Exemple 71. Classe SimpleCompteur utilisant un AtomicInteger

public  class SimpleCompteur {

    private  static AtomicInteger compte =  new AtomicInteger(0) ;
   
    public  void compte() {
      compte.incrementAndGet() ;
   }

    public  static  int getCompte() {
       return compte.intValue() ;
   }
}

La méthode incrementAndGet() incrémente l'entier stocké dans l'instance d' AtomicInteger de façon atomique. Notre application fonctionne donc correctement. Si l'on augmente le nombre de threads et d'incrémentations, on se rend compte que cette version de notre application exemple est environ deux fois plus rapide à s'exécuter que si l'on utilise un bloc synchronisé.

5.3. Variables atomiques

L'API Concurrent nous donne, dans le package java.util.concurrent.atomic, un certain nombre de classes dont certaines permettent de gérer des variables atomiques. Dans leur version fournie avec le JDK, ces variables n'utilisent pas de synchronisation, ce qui leur permet d'être très performantes. Il faut noter qu'elles ne sont pas faites pour remplacer les classes enveloppe des types de base ( Integer, Float, etc...). Notamment ces classes ne surchargent pas la méthode equals() et ne sont pas Comparable. On ne peut donc raisonnablement pas les utiliser comme clé dans des tables de hachage. Voyons ces classes.
  • AtomicBoolean, AtomicInteger, AtomicLong et AtomicReference : permettent de gérer les booléens, les entiers 32 bits et 64 bits, ainsi que les références sur d'autres objets.
  • AtomicIntegerArray, AtomicLongArray et AtomicReferenceArray : permettent de gérer des tableaux d'entiers 32 et 64 bits, et les tableaux de références sur d'autres objets.
Ces classes exposent toutes des méthodes qui fonctionnent sur le même principe. Examinons celles de AtomicInteger. Cette classe expose deux constructeurs : le premier ne prend pas d'argument, et encapsule un entier de valeur 0, le second prend un int en argument, dont la valeur sera celle encapsulée par cet AtomicInteger.
  • get(), intValue(), longValue(), floatValue(), doubleValue() : permettent de retourner la valeur encapsulée sous différentes formes.
  • set(int newValue) : remplace la valeur encapsulée courante par celle passée en paramètre.
  • incrementAndGet(), decrementAndGet() et addAndGet(int delta) : ces trois méthodes retournent un int, dont la valeur est le résultat de l'incrémentation, de la décrémentation, et de l'ajout de delta à la valeur encapsulée. Ces opérations sont atomiques.
  • getAndIncrement(), getAndDecrement() et getAndAdd(int delta) : ces trois méthodes retournent un int, dont la valeur est la valeur encapsulée, puis incrémente, décrémente ou ajoute la valeur delta à cette valeur. Ces opérations sont atomiques.
  • compareAndSet(int expect, int value) : compare la valeur encapsulée avec expect, si ces deux valeurs sont égales, alors la valeur encapsulée devient value. Dans le cas d'objets, la comparaison utilise == et non pas la méthode equals().
Les classes atomiques construites sur des tableaux prennent en plus un index comme paramètre pour ces méthodes, qui indique sur quel élément du tableau l'opération est menée.
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