Blue Flower

Chercher

1. Verrous et synchronisation des threads

1.1. Les états d'un thread

Un thread est à tout moment dans un des cinq états suivants:

  1. création
    le thread vient d'être créé, mais n'a pas encore été exécuté; dans cet état on fait une déclaration pour créer un nouveau thread mais ce denier ne démarre pas tout de suite;
    "Thread monThread = new MonThreadClass(); "
  2. exécutable
    le thread est candidat à l'exécution, il attend que le système lui alloue le processeur pendant une certaine durée, appelée quantum de temps;
    "Thread monThread = new MonThreadClass();
    monThread.start();"
  3. en cours d'exécution
    le thread est en cours d'exécution par le processeur (sur un système monoprocesseur, un seul thread peut se trouver dans cet état),
  4. bloqué
    le thread a provoqué une opération bloquante ou s'est "endormi" volontairement pour une certaine durée; on est dans l'une des situations suivantes:
    Une application fait appel à sa méthode "suspend()",
    Une application appelle sa méthode "sleep()" ,
    un thread utilise sa méthode "wait()" pour attendre selon une condition liée à une variable,
    le thread est bloqué sur une I/O .
    Exemple:
    Thread myThread = new MyThreadClass();
    myThread.start();
    try {
    myThread.sleep(10000);
    } catch (InterruptedException e){
    }
  5. détruit
    le thread a fini son exécution ou a été arrêté par un autre thread.
cycle de vie
cycle de vie

Pour faciliter la gestion des différents états que peut traverser un thread, le langage java offre des mécanismes de régulation notamment des systèmes de verrou qui permettent de protéger certaines parties du code afin qu'elles soient exécutées par plusieurs threads de façon cohérente. La façon la plus simple (la plus facile) est de verrouiller certaines méthodes ou variables en les précédant du mot clé synchronized; ce mot "synchronized" en java assure:

  • Qu'un seul thread peut exécuter un bloc de code donné à un moment donné.
  • Que chaque thread qui entre dans un bloc de code "synchronized" est sûr de voir tous les effets des modifications prévues sur les variables partagées

1.2. La synchronisation

La synchronisation est nécessaire pour gérer l'exclusion mutuelle à l'accès d'un zone de code critique par plusieurs threads.
Le mot clé "synchronized" appelé modificateur de méthode d'instance contrôle les modifications de variables partagées au sein d'une zone critique . Cela assure qu' à chaque instant, un seul thread peut exécuter et modifier des variables dans zone critique les autres threads attendront leur tour.

Comme les threads d'une même Machine Virtuelle partagent le même espace mémoire, ils ont accès à n'importe quelles méthodes ou quels champs d'objets existant dans cette espace. En principe cela est très pratique, mais dans certains cas, il est préférable que deux threads n'aient pas accès n'importe quand à certaines données. Exemple, Un thread qui doit modifier une variable et un autre qui a besoin de la lire pour l'afficher ne doivent pas y avoir accès au même moment. Dans ce cas il est impératif de les synchroniser.

1.3. Moniteur

Dans le langage Java, tout objet (instance d'une classe) comporte un verrou intrinsèque ou verrou implicite qui peut empêcher que deux threads différents accèdent simultanément à un même objet lorsqu'une méthode de l'un des threads est déclarée synchronized sur cet objet. Ce verrou s'appelle moniteur; Le mot-clé synchronized permet de demander l'acquisition d'un moniteur donné pour la durée d'un bloc de code.
Si une ou plusieurs méthodes sont déclarées synchronized dans une classe, si on a un objet de cette classe et si l'une des méthodes "synchronized" est invoquée sur l'objet en question, deux cas se présentent:

  1. Soit l' objet n'est pas verrouillé
    Dans ce cas le système pose un verrou sur cet objet puis la méthode est exécutée normalement et quand la méthode est terminée, le système retire le verrou sur cet Objet.
  2. Soit l'objet est déjà verrouillé
    Si le thread courant n'est pas celui qui a verrouillé l'objet, le système met le thread courant dans l'état bloqué, tant que l'objet est verrouillé. Une fois que l'objet est déverrouillé, le système remet ce thread dans l'état exécutable, pour qu'il puisse essayer de verrouiller l'objet et exécuter la méthode "synchronized".

Le modificateur synchronized ne garantit pas l'ordre dans lequel les threads seront exécutés. Pour cela, il existe une méthode de la classe Object qui permet de mettre en attente volontairement un thread sur un objet; c' est la méthode "wait()", il existe une autre méthode de la classe Object qui permet de prévenir les threads en attente sur cet objet que celui-ci n'est plus verrouillé; ce sont les méthodes "notify()" (pour avertir un seul thread mis en attente sur l' objet verrouillé par synchronized )) ou "notifyAll()" (pour tous les threads mis en attente sur l' objet verrouillé par synchronized).
Ces méthodes ne peuvent être invoquées que sur un objet verrouillé par le thread courant, c'est-à-dire le thread qui est en train d'exécuter une méthode ou un bloc synchronized ( verrouillé sur cet objet). Si ce n'est pas le cas, une exception IllegalMonitorStateException est déclenchée.
Quand la méthode wait() est invoquée sur un objet (qui peut être this), le thread courant perd le contrôle; il est mis en attente et l'ensemble des verrous est retiré. Comme chaque objet Java mémorise l'ensemble des threads mis en attente sur lui, le thread courant est ajouté à la liste des threads en attente sur l'objet qui a invoqué "wait()".
Un thread mis en attente est retiré de la liste d'attente quand une des trois raisons suivantes survient :

  1. Le thread1 a été mis en attente en donnant en argument à la méthode "wait()" un délai qui a fini de s'écouler,
  2. Le thread courant a invoqué la méthode "notify()" sur objet1, et thread1 a été choisi parmi tous les threads en attente,
  3. Le thread courant a invoqué la méthode "notifyAll()" sur l'objet. Le thread est mis alors dans l'état exécutable, et essaie de verrouiller l'objet, pour continuer son exécution. Quand il devient le thread courant, l'ensemble des verrous qui avait été enlevé de l'objet à l'appel de la méthode "wait ()" est remis sur l'objet, pour que le thread et l'objet se retrouvent dans le même état qu'avant l'invocation de "wait()".

Un thread mis en attente en utilisant la méthode "wait()" sans argument sur un objet, ne peut redevenir exécutable qu'une fois qu'un autre thread a invoqué "notify()" ou "notifyAll()" sur ce même objet. Donc la méthode "wait()" doit toujours être utilisé avec la méthode "notify()", et être invoqué avant cette dernière méthode.

Qu'est ce qui se passe si notify() est appelé et qu'il n' y a pas de thread mis en attente par wait()?

Dans la pratique ce cas ne peut pas avoir lieu; toutefois si cela arrive, la méthode notify() n'a aucun effet et la notification est perdue.

Exemple d'utilisation de wait()/notify()

Dans notre exemple on commence par créer une classe qui gère un message; cette classe sera utilisée dans deux autre classes à savoir la classe Waiter et la classe Notifier.

La classe Waiter implémente l'interface Runnable. Un objet Waiter attend un objet Notifier pour terminer son exécution sur l'objet Message; cela est rendu possible par la le clé "synchronized" sur l'objet Message.

La classe Notifier implémente l'interface Runnable. L'objet Notifier est endormi pendant 3 secondes. Après ça, il modifie le message et puis notifie le thread en attente sur l'objet Message qu'il peut reprendre son exécution; Ceci est fait au sein d'un "bloc synchronized" synchronisé sur l'objet Message.

La classe WaitNotifyExample crée un objet Message. Elle crée aussi un objet Waiter avec l'objet Message. Elle crée et démarre un thread avec l'objet Waiter. Après ça, elle crée un objet Notifier avec l'objet Message. elle crée et démarre un autre thread avec l'objet Notifier.

package com.omara;

public class Message {
	private String text;
	public Message(String text) {
		this.text = text;
	}
	public String getText() {
		return text;
	}
	public void setText(String text) {
		this.text = text;
	}
}
//+++++++++++++++++++++++++++++
package com.omara; import java.util.Date; public class Waiter implements Runnable { Message message; public Waiter(Message message) { this.message = message; } @Override public void run() { synchronized (message) { try { System.out.println("Waiter is waiting for the notifier at " + new Date()); message.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } System.out.println("Waiter is done waiting at " + new Date()); System.out.println("Waiter got the message: " + message.getText()); } } //++++++++++++++++++++++++++ package com.omara; import java.util.Date; public class Notifier implements Runnable { Message message; public Notifier(Message message) { this.message = message; } @Override public void run() { System.out.println("Notifier is sleeping for 3 seconds at " + new Date()); try { Thread.sleep(3000); } catch (InterruptedException e1) { e1.printStackTrace(); } synchronized (message) { message.setText("Notifier took a nap for 3 seconds"); System.out.println("Notifier is notifying waiting thread to wake up at " + new Date()); message.notify(); } } } //++++++++++++++++++++++++++ package com.omara; public class WaitNotifyExample { public static void main(String[] args) { Message message = new Message("Howdy"); Waiter waiter = new Waiter(message); Thread waiterThread = new Thread(waiter, "waiterThread"); waiterThread.start(); Notifier notifier = new Notifier(message); Thread notifierThread = new Thread(notifier, "notifierThread"); notifierThread.start(); } }

Voir ci-dessous l'affichage de l'exécution du programme. En regardant la sortie après l'exécution du programme WaitNotifyExampleFrom, on note que le le thread Waiter est mis en attente pendant que le thread Notifer détient le verrou sur l'objet message. Lorsque le Thread Notifier notifie qu'il a fini , l'exécution du thread Waiter reprend.

Affichage du résultat de l' exécution de WaitNotifyExample:

Waiter is waiting for the notifier at Tue May 19 17:52:57 PDT 2009
Notifier is sleeping for 3 seconds at Tue May 19 17:52:57 PDT 2009
Notifier is notifying waiting thread to wake up at Tue May 19 17:53:00 PDT 2009
Waiter is done waiting at Tue May 19 17:53:00 PDT 2009
Waiter got the message: Notifier took a nap for 3 seconds

 

1.4. 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.

1.5. 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 considérée comme insuffisante. Il n'empêche, cela explique que tout objet Java peut être utilisé pour garder un bloc d'instructions.
Ce mécanisme de synchronisation permet de résoudre le problème d'accès de façon concurrente à une ou plusieurs variable 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 threads 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 (voir Concurrence: Amélioration).

1.6. Exemple avec synchronized

Pour illustrer le fonctionnement du mot "synchronized" on va définir deux versions d'une classe appelée Counter; on utilise cette classe dans un programme appelé "CounterTest."

Première version de Counter sans synchronisation:

public class Counter {
    private int value = 0;
    public void increment() {
        value++;
    }
    public int getValue() {
        return value;
    }
}

Pour tester le comportement de ce compteur, le programme CounterTest ci-dessous lance NB_THREADS threads qui effectuent chacun NB_INCREMENTS appels à la méthode "increment()". A la fin du programme, on s'attend donc à ce que la valeur du compteur soit de "NB_THREADS * NB_INCREMENTS".
Avec 5 threads et 100 incréments, le compteur vaut normalement 500. Le test est répété plusieurs fois afin de vérifier sa stabilité.
La version de Counter sans synchronized dans la classe CounterTest donne des résultats incohérents à chaque fois qu'on augmente le nombre de thread; cela donne:
Avec un seul Thread on a:
1 thread(s) X 1000000 increments = 1000000
1 thread(s) X 1000000 increments = 1000000
1 thread(s) X 1000000 increments = 1000000
1 thread(s) X 1000000 increments = 1000000

Avec 2 threads on a:
2 thread(s) X 1000000 increments = 1007644
2 thread(s) X 1000000 increments = 1999360
2 thread(s) X 1000000 increments = 1999384
2 thread(s) X 1000000 increments = 1784593

Deuxième version avec "synchronized".

public class Counter {
    private int value = 0;
    private final Object valueMonitor = new Object(); // Protects "value"
    public void increment() {
        // Acquire the monitor before modifying the data
        synchronized (valueMonitor) {
            value++;
        }
    }
    public int getValue() {
        // Acquire the monitor before reading the data too !
        synchronized (valueMonitor) {
            return value;
        }
    }
}

La solution à ce problème consiste à s'assurer que les trois opérations seront toujours exécutées de manière atomique, c'est-à-dire que leur séquence sera toujours exécutée en intégralité et sans interruption de la part d'autres threads.
Les moniteurs et les locks de Java donnent justement cette garantie.

public class CounterTest {
 
    private static final int NB_THREADS = 1;
    private static final int NB_INCREMENTS = 1_000_000;
 
    public static void main(String[] args) throws InterruptedException {
 
        final Counter counter = new Counter();
 
        // A job that increments the counter NB_INCREMENT times
        Runnable incrementer = new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < NB_INCREMENTS; i++) {
                    counter.increment();
                }
            }
        };
 
        // Submit several jobs that increment the counter
        ExecutorService pool = Executors.newFixedThreadPool(4);
        for (int i = 0; i < NB_THREADS; i++) {
            pool.submit(incrementer);
        }
        pool.shutdown();
        pool.awaitTermination(5, TimeUnit.SECONDS);
        // Verify the counter's final value
        int counterValue = counter.getValue();
        System.out.printf("%d thread(s) X %7d increments = %d %n", NB_THREADS, NB_INCREMENTS, counterValue);
    }
}

L'exécution du test avec 5 threads donne cette fois le résultat attendu, et de manière reproductible :
5 thread(s) X 1000000 increments = 5000000
5 thread(s) X 1000000 increments = 5000000
5 thread(s) X 1000000 increments = 5000000
5 thread(s) X 1000000 increments = 5000000

1.7. 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.

1.8. Exemple de Deadlock

public class TestThread { 
    public static Object Lock1 = new Object(); 
    public static Object Lock2 = new Object(); 
    public static void main(String args[]) { 
     ThreadDemo1 T1 = new ThreadDemo1(); 
     ThreadDemo2 T2 = new ThreadDemo2(); 
     T1.start(); 
     T2.start(); 
   } 
   private static class ThreadDemo1 extends Thread { 
    public void run() { 
     synchronized (Lock1) { 
     System.out.println("Thread 1: Holding lock 1..."); 
     try { 
      Thread.sleep(10); 
     } catch (InterruptedException e) {} 
     System.out.println("Thread 1: Waiting for lock 2..."); 
     synchronized (Lock2) { 
      System.out.println("Thread 1: Holding lock 1 & 2..."); 
     } 
   } 
  } 
 } 
   private static class ThreadDemo2 extends Thread { 
     public void run() { 
      synchronized (Lock2) { 
       System.out.println("Thread 2: Holding lock 2..."); 
        try {
                 Thread.sleep(10); 
        } catch (InterruptedException e) {} 
        System.out.println("Thread 2: Waiting for lock 1..."); 
      synchronized (Lock1) { 
        System.out.println("Thread 2: Holding lock 1 & 2..."); 
      } 
    } 
  } 
 } 
} 

Quand on compile et qu'on exécute ce programme on obtient une situation de Deadlock; à la sortie on obtient ce qui suit :

Thread 1 : Holding lock1. ...
Thread 2 : Holding lock2. ...
Thread 1 : Waiting for lock2. ...
Thread 2 : Waiting for lock1. ... 

2. Priorité

2.1. Généralités

On peut affecter à chaque thread une priorité, qui est un nombre entier. Plus ce nombre est grand, plus le processus est prioritaire. void setPriority(int priority) assigne une priorité au thread donné et int getPriority() renvoie la priorité d'un thread donné.
L'idée est que plus un processus est prioritaire, plus l'ordonnanceur JAVA doit lui permettre de s'exécuter tôt et vite. La priorité peut être maximale: Thread.MAX_PRIORITY(en général 10), normale (par défaut): Thread.NORM_PRIORITY (en général 5), ou minimale Thread.MIN_PRIORITY(elle vaut en général 0). Pour bien comprendre cela, il nous faut décrire un peu en détail la façon dont sont ordonnancés les threads JAVA.
On peut également déclarer un processus comme étant un démon ou pas:
setDaemon(Boolean on);
boolean isDaemon();
La méthode setDaemon doit être appelée sur un processus avant qu'il soit lancé (par la méthode start()). Un processus démon est typiquement un processus "support" aux autres. Il a la propriété d'être détruit quand il n'y a plus aucun processus utilisateur (non-démon) restant (en fait il y a un certain nombre de threads systèmes par JVM: ramasse-miettes, horloge, etc...). En fait, il est même détruit dès que le processus qui l'a créé est détruit ou terminé.

2.2. Niveau de sécurité

Quand peut-on partager un objet entre plusieurs threads en toute sécurité, sans risquer de voir l'état de cet objet corrompu ou avoir des problèmes de cohérence entre ce que voit chaque thread sur l'état de l'objet ? Afin de répondre à cette question, des niveaux de sécurité ont été définis. Vous devriez toujours documenter vos classes en fonction de ces niveaux, pour permettre aux programmeurs qui utilisent vos classes de savoir dans quelle mesure ils peuvent les utiliser dans des programmes concurrents.

Cinq niveaux ont été définis et sont résumés par Joshua Bloch dans son livre Effective Java. Voici les cinq niveaux, le premier étant le plus sûr, et les suivants étant de moins en moins sûrs :

  1. Immutable
  2. Unconditonally Tread-Safe
  3. Conditonally Tread-Safe
  4. Not Thread-Safe
  5. Thread-Hostile

précédent