Rechercher
 

La gestion des threads en Java

Outils de monitoring (mémoire/GC) Le tutoriel JDBC



Introduction

Qu'est ce qu'un thread ?

Tout le monde a entendu parler de système multi-tâches. Un tel système est capable d'exécuter plusieurs programmes en parallèle sur une même machine. Mais attention : dans la quasi totalité des cas il n'y a jamais deux programmes différents qui s'exécutent au même instant. La raison en est simple : la plupart du temps, une machine n'a qu'un seul processeur et ce dernier n'est capable de réaliser qu'une seule chose à la fois. Mais alors, comment se fait t'il qu'on arrive malgré tout à exécuter plusieurs applications en même temps (ou quasiment) ?

La plupart des systèmes d'exploitation sont équipés d'un ordonnançeur de tâches. Ce composant logiciel a pour mission de donner à tour de rôle le processeur aux programmes (on dit au processus) en cours d'exécution. La durée pendant laquelle un processus est actif est en fait très courte et c'est grâce à cette activation cyclique et brève que l'utilisateur a l'impression que plusieurs choses sont en cours d'exécution sur sa machine.

Bien souvent le problème est encore plus complexe. En effet, sur les systèmes récents, le processus est en fait inactif : il permet juste de partager les données d'une application. Mais les partager pour qui ? Et bien, pour les threads ! En fait, une application qui s'exécute est représentée par un processus et par au moins un thread (un processus pouvant contenir plusieurs threads). La meilleure traduction française de thread me semble être une tâche.

Les threads en Java

Vous avez, bien entendu, la possibilité en Java de créer des threads. En effet, quand vous allez lancer un programme Java (par l'intermédiaire de la JVM) vous lancez en fait un processus. Ce processus, de base, contient plusieurs threads : le thread principal (celui qui exécute votre code à partir du main) et d'autres pour, par exemple, la gestion du "garbage collector". La capture d'écran suivant vous montre les threads contenus dans un processus Java ne faisant qu'une unique boucle infinie ( while(true); ).

Bien entendu, vous pouvez vous aussi créer vos propre threads au sein de vos applications Java pour réaliser plusieurs tâches en parallèle. En fait deux techniques vont vous permettre de créer vos threads. Nous allons toutes les deux les étudier. Dans les deux cas, vous pourrez ensuite, plus ou moins, contrôler vos threads : nous verrons les aspects principaux de la gestion des threads. Puis nous finirons cette étude en nous intéressant à la synchronisation des threads accédant à des ressources partagées : l'éternel problème lors de la manipulation de threads, et ce quelque soit le langage de programmation utilisé.

Création et démarrage d'un thread.

Comme nous venons de le dire plus haut, il existe, en Java, deux techniques pour créer un thread. Soit vous dérivez votre thread de la classe java.lang.Thread, soit vous implémentez l'interface java.lang.Runnable. Les différences sont subtiles, mais notez déjà que si vous implémentez l'interface Runnable, il vous reste toujours la possibilité de dériver d'une autre classe (n'oublions pas qu'en Java, il n'y a pas d'héritage multiple). De plus, selon la technique que vous choisissez, vous aurez plus ou moins de facilité pour partager (ou non) des données entre différents threads. Regardons ces deux techniques de plus près.

Créer un thread en dérivant de la classe java.lang.Thread.

Cette classe permet donc, si l'on en hérite de pouvoir créer un objet de Thread. En effet, la classe Thread n'est certes pas abstraite (nous y reviendrons) mais il faut tout de même redéfinir la méthode run (qui de base ne fait quasiment rien). Cette méthode publique n'attend aucun paramètre et ne retourne rien. Mais quoi mettre dans cette méthode ? Et bien le code que vous souhaitez voir s'exécuter dans un thread.

Une fois votre classe codée, il vous faut créer un objet et lancer le Thread. Comment lanceriez vous ce thread ? Si vous pensiez simplement invoquer la méthode run sur votre objet, sachez que c'est une erreur. En effet, si vous le faites, votre programme compile et s'exécute, mais l'exécution reste séquentielle. Ce qu'il faut c'est activer le nouveau thread au niveau du système. Pour ce faire, il vous faut plutôt invoquer la méthode start. Cette méthode est codée au niveau de la classe Thread : vous n'avez pas besoin de la redéfinir. A titre indicatif, notez que la méthode sleep de la classe Thread est utilisée afin de vous laisser le temps de visualiser les résultats.

/** Premier test de classe de thread en utilisant la
 *  technique qui consiste à dériver de la classe Thread.
 */
public class FirstThread extends Thread {
    /** Un attribut propre à chaque thread */
    private String threadName;
    
    /** Création et démarrage automatique du thread */
    public FirstThread(String threadName) {
        this.threadName = threadName;
        this.start();
    }

    /** Le but d'un tel thread est d'afficher 500 fois
     *  son attribut threadName. Notons que la méthode
     *  <I>sleep</I> peut déclancher des exceptions.
     */
    public void run() {
        try {
            for(int i=0;i<500;i++) {
                System.out.println(
                    "Thread nommé : " + this.threadName +
                    " - itération : " + i
                );
                Thread.sleep(30);  
            }
        } catch (InterruptedException exc) {
            exc.printStackTrace();
        }
    }
 
    /** Le point de démarrage de votre programme.
     *  Notez bien que nous lançons deux threads et que
     *  chacun  d'eux possède une données qui lui est
     *  propre.
     */
    static public void main(String argv[]) {
        FirstThread thr1 = new FirstThread("Toto");
        FirstThread thr2 = new FirstThread("Tata");
    }
} 

Notez bien qu'avec cette technique, vous avez autant d'objets que de threads. Cela permet de pouvoir garantir que chaque thread a bien sa propre identité. Vous pourriez aussi avoir plusieurs classes différentes qui dérivent de java.lang.Thread : chacune d'entre elles aurait alors son propre run, permettant ainsi à votre programme de faire tout ce qu'il doit.

Le problème de cette technique réside dans le fait que comme l'on dérive déjà d'une classe, vous ne pouvez plus dériver d'une autre classe. Conceptuellement parlant, cela peut être gênant. Considérons un cas simple. Vous devez coder un bouton qui lorsque l'on lui clique dessus se doit de lancer une tâche longue en arrière plan (dans un thread). Dans ce cas, on aurait besoin de dériver de la classe de bouton et de la classe de thread : ce n'est pas possible en Java. Pour pallier le problème, les concepteurs de la librairie Java ont pensé à une seconde technique non basée sur une relation d'héritage. Il suffit d'implémenter l'interface java.lang.Runnable.

Créer un thread en implémentant l'interface java.lang.Runnable.

La seconde technique consiste donc à implémenter l'interface java.lang.Runnable. Je rappelle, à titre indicatif, que pour implémenter une interface, vous vous devez de coder TOUTES les méthodes qui y sont définies. L'interface Runnable est en fait très simple dans le sens où elle ne définit qu'une unique méthode : la méthode run (étonnant non ?). Mais attention, dans votre classe (qui implémente l'interface) vous n'avez pas de méthode start qui aurait pu permettre le démarrage de votre thread. Alors comment faire pour s'en sortir ?

Comme je le disais plus haut, les concepteurs de la librairie ont pensés à tout : vous allez réutiliser la classe Thread. Peut être certains d'entre vous ont noté que la classe Thread n'est pas abstraite. Dans une certaine mesure, on pourrait dire que cela est dommage, dans le sens où cela aurait obligé à coder la méthode run. En effet, si vous utilisez la première technique et que vous vous trompez dans le nom de la méthode run (Run par exemple), tout compile, mais votre thread ne fera rien ! Alors pourquoi ont t'ils fait ce choix ? En bien par ce qu'en fait, la méthode run de la classe Thread fait quelque chose. Dans le cas où c'est l'implémentation du run d'origine qui prend la main, la méthode fonctionnera de pair avec un constructeur particulier de la classe Thread. Devinez ce que ce constructeur s'attend à prendre en paramètre ? ... Et oui, un objet de type Runnable (qui implémente donc l'interface) comme cela le système est certain que la méthode run est disponible. N'oublions pas qu'une interface peut, dans une certaine mesure, être vue comme un contrat que les deux parties acceptent et signent. Le bout de code suivant montre une partie du code de la classe Thread. Analysez le bien.

package java.lang;

public class Thread extends Object {
    private Thread theThread = null;

    // ... du code ...

    public Thread(Runnable theThread) {
        this.theThread = theThread;
    }

    // ... du code ...

    public void run() {
        if (this.theThread != null) {
            theThread.run();
        }
    }

    // ... du code ...
}

Ainsi, si vous invoquez la méthode start de la classe Thread, sans en avoir hérité, et si vous avez passé à votre objet de thread un autre objet possédant lui aussi une méthode run, c'est le code de ce dernier qui sera au final bien exécuté dans un thread. Ces petites explications étant faîtes, regardons une petit exemple similaire au premier, mettant en oeuvre cette technique.

/** Second test de classe de thread en utilisant la
 *  technique qui consiste à implémenter l'interface
 *  java.lang.Runnable
 */
public class SecondThread implements Runnable {
    /** Un attribut partagé par tous les threads */
    private int counter;

    /** Démarrage de cinq threads basés sur un même objet */
    public SecondThread (int counter) {
        this.counter = counter;

        // On démarre cinq threads sur le même objet
        for (int i=0;i<5;i++) {
            (new Thread(this)).start();
        }
    }
    
    /** Chaque thread affiche 500 fois un message. Un
     *  unique compteur est partagé pour tous les threads.
     *  Il y a cinq threads. Le dernier affichage devrait
     *  donc être "Valeur du compteur == 2499".
     */
    public void run() {
        try {
            for(int i=0;i<500;i++) {
                System.out.println(
                    "Valeur du compteur == " + counter++
                );
                Thread.sleep(30);
            }
        } catch (InterruptedException exception) {
            exception.printStackTrace();
        }
    }

    /** Le main créer un unique objet  sur lequel vont se
     *  baser cinq threads. Il vont donc tous les cinq se
     *  partager le même attribut.
     */
    static public void main(String argv[]) {
        SecondThread p1 = new SecondThread(0);
    }
} 

Il est important de noter qu'avec cette variante, vous pouvez baser plusieurs threads sur un même et unique objet. Il est donc facile de pouvoir partager des données entre ces différents threads. Mais attention, il faudra certainement synchroniser l'accès à ces données partagées : mais cela est une autre histoire, et nous y reviendrons ultérieurement dans cette page.

Pour bien imager le fait qu'en implémentant l'interface Runnable il est toujours possible de dériver d'une autre classe, je vous suggère de regarder de plus près cet autre petit exemple. Nous y créons une classe "Clock" permettant d'afficher l'heure en cours. Cette classe dérive de la classe java.awt.Label. Notre objet est donc un label que l'on peut ajouter à une interface graphique et sur lequel nous pouvons notamment changer la couleur d'arrière plan. En plus de cela, notre classe fournit la méthode run requise par l'interface Runnable. Il ne reste plus qu'à lancer un objet de thread sur notre label pour pouvoir changer son contenu à chaque seconde. Afin que vous puissiez directement voir le résultat dans votre navigateur, le composant Clock est utilisé dans une applet directement incorporée dans ce document.

import java.util.*;
import java.text.*;
import java.awt.*;

public class Clock extends Label implements Runnable {
    private DateFormat timeFormat = DateFormat.getTimeInstance();

    public void run() {
        try {
            while(true) {
                this.setText(timeFormat.format(new Date()));
                Thread.sleep(1000);
            }
        } catch(Exception exception) {
            exception.printStackTrace();
        }
    }

    public Clock() {
        this.setText(timeFormat.format(new Date()));
        this.setAlignment( Label.CENTER );
        (new Thread(this)).start();
    }
}
import java.awt.*;
import java.applet.*;

public class Start extends Applet {
    public void init() {
        this.setLayout(new FlowLayout());
        Label clock1 = new Clock();
        clock1.setBackground(new Color(200,200,255));

        Label clock2 = new Clock();
        clock2.setBackground(new Color(200,255,200));

        Label clock3 = new Clock();
        clock3.setBackground(new Color(255,200,200));

        this.add(clock1);
        this.add(clock2);
        this.add(clock3);
    }
}

Quelle technique choisir ?

Donc deux techniques principales peuvent être utilisées pour créer un thread. Résumons leurs avantages et leurs inconvénients.

  Avantages Inconvénients
extends java.lang.Thread Chaque thread a ses données qui lui sont propres. On ne peut plus hériter d'une autre classe.
implements java.lang.Runnable L'héritage reste possible. En effet, on peut implémenter autant d'interfaces que l'on souhaite. Les attributs de votre classe sont partagés pour tous les threads qui y sont basés. Dans certains cas, il peut s'avérer que cela soit un atout.

Notez aussi qu'il est malgré tout possible de partager des données avec la première technique étudiée : il suffit dans ce cas de définir des attributs statiques. Un attribut statique est partagé par toutes les instances d'une même classe.

Gestion des threads.

Une fois vos threads créés et lancés, vous pouvez avoir besoin de les contrôler afin d'affiner le comportement de votre application. Il est vrai que vous n'avez pas, en Java, un contrôle absolu sur l'exécution de vos threads, mais pas mal de choses restent malgré tout réalisables. Pour que les choses soient claires, il nous faut, dans un premier temps, mieux comprendre le cycle de vie d'un thread. Par la suite, nous regarderons quelques unes des possibilités de contrôle de plus prés.

Cycle de vie d'un thread.

Un thread peut passer par différents états, tout au long de son cycle de vie. Le diagramme suivant illustre ces différents stades ainsi que les différentes transitions possibles. Nous reprendrons ensuite en détail chaque point du diagramme, pour mieux comprendre chaque étape de vie d'un thread.

Quand vous créez un thread, celui-ci est par défaut dans son état initial. Il faut invoquer la méthode start pour lui permettre de passer dans un état exécutable. Si vous invoquez, au contraire, la méthode stop, vous passez ce thread dans un état terminal, sans qu'il n'ait eu le temps d'exécuter la moindre instruction.

Tant que le thread est dans un état exécutable, il est susceptible de pouvoir recevoir le processeur. Celui-ci lui sera octroyé durant un bref instant (quelques millisecondes). Au terme de cette durée, le système élira un autre thread exécutable et lui donnera la main. Les systèmes d'exploitations modernes sont qualifiés de préemptifs : aucune action n'est à faire par le thread pour que le système lui hôte le processeur. Mais il existe aussi d'autres systèmes qualifiés de non préemptifs : dans ce cas les choses se compliquent un peu. Il est de la charge du thread d'exécuter un appel à la méthode yield() pour pouvoir donner la main à un autre thread. Nous ne traiterons pas ici de cette technique.

Un thread peut aussi être sorti de la liste des threads en attente du processeur. Dans ce cas il passe dans un état non exécutable. Il lui faudra rejoindre le groupe des threads exécutables pour pouvoir à nouveau recevoir le processeur. Pour passer dans l'état non exécutable, deux méthodes peuvent être invoquées : Thread.sleep(long durée), qui suspend le thread durant quelques instants, où les méthodes wait de la classe java.lang.Object. Nous reviendrons ultérieurement sur ces dernières.

Pour terminer un thread, deux techniques vous sont proposées. Soit vous sortez normalement de la méthode run pour votre thread. Soit vous invoquez sur votre thread la méthode stop. Dans les deux cas, le thread ne pourra jamais redémarrer.

Démarrage, suspension, reprise et arrêt d'un thread.

Pour gérer l'exécution des threads, vous disposez de différentes méthodes. La liste suivante vous donne quelques informations supplémentaire sur certaines d'entre-elles.

Gestion de la priorité d'un thread.

Vous pouvez, en Java, jouer sur la priorité de vos threads. Sur une durée déterminée, un thread ayant une priorité plus haute recevra plus fréquemment le processeur qu'un autre thread. Il exécutera donc, globalement, plus de code.

La priorité d'un thread va pouvoir varier entre 0 et 10. Mais attention, il n'est en aucun cas garanti que le système hôte saura gérer autant de niveaux de priorités. Des constantes existent et permettent d'accéder à certains niveaux de priorités : MIN_PRIORITY (0) - NORM_PRIORITY (5) - MAX_PRIORITY (10).

L'exemple de code qui suit, lance trois threads supplémentaires. Chacun d'eux se voit affecter une priorité différente. Quelque soit le thread considéré; celui-ci exécute un code très simpliste : il incrémente indéfiniment un compteur. Malgré cela, au bout d'un certain temps le thread initial stoppe les trois autres et l'on regarde le nombre d'incrémentations réalisé par chacun d'entre eux.

public class ThreadPriority extends Thread {
    private int counter = 0;

    public void run() {
        while(true) counter++;
    }

    public int getCounter() { return this.counter; }

    public static void main(String args[]) throws Exception {
        ThreadPriority thread1 = new ThreadPriority();
        ThreadPriority thread2 = new ThreadPriority();
        ThreadPriority thread3 = new ThreadPriority();

        thread1.setPriority(Thread.MAX_PRIORITY);
        thread2.setPriority(Thread.NORM_PRIORITY);
        thread3.setPriority(Thread.MIN_PRIORITY);

        thread1.start(); thread2.start(); thread3.start();
        Thread.sleep(5000);
        thread1.stop(); thread2.stop(); thread3.stop();

        System.out.println("Thread 1 : counter == " + thread1.getCounter());
        System.out.println("Thread 2 : counter == " + thread2.getCounter());
        System.out.println("Thread 3 : counter == " + thread3.getCounter());
    }
}

Le tableau suivant montre les variations du nombre de constructions, en fonction des priorités affectées. Au terme de votre analyse, il me semble qu'il vous apparaîtra clairement que la priorité de threads est un mécanisme de contrôle efficace.

Thread 1 Thread 2 Thread3
Thread.NORM_PRIORITY Thread.NORM_PRIORITY Thread.NORM_PRIORITY
250 752 409 256 697 243 251 964 807
Thread.MIN_PRIORITY Thread.NORM_PRIORITY Thread.MAX_PRIORITY
11 104 663 20 673 290 1 164 460 398

Gestion d'un groupe de threads.

Une autre possibilité intéressante consiste à regrouper différents threads. Dans un tel cas, vous pouvez invoquer un ordre sur l'ensemble des threads du groupe, ce qui peut dans certains cas sérieusement simplifier votre code.

Pour inscrire un thread dans un groupe, faut t'il que le groupe soit initialement créé. Pour ce faire, il vous faut instancier un objet de la classe ThreadGroup. Un fois le groupe créé, vous pouvez attacher vos threads à ce groupe. L'attachement d'un thread à un groupe se fait via un constructeur de la classe Thread. Par la suite, un thread ne pourra en aucun cas changer de groupe. Si vous avez mis en oeuvre de l'héritage, n'oubliez pas que dans ce cas tous les constructeurs parents sont invoqués lors de la création d'un objet.

Une fois tous vos threads attachés à votre groupe, vous pouvez alors invoquer les méthodes de contrôle d'exécution des threads sur l'objet de groupe. Les noms des méthodes sont identiques à la classe Thread : suspend(), resume(), stop(), ...

Synchronisation de threads et accès aux ressources partagées.

Lorsque que vous lancez une JVM (Machine Virtuelle Java), vous lancez un processus. Ce processus possède plusieurs threads et chacun d'entre eux partage le même espace mémoire. En effet, l'espace mémoire est propre au processus et non à un thread. Cette caractéristique est à la fois un atout et à la fois une contrainte. En effet, partager des données pour plusieurs threads est, par définition, relativement simple. Par contre les choses peuvent se compliquer sérieusement si la ressource (les données) partagée est accédée en modification : il faut synchroniser les accès concurrents. Nous sommes certainement face à l'un des problèmes informatiques les plus délicats à résoudre.

Pour mieux comprendre les choses, imaginons que deux threads cherchent à modifier (à incrémenter) l'état d'un attribut statique, de type entier, nommé MaClasse.shared. Seul un thread à la fois peut exécuter du code, mais n'oublions pas que les systèmes les plus modernes sont préemptifs ! De plus une instruction telle que MaClasse.shared = MaClasse.shared + 1; se traduit en plusieurs instructions en langage machine. Imaginez qu'un premier thread évalue l'expression MaClasse.shared + 1 mais que le système lui hôte le cpu, juste avant l'affectation, au profit d'un second thread. Ce dernier se doit lui aussi d'évaluer la même expression. Le système redonne la main au premier thread qui finalise l'instruction en effectuant l'affectation, puis le second en fait de même. Au final de ce scénario, l'entier aura été incrémenté que d'une seule et unique unité. De tels scénarios peuvent amener à des comportements d'applications chaotiques. Il est donc vital d'avoir à notre disposition des mécanismes de synchronisation.

Notions de verrous.

L'environnement Java offre un premier mécanisme de synchronisation : les verrous (locks en anglais). Chaque objet Java possède un verrou et seul un thread à la fois peut verrouiller un objet. Si d'autres threads cherchent à verrouiller le même objet, ils seront endormis jusqu'à que l'objet soit déverrouillé. Cela permet de mettre en place ce que l'on appelle plus communément une section critique.

Pour verrouiller un objet par un thread, il faut utiliser le mot clé synchronized. En fait, il y a deux façons de définir une section critique. Soit on synchronise un ensemble d'instructions sur un objet, soit on synchronise directement l'exécution d'une méthode pour une classe donnée. Dans le premier cas, on utilise l'instruction synchronized. Dans le second cas, on utilise le qualificateur synchronized sur la méthode considérée. Le tableau suivant indique la syntaxe à utiliser dans les deux cas.

synchronized(object) {
    // Instructions de manipulation d'une
    // ressource partagée.
}
public synchronized void meth(int param) {
    // Le code de la méthode synchronizée.
}

Afin de mieux comprendre les choses, nous allons étudier un petit exemple : celui-ci va permettre à plusieurs threads de tracer des pixels en parallèle. Mais attention, pour que le programme se déroule normalement, il faut que l'appel à la méthode de tracé soit synchronisé. Sinon, il y aura des possibilités pour que certains pixels soient sautés.

L'interface graphique de l'applet présente deux boutons et une zone de dessin. Les deux boutons permettent de lancer le tracé dans la zone de dessin. Mais l'un des boutons lance le tracé sans synchronisation alors que l'autre le fait en synchronisant les threads. Pour localiser le code mettant en oeuvre la synchronisation, regardez le code de la méthode run.

import java.applet.*;
import java.awt.*;
import java.awt.event.*;

public class Synchronized extends Applet
        implements ActionListener, Runnable {
    private Panel pnlButtonBar = new Panel();
    private Button btnStartNormal = new Button("Non synchronisé");
    private Button btnStartSynchronized = new Button("Synchronisé");
    private Canvas cnvGraphic = new Canvas();
    private Label lblStatus = new Label("Choisissez un mode d'exécution");

    private boolean drawMode = false;
    private int x, y;

    public void init() {
        pnlButtonBar.setLayout(new FlowLayout());
        pnlButtonBar.add(btnStartNormal);
        pnlButtonBar.add(btnStartSynchronized);
        btnStartNormal.addActionListener(this);
        btnStartSynchronized.addActionListener(this);

        this.setLayout(new BorderLayout());
        this.add(pnlButtonBar, BorderLayout.NORTH);

        cnvGraphic.setBackground(Color.white);
        this.add(cnvGraphic, BorderLayout.CENTER);

        this.add(lblStatus, BorderLayout.SOUTH);
    }

    public void plot() {
        this.cnvGraphic.getGraphics().drawLine(this.x,this.y,this.x,this.y);
        if (++this.x >= 300) { this.x=0; this.y++; }
    }

    public void run() {
        while(this.y < 100) {
            if (drawMode) {
                synchronized(this) { this.plot(); }
            } else {
                this.plot();
            }
        }
    }

    public void actionPerformed(ActionEvent event) {
        this.drawMode = (event.getSource() == btnStartSynchronized );
        this.cnvGraphic.repaint();
        this.x = this.y = 0;

        (new Thread(this)).start();
        (new Thread(this)).start();
        (new Thread(this)).start();
    }
}

Attendre l'accès à une ressource.

Mais poser des verrous ne suffit par toujours. Dans certains cas, vos threads doivent patienter (pour ne pas consommer trop de temps CPU) avant qu'une ressource ne soit disponible. Pour gérer ces cas, l'environnement Java propose aussi un support pour pouvoir contrôler l'activité de vos threads. Il vous est possible de stocker (un tableau d'élément de type java.lang.Object) des threads endormis sur un objet dans le but de la manipuler (attention, cela n'a rien à voir avec les verrous).

Tout le support nécessaire à cette gestion est fourni dans la classe Object. Celle-ci propose notamment quatre méthodes permettant d'endormir un thread, ainsi que de le réveiller. Pour de plus amples informations, vous pouvez toujours consulter la documentation de l'API du JDK.

Pour endormir un thread sur le moniteur, il vous faut utiliser la méthode wait. Plusieurs prototypes vous sont fournis afin de pouvoir attendre indéfiniment soit durant un délai maximal. Il est vrai que la méthode Thread.sleep permet aussi de faire patienter un thread, mais il sera alors impossible de faire reprendre l'activité du thread avant la fin du timeout. Par contre, la méthode Object.wait le permet.

Pour réveiller des threads endormis, vous pouvez utiliser les méthodes notify et notifyAll. Soit vous choisissez de réveiller un unique thread endormi sur un objet sur lequel il faut se synchroniser, soit vous décidez de tous les réveiller.

Exemple de producteurs/consommateurs.

Pour mettre en oeuvre un exemple de synchronisation un peu évolué, nous allons considérer un cas d'école : un exemple de producteurs/consommateurs. Dans un tel cas, une ressource partagée est utilisée par des threads qui produisent et par des threads qui consomment. Mais attention, il est hors de question de consommer si rien n'a été produit.

Pour implémenter notre objet partagé, nous allons coder une pile (Stack en anglais). Une pile est une structure de données qui se comporte un peu comme un pile d'assiettes. Pour empiler des assiettes, on les pose par dessus, et on dépile toujours la dernière empilée. mais attention : notre pile va être bornée. On ne peut donc pas empiler indéfiniment. Pour empiler des données supplémentaires, il faudra attendre que des threads consommateurs aient dépilé les anciennes données.

Dans ce cas, les choses sont subtiles. L'objet de synchronisation est clairement la pile. Nous allons donc, au gré de l'exécution du programme, endormir des threads sur cet objet. Mais deux types de threads seront à considérer : ceux qui produisent et ceux qui consomment. Imaginons le scénario suivant : un thread empile une donnée. Il va donc utiliser une méthode pour réveiller un éventuel consommateur. Mais qu'est ce qui garantit que le thread réveillé ne sera pas un autre producteur ? Si c'était le cas, nous aboutirions à des cas d'inter-blocage : le programme n'évoluerait plus, mais ne terminerait pas. Il nous faudra donc utiliser la méthode notifyAll pour réveiller les threads. Il faut alors garantir que tous les threads réveillés qui n'ont pas accès à la ressource se rendorment rapidement. D'où le code des méthodes push et pop de la classe suivante.

public class Pile {
    private int array [];
    private int size, index;

    /** Un constructeur de pile */
    public Pile() {
        this (5);
    }

    /** Un autre constructeur de pile qui prend la taille de cette dernière */
    public Pile(int size) {
        this.size = size; 
        this.index = 0;
        this.array = new int[size];
    }

    /** Renvoie true si la pile est vide */
    public boolean isEmpty() { return index == 0; }

    /** Renvoie true si la pile est pleine */
    public boolean isFull() { return index == size; }

    /** Cette méthode synchronisée permet de dépiler une valeur */
    public synchronized int pop () {
        try { 
            while (isEmpty()) {
                System.out.println("Consommateur Endormi");
                wait();
            }
        } catch(Exception e) {
            e.printStackTrace();
        }

        int val = array[--index];
        notifyAll();
        return val;
    }

    /** Cette méthode synchronisée permet d'empiler une valeur */
    public synchronized void push(int value) {
        try { 
            while (isFull()) {
                System.out.println("Producteur Endormi");
                wait();
            }
        } catch(Exception e) {
            e.printStackTrace();
        }

        array[index++] = value;
        notifyAll();
    }
}

Maintenant que notre ressource partagée est prête, il ne nous reste plus qu'à coder nos producteurs et nos consommateurs. Dans les deux cas, ces deux types (classes) de composants partagent tous certaines caractéristiques : ils travaillent tous sur la même pile et dans les deux cas, cadencer les choses via un Thread.sleep pourra permettre une bonne lisibilité des résultats sur la console. Nous utilisons ici l'héritage pour définir le tronc commun à tous nos threads. La classe de base se nomme Fonctionneur, et elle est de plus abstraite : nous ne voulons pas permettre d'instancier un quelconque objet de ce type là (de plus, nous n'avons pas d'implémentation de la méthode run).

abstract class Fonctionneur implements Runnable {
    /** La pile partagée par tous nos threads */
    private static Pile p = new Pile(5);
    /** Un délai d'attente pour chaque thread */
    protected long sleepTime;

    /** Un constructeur par défaut. Pas de délai d'attente */
    public Fonctionneur () {
        this (0);
    }

    /** Un constructeur qui prend un délai d'attente pour le thread */
    public Fonctionneur (long sleepTime) {
        this.sleepTime = sleepTime;
        (new Thread(this)).start();
    }

    /** Permet de pouvoir récupérer la pile */
    public Pile getPile() { return p; }
}

Nous pouvons maintenant dériver de cette classe nos deux types de threads : les producteurs (classe Producteur) qui vont produire des valeurs entières et donc les empiler (tant que cette dernière n'est pas pleine) et les consommateurs (class Consommateur) qui vont lire des valeurs entières sur la pile (tant que cette dernière n'est pas vide).

public class Producteur extends Fonctionneur {

    private int value = 0;
    
    public Producteur() { super(); }

    public Producteur(long sleepTime) { 
        super(sleepTime);
    }

    public void run() {
        while (true) {
            try {
                Thread.sleep ((long)Math.random()*this.sleepTime);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            System.out.println (this + " empile " + value);
            getPile().push(value++);
        }
    }
}
public class Consommateur extends Fonctionneur {

    public Consommateur() { super(); }
    public Consommateur(long sleepTime) { 
        super(sleepTime);
    }

    public void run() {
        while (true) {
            try {
                Thread.sleep ((long)Math.random()*this.sleepTime);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            System.out.println (this + " depile " + getPile().pop());
        }
    }
}

Maintenant il ne reste plus qu'à coder une classe de démarrage afin d'initier le démarrage des threads à synchroniser sur la pile.

public class Start {
    static public void main (String args[]) {
        // Démarrage de deux Producteurs
        new Producteur(1000);
        new Producteur(1000);
        // Démarrage de deux Consommateurs
        new Consommateur(1000);
        new Consommateur(1000);

        while (true) { // Mise en veille du thread principal
            try { 
                Thread.sleep (10000L);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}

Conclusion

Nous venons donc de voir qu'en Java, il est possible de créer des threads. Deux techniques vous sont proposées. Chacune d'entre elle ayant des avantages et des inconvénients (nous avons dans ce chapitre, volontairement un peu utilisé les deux techniques). De plus nous avons vu qu'il été possible de contrôler, plus ou moins, finement l'exécution de vos threads. Il existe même la notion de groupes de threads permettant une gestion en lots de threads.

Mais comme dans tout langage, s'il n'était pas possible de synchroniser les accès concurrents aux ressources partagées, cette solution serait trop souvent inutile. En conséquence, la notion de verrous (sections critiques) et un mécanisme de synchronisation (mise en sommeil et réveil de threads) vous sont proposés.



Outils de monitoring (mémoire/GC) Le tutoriel JDBC