4. Synchronisation

4.1. Définition d'un bloc synchronisé

On appelle bloc synchronisé, tout bloc portant une déclaration synchronized. On peut poser le mot-clé synchronized de trois façons. Pour chacune de ces façons, le bloc synchronisé fonctionne avec un paramètre de synchronisation.
  • On peut ajouter le mot-clé synchronized en tant que modificateur d'une méthode statique. Dans ce cas le paramètre de synchronisation est la classe qui possède ce bloc.
  • On peut l'ajouter comme modificateur d'une méthode non statique. Dans ce cas, le paramètre de synchronisation est l'instance de cette classe dans laquelle on se trouve. On peut accéder à cette instance par le mot-clé this.
  • On peut enfin créer un bloc commençant par le mot-clé synchronized. Dans ce cas, le paramètre de synchronisation doit être passé explicitement à l'ouverture de ce bloc. Ce peut être tout objet Java.

Exemple 64. Déclaration d'un bloc synchronisé

public  class Thermostat {

    private Object key =  new Object() ;

    // méthode statique synchronisée, le paramètre de synchronisation est
    // l'objet Thermostat.class
    public  static  synchronized  boolean getNombreThermostats() {
      
       // corps de la méthode
   }

    // méthode non statique synchronisée, le paramètre de synchronisation est
    // l'objet this
    public  synchronized  boolean plusChaud() {
      
       // corps de la méthode
   } 
   
    public  boolean plusFroid() {
      
       // synchronization sur l'objet key
       // on peut aussi synchroniser sur l'objet this
       synchronized(key) {
      
          // bloc synchronisé
      }
   }
}

4.2. Fonctionnement d'un bloc synchronisé

La paramètre du bloc synchronisé joue le rôle de garde pour ce bloc synchronisé. Cet objet possède une unique clé. Cette clé est nécessaire pour pouvoir entrer dans le bloc synchronisé, et exécuter les instructions qu'il contient. Quand un thread obtient cette clé, il entre dans le bloc, et exécute le code qui s'y trouve. À la sortie du bloc, il rend la clé à l'objet qui garde le bloc. Aucun thread ne peut entrer dans ce bloc s'il ne possède pas la clé qui permet d'y entrer. Supposons qu'un thread se présente à l'entrée du bloc, alors qu'un premier est dedans, en train de l'exécuter. La clé n'étant pas disponible, cet autre thread se verra refuser l'accès au bloc. Il devra attendre que le premier thread rende la clé afin de pouvoir y entrer à son tour. Tout objet Java possède une clé, il s'agit d'un mécanisme bas niveau, présent sur la classe Object. Lorsque le langage Java a été conçu, cette idée était considérée comme excellente, aujourd'hui elle est plutôt battue en brêche... Il n'empêche, cela explique que tout objet Java peut être utilisé pour garder un bloc d'instructions. Ce mécanisme de synchronization permet de résoudre le problème de concurrence d'accès que nous avons exposé. Cela dit, il pose un problème de performance. Effectivement, si un grand nombre de threads sont en concurrence d'exécution, tout bloc synchronisé devient un goulet d'étranglement dans lequel les thread ne peuvent passer qu'un par un. L'accès à l'état d'un objet, et la modification de cet état, est un des quelques problèmes cruciaux de la programmation concurrente. Jusqu'à la version 4, Java n'exposait pas beaucoup d'éléments pour traiter ces questions. Depuis la version 5, l'API s'est considérablement enrichie, comme nous allons le voir.

4.3. Notion de deadlock

Une mauvaise gestion de la synchronisation entre blocs peut mener à une situation de blocage total d'une application, appelée deadlock . Une deadlock intervient lorsqu'un premier thread T1 se trouve dans un bloc synchronisé B1, et est en attente à l'entrée d'un autre bloc synchronisé B2. Malheureusement, dans ce bloc B2, se trouve déjà un thread T2, en attente de pouvoir entrer dans B1. Ces deux threads s'attendent mutuellement, et ne peuvent exécuter le moindre code. La situation est bloquée, la seule façon de la débloquer est d'interrompre un des deux threads . Construisons un exemple de deadlock , afin d'illustrer la situation. Cet exemple fonctionnera avec deux classes : une classe Verre et une classe Carafe. Lorsque le verre se remplit, la carafe se vide, et réciproquement.

Exemple 65. Deadlock : classe Verre

public  class Verre {

    // permet de savoir si le verre est vide ou pas
    private  boolean plein = false ;
   
    // un verre a besoin d'une carafe pour se remplir
    private Carafe carafe ;

    // on ne peut se remplir que si la carafe est pleine
    public  synchronized  void remplis() {
       if (carafe.isPlein()) {
         plein = true ;
         carafe.setPlein(false) ;
      }
   }

    // et l'on ne peut se vider que si la carafe est vide
    public  synchronized  void vide() {
       if (!carafe.isPlein()) {
         plein = false ;
         carafe.setPlein(true) ;
      }
   }
   
    // permet au monde extérieur de sa voir
    // si nous sommes plein ou vide
    public  synchronized  boolean isPlein() {
       return  this.plein ;
   }

    // reste des getters non synchronisé
    public  void setPlein(boolean plein) {
       this.plein = plein ;
   }
	
    public Carafe getCarafe() {
       return carafe ;
   }
}

La classe Carafe ressemble beaucoup à la classe Verre.

Exemple 66. Deadlock : classe Carafe

public  class Carafe {

    // permet de savoir si la carafe est vide ou pas
    private  boolean plein = false ;
   
    // un verre a besoin d'un verre pour se vider
    private Verre verre ;

    // on ne peut se remplir que si le verre est plein
    public  synchronized  void remplis() {
       if (verre.isPlein()) {
         plein = true ;
         verre.setPlein(false) ;
      }
   }

    // et l'on ne peut se vider que si le verre est vide
    public  synchronized  void vide() {
       if (!verre.isPlein()) {
         plein = false ;
         verre.setPlein(true) ;
      }
   }
   
    // permet au monde extérieur de sa voir
    // si nous sommes plein ou vide
    public  synchronized  boolean isPlein() {
       return  this.plein ;
   }

    // reste des getters non synchronisé
    public  void setPlein(boolean plein) {
       this.plein = plein ;
   }
	
    public Verre getVerre() {
       return verre ;
   }
}

Il ne reste plus qu'à faire fonctionner ce modèle pour observer le deadlock .

Exemple 67. Deadlock : application

// dans une méthode main
 // initialisation d'une carafe et d'un verre, associés l'un à l'autre
 final Carafe carafe =  new Carafe() ;
 final Verre verre =  new Verre() ;
carafe.setVerre(verre) ;
verre.setCarafe(carafe) ;

 // création d'une instance de Runnable qui remplit et vide la carafe
 // dans le verre
 // on peut augmenter le nombre de cycles dans la boucle for
Runnable carafeApp =  new Runnable() {
	
    public  void run() {

       for (int i =  0 ; i <  20 ; i++) {
         carafe.remplis() ;
         carafe.vide() ;
      }
   }
} ;

 // création d'une instance de Runnable qui remplit et vide le verre
 // dans la carafe
 // on peut augmenter le nombre de cycles dans la boucle for
Runnable verreApp =  new Runnable() {

    public  void run() {

       for (int i =  0 ; i <  20 ; i++) {
         verre.remplis() ;
         verre.vide() ;
      }
   }
} ;

 // création des threads
Thread [] carafeApps =  new Thread [5] ;
Thread [] verreApps =  new Thread [5] ;

 // creation des threads pour le vidage de la carafe
 // et du verre
 for (int i =  0 ; i < carafeApps.length ; i++) {
   carafeApps[i] =  new Thread(carafeApp) ;
   verreApps[i] =  new Thread(verreApp) ;
}

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

 // attente de la fin de l'exécution des threads
 for (int i =  0 ; i < carafeApps.length ; i++) {
   carafeApps[i].join() ;
   verreApps[i].join() ;
}

Lancer cette application peut fonctionner, les deadlocks ne sont pas des phénomènes qui se produisent systématiquement. Cela dit, dans ce cas, on doit l'observer au bout de quelques tentatives : l'application reste bloquée. En mode débug, Eclipse nous permet de mettre en pause les threads d'une application un par un, afin de voir ce qu'ils font. Dans une situation de deadlock , il peut être très intéressant de le faire, et de regarder quels verrous chaque thread possède, et s'il est en attente, qui possède le verrou qu'il doit acquérir pour avancer.
Configurer Eclipse pour observer un deadlock

Figure 1. Configurer Eclipse pour observer un deadlock


Une fois Eclipse configuré, la mise en pause des bons threads va nous montrer une situation qui ressemblera à celle présentée sur cette figure.
Observation d'un deadlock avec Eclipse

Figure 2. Observation d'un deadlock avec Eclipse


Sur cette figure, on a ouvert les deux threads Thread-4 et Thread-7. Le Thread-4 est en train d'exécuter la méthode Carafe.vide(), qui appelle Verre.isPlein(). Ce thread possède le verrou de l'objet Carafe dont l'ID est 19, et il attend celui de l'objet Verre dont l'ID est 28. Il ne peut pas entrer dans le corps de la méthode Verre.isPlein() tant qu'il n'a pas ce verrou. Le Thread-7 est en train d'exécuter la méthode Verre.remplis() qui appelle Carafe.isPlein(). Il possède le verrou de l'objet Verre dont l'ID est 28. C'est ce verrou que le Thread-4 attend. Et il attend le verrou de l'objet Carafe dont l'ID est 29. Ce verrou est possédé par le Thread-4. On remarquera qu'Eclipse a mis ces deux threads en rouge, car il a détecté la situation de deadlock . Dans ce contexte, la seule façon de progresser est d'interrompre l'un des deux threads .

4.4. Bonnes pratiques pour la synchronisation de threads

Éviter les situations de deadlocks n'est pas toujours une chose facile. Néanmoins, on peut tenter de respecter quelques principes, qui permettent de limiter les risques.

4.4.1. N'utiliser la synchronisation qu'en cas de nécessité

L'API Concurrent propose de nombreuses techniques qui rendent la synchronisation inutile :
  • les variables atomiques ;
  • les collections non-bloquantes et thread-safe ;
  • les objets de synchronisation explicites, qui permettent de rendre la main quand l'acquisition d'un verrou prend trop de temps.
Ces objets n'étaient pas disponibles en Java 4 (du moins pas dans l'API standard), ils le sont depuis Java 5, et leur utilisation judicieuse permet de retirer nombre de clauses synchronized d'une application.

4.4.2. Ne pas exposer les verrous de synchronisation

L'objet paramètre d'un bloc synchronized ne devrait jamais être exposé. En particulier, synchroniser un bloc sur this ou sur la classe englobante est une très mauvaise idée. Le plus souvent, n'importe quel autre objet de l'application pourra obtenir une référence sur cet objet de synchronisation, et s'il lui vient la mauvaise idée de l'utiliser pour synchroniser d'autres blocs, des situations de deadlock pourront arriver. Il faut également se rappeler que les chaînes de caractères sont traitées de façon particulière par le compilateur. Ne jamais tenter de synchroniser un bloc en utilisant une chaîne de caractères est un principe absolu.

4.4.3. Documenter parfaitement les stratégies de synchronisation

Si l'objet de synchronisation est un champ privé de la classe qui possède ce bloc, alors il faut parfaitement documenter le fait que l'appel de certaines méthodes est synchronisé. Cela pourra prévenir les utilisateurs de ces méthodes, et éviter des situations de deadlock .

4.4.4. Ne pas imbriquer les appels synchronisés

Un bloc synchronisé ne devrait jamais faire appel à un objet collaborateur pour effectuer ses traitements. Ce principe est probablement complexe à appliquer, mais si l'on y arrive, aucune situation de deadlock ne pourra se présenter.
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