I. Introduction

Bien que Java ait montré des résultats probants sur des applications côté serveur, il ne possède toutefois qu'une acceptation limitée pour ce qui relève des applications en temps réel, dans les domaines scientifiques ou sensibles à la sécurité. Dans ces domaines en particulier, C et C++ demeurent encore les langages de prédilection, malgré l'existence de compilateurs Java tels que le compilateur Gnu pour Java (soit Gnu Compiler for Java, ou GCJ en anglais). Le présent article sera consacré à l'analyse des différentes techniques permettant de réduire l'écart entre les langages C/C++ et Java dans les systèmes dits en temps réel/embarqués. Les approches présentées dans le présent article sont des projets libres (implémentées par la bibliothèque Javolution), n'encombrent que très peu votre système, ne nécessitent pas de Machines Virtuelles Java spécialisées, et peuvent être utilisées sur la majorité des environnements d'exécution, parmi lesquels Java 2 Platform Micro Edition (J2ME). Ces techniques peuvent, dans leur ensemble, accélérer de manière significative l'exécution des programmes, avantage non moins digne d'intérêt pour les applications qui ne s'exécutent pas en temps réel.

II. Récupération de place

L'adoption de Java dans les applications en temps réel ou dans les systèmes sensibles à la sécurité a longtemps été écartée en raison de la lenteur engendrée par la récupération de place. Imaginez par exemple un contrôleur de la circulation aérienne dont l'écran d'affichage se gèle soudainement pendant quelques secondes alors que le contrôleur donnait des instructions au pilote aérien, simplement parce que la tâche qui consiste à récupérer de la place a décidé à ce moment-là de se bloquer ! (C'est la raison pour laquelle, jusqu'à récemment, la licence Java interdisait de manière explicite le recours au langage Java pour les applications en matière de contrôle du trafic aérien.)

Quant aux applications en temps réel, le non-respect d'une échéance est une question réellement problématique - même une grave erreur pour les applications en temps réel dur. Par ailleurs, l'analyse du temps d'exécution selon la pire éventualité (soit worst-case execution time, ou WCET en anglais) peut connaître des complications supplémentaires dues aux évènements suivants :

  • initialisation de classes dynamiques ;
  • redimensionnement des tables internes ;
  • compilation juste-à-temps (inapplicable au compilateur Gnu pour Java) ;
  • interruption du nettoyage.

Les trois premières tâches peuvent être facilement résolues ; par exemple, initialiser l'ensemble des classes dès le départ, de façon à s'assurer que les tables sont suffisamment importantes pour supporter le scénario de la pire éventualité et désactiver la compilation juste-à-temps (autrement dit, utiliser l'option - Xint). En ce qui concerne le nettoyage, deux solutions divergentes émergent :

  • minimiser la durée d'interruption. C'est en tout cas la direction que prennent Sun avec son Nettoyeur Concurrent ainsi qu'IBM avec son projet Metronome ;
  • anticiper le nettoyage. Cette solution est soutenue par le Real-Time Expert Group, avec son modèle de mémoire RTSJ (c'est-à-dire Real-Time specification for Java).

Toutefois, aucune de ces deux solutions n'est vraiment satisfaisante. La première d'entre elles peut exiger qu'une partie importante de l'Unité Centrale soit vouée à la récupération de place (sur la base du scénario de génération d'informations parasites selon la pire éventualité). Quant à la solution du modèle de mémoire RTSJ, elle nécessite l'utilisation de fils d'exécution particuliers : les fils dits NoHeapRealtimeThread (c'est-à-dire des fils d'exécution en temps réel qui n'ont pas accès aux objets présents dans le tas) qui ne sont autorisés, à aucun moment et sous aucun prétexte, à toucher la mémoire du tas ! De la même manière, les concepts de ce modèle dits de mémoire étendue et de mémoire immortelle et la façon de transférer les données entre ces deux zones tendent à encombrer le style de la programmation. Mais, l'obstacle majeur, qui joue en défaveur de ces solutions, est qu'elles nécessitent toutes deux un recours à des Machines Virtuelles Java spécialisées dont le fonctionnement peut comme ne peut pas être efficace selon les outils temps réel, les noyaux (Systèmes d'exploitation en Temps Réel ou RTOS en anglais), et le matériel existants.

Toutefois, il existe des techniques offrant d'autres solutions sans avoir recours à aucun système d'exécution Java particulier. Ces techniques consistent à limiter/éviter les interruptions de récupération de place en éliminant les informations parasites générées.

III. Compte des références

Par compte des références, j'entends l'opération selon laquelle chaque objet compte le nombre de références directes qui lui sont adressées à partir d'autres objets présents dans le système. Lorsque le compte de références tombe à zéro, il n'existe plus de référence à l'objet étudié et celui-ci peut alors retourner à une réserve d'objets pour une réutilisation future. Cette approche est facile à implémenter si les objets recyclables peuvent être organisés selon un graphe acyclique dirigé ; en d'autres mots, ils peuvent être disposés dans des structures arborescentes sans aucun cycle. Puis, lorsqu'un objet est enlevé (autrement dit retourné à la réserve) les comptes de références de ses fils diminuent automatiquement, ce qui peut conduire à des éliminations supplémentaires (c'est ce qu'on appelle la désaffectation en cascade).

Cette technique a été utilisée avec succès dans le premier affichage en temps réel pour le contrôle aérien. Pour cet affichage, des composants de type Swing sont appliqués à un modèle de graphe pour scène graphique sans aucun cycle autorisé.

Le compte de références permet non seulement de faciliter l'élimination et le recyclage d'objets graphiques de scènes isolées (généralement un objet graphique ou un contexte tel que la couleur), mais aussi de libérer n'importe quelles ressources matérielles associées (telles que l'ID liste d'affichage OpenGL ou l'ID texture). Les structures de données internes (tableaux, listes, et autres structures assimilées) demeurent avec l'objet et ne sont pas désaffectées, ce qui permet d'éviter de la sorte toutes nouvelles affectations lors de la réutilisation de l'objet (bien qu'elles devraient être effacées).

La performance induite par le compte de références est excellente et la technique s'avère également fort efficace pour les objets de taille importante et de longue durée de vie. En ce sens, cette technique vient compléter le Collecteur incrémental HotSpot, lui-même très efficace dans la réclamation d'objets de petite taille ou dont la durée de vie est courte, mais retourne vers une collecte complète du genre stop the world (qui peut prendre plusieurs secondes selon la taille du tas) lorsque la génération en charge se remplit. Le compte de références et le Collecteur Incrémental étaient utilisés de pair pour l'affichage en temps réel exposé plus haut. Comme le collecteur n'est jamais pressé par de soudaines opérations considérables d'affectation/désaffectation, la collecte complète n'a pas besoin de s'exécuter et les quelques objets de courte durée affectés à la volée sont facilement regroupés par le Collecteur Incrémental en l'espace de quelques millisecondes, bien en dessous du temps d'attente maximum que des applications relatives au contrôle de la circulation aérienne sont prêtes à tolérer (50 ms).

IV. Groupes de fils d'exécution

Les groupes de fils d'exécution sont semblables à l'affectation de la pile sur des variables primitives déclarées en local, mais étendues à des objets non primitifs. L'unique problème d'une telle technique consiste à s'assurer que les objets ne sont pas référencés une fois la pile dépilée (ce problème est absent lorsqu'il s'agit de types primitifs manipulés par valeur, et non par référence). Il n'est toutefois pas possible d'éliminer entièrement cette possibilité, mais le risque peut être considérablement atténué dans la pratique.

Tout d'abord, en ayant recours à la déclaration du bloc try/finally, il est possible de garantir que les piles soient toujours dépilées au moment où elles quittent le champ du contexte d'une réserve, même si une exception se manifeste.

Deuxièmement, contrairement aux piles habituelles, qui sont automatiquement déposées/dépilées lorsqu'un fil d'exécution entre/sort une méthode, les contextes des réserves peuvent être placés n'importe où, généralement dans des méthodes susceptibles de produire un grand nombre d'informations parasites. Ou en d'autres termes, la plupart des méthodes n'est pas obligée de déclarer le contexte de la réserve pour limiter l'impact sur le code. Dans la majorité des cas, par exemple pour les gestionnaires d'évènements, un seul contexte de réserve suffit, s'il est placé à la racine de la méthode de rappel.

Cette approche présente un autre avantage : le recyclage consiste de manière basique à refixer les pointeurs de la pile au-dessus de la sortie pour être exécutés avec plus de rapidité (indépendamment du nombre d'objets affectés et bien plus rapide que le Collecteur). Nul besoin non plus de dimensionner la pile - sa taille s'adapte de manière automatique, selon l'utilisation de la pire éventualité.

Comme il a été mentionné plus haut, les solutions émergentes n'exigent aucune modification dans la machine virtuelle. Le nouveau mot-clé ne peut donc pas être utilisé pour l'affectation de la pile puisqu'il se charge déjà de l'affectation sur le tas. Afin d'affecter des ressources à partir de la pile, il est nécessaire d'avoir recours aux classes d'objets.

L'approche généralement recommandée consiste à encapsuler l'utilisation des classes d'objets au sein de vos méthodes de classes d'objets. (Le recours à des méthodes de classes d'objets statiques plutôt qu'au constructeur est également recommandé pour d'autres raisons ; je vous renvoie à ce propos à l'ouvrage de Joshua Bloch intitulé « Effective Java » partie 1.) Pour les classes dont vous n'avez pas le contrôle (telles que les classes API Java), les classes d'objets doivent être utilisées de manière explicite.

Demeure bien évidemment le risque de mélanger les références lorsque les références à un objet de la pile existent une fois la pile dépilée. Ce qui peut s'avérer très dangereux ; l'immutabilité ne serait pas valable pour de tels objets, par exemple.

En pratique, très peu de méthodes n'ont à se soucier de la règle d'exportation. Parmi ces méthodes, on compte :

  • les méthodes dont la déclaration du bloc try, finally relatif au contexte de la réserve est définie. Ces méthodes ont pour objectif de vérifier que les objets créés/modifiés au sein du champ du contexte et accessibles à l'extérieur de ce champ sont exportés. L'exportation consiste à extraire l'objet de la réserve où il se trouve pour le déposer dans une réserve extérieure (ou dans le tas s'il n'existe pas de réserve extérieure). L'exportation est une méthode dite récursive et tout objet référencé par l'objet exporté est également exporté à son tour. Dans l'exemple de la Matrice exposée ci-dessous, non seulement l'objet résultant de la matrice lui-même, mais l'ensemble des éléments de la matrice qui ont été affectés sur la pile actuelle sont exportés ;
  • les méthodes dont l'objectif est de créer ou modifier les objets temps réel dits statiques. Comme n'importe quel fil d'exécution peut accéder aux objets statiques, ces derniers doivent être déplacés vers le tas une fois créés ou modifiés. L'exportation d'objets vers le tas permet d'éviter que ces derniers ne soient recyclés, si ce n'est par le collecteur. Ce procédé s'avère particulièrement utile en ce qui concerne les constantes globales statiques.

Bien que l'exportation soit efficace pour les constantes dites statiques, elle contredit l'objectif qui consiste à éviter la récupération de place en contournant l'affectation dynamique du tas dans les cas généraux de mises à jour de l'état du système. Plus particulièrement, l'exportation n'est pas possible pour des applications en temps réel dur qui n'accepteront jamais l'exécution de récupération de place !

V. Aucune récupération de place, en aucun cas !

L'affectation de la pile fournit de bons résultats pour les objets temporaires, mais les applications présentent également des objets dits persistants. Si nous affectons ces objets sur le tas, des informations parasites seront automatiquement générées lorsque ces objets sont replacés (en partant du principe que ces objets sont immuables). Afin d'éviter cette situation, il est pertinent d'être capable de déplacer des objets dits persistants vers une zone temporaire où ils ne seront pas recyclés puis les replacer dans la pile lorsque ces objets doivent être replacés. Le Listing 7 illustre la façon de procéder.

Figure 1.Application en temps réel dur sans aucune récupération de place
Figure 1.Application en temps réel dur sans aucune récupération de place

Ainsi qu'il a été démontré, il est relativement aisé d'éviter une allocation dynamique de la mémoire (ainsi que le travail d'un collecteur) en déplaçant les objets de l'espace d'un contexte vers un autre. Cette technique est également assez rapide puisqu'elle n'exige pas de copier l'objet dans son intégralité.

VI. Avantages liés aux groupes de fils d'exécution

Contrairement au collecteur d'informations parasites, les groupes de fils recyclent l'ensemble des objets et non la mémoire ! En termes de performance, c'est un énorme avantage surtout pour les objets de taille considérable. Le coût relatif à l'affectation de ressources sur le tas est en quelque sorte proportionnel à la taille de l'objet qui a été alloué. En évitant ou en repoussant ce coût d'utilisation, il vous est possible d'augmenter de manière spectaculaire la rapidité d'exécution.

Grâce à la non-exécution du collecteur d'informations parasites, l'application devient plus déterministe et l'Unité Centrale est utilisée de manière exclusive pour le code de votre application (et non pour la gestion de mémoire).

Par exemple, la bibliothèque Javolution fournit un analyseur syntaxique XML 3 à 5 fois plus rapide que le plus rapide des analyseurs conventionnels SAX2 pour la simple raison qu'il n'affecte pas des instances String lors de l'analyse !

VII. Accès Direct à la mémoire

Les programmeurs n'écrivent que rarement des méthodes natives isolées dans le but de lire/écrire chaque bit de contrôle du dispositif mémorisé. Par ailleurs, l'échange des données au moyen des pilotes natifs de C/C++ pose souvent des problèmes lorsque la disposition de la mémoire des objets Java n'est pas déterminée par le compilateur. La disposition des objets en mémoire est retardée lors de l'exécution et déterminée par l'interpréteur (ou le compilateur juste-à-temps). Heureusement, il existe des manières qui permettent d'éviter le goulot d'étranglement dû à l'Interface Native Java (soit Java Native Interface, ou JNI en anglais) et de fournir une interopérabilité directe avec C/C++ grâce aux classes Java, en imitant les structures C/C++ ainsi que les types d'union.

Le langage C définit deux types importants de couches de disposition - structure et union. La couche structure contient une séquence d'objets de types divers et la couche union est, quant à elle, capable de contenir n'importe quel objet, de un à plusieurs, de types différents. La méthode consistant à construire des objets est appliquée de manière récursive.

Les membres des couches structure/union doivent se conformer aux règles d'alignement ; ils doivent plus particulièrement commencer dans une limite d'adressage appropriée à leur type respectif (ainsi, une valeur flottante de 32 bits doit, par exemple, débuter sur une frontière de 4 octets). Par ailleurs, le stockage de valeurs à plusieurs octets doit être adapté selon le type de système Endian (petit ou grand). Ainsi, pour les systèmes big-endian (grand boutien), la valeur la plus significative dans la séquence est rangée sur l'adresse la plus basse (soit la première). En revanche, pour les systèmes dits little-endian (petit boutien), c'est la valeur la moins significative dans la séquence qui sera stockée en premier.

Par exemple, le nombre décimal 12.5 peut prendre la représentation d3 07 00 00 (pour les systèmes little-endian) ou 00 00 07 d3 (pour les systèmes big-endian).

Les processeurs x86/Pentium d'Intel ainsi que leurs clones relèvent des systèmes dits little-endian alors que SPARC de Sun, le processeur 68x00 de Motorola, ainsi que les familles de PowerPC relèvent quant à eux des systèmes dits big-endian.

La représentation des données des objets Java est toujours opaque. Comment donc forcer la disposition de la mémoire pour les objets Java ? Java ne garantit même pas que les variables d'instance soient rangées dans le même ordre que leur ordre déclaratif !

Java se contente d'indiquer la procédure d'initialisation des instances Java nouvellement créées (voir l'ouvrage « Java Language Specification » point 12.5). Ce langage indique, notamment, que les variables d'instance sont initialisées de gauche à droite dans l'ordre où elles apparaissent textuellement dans le code source pour la classe concernée.

Si les variables d'instance sont initialisées de manière séquentielle selon leur ordre déclaratif et si l'initialisation implique la création de classes internes, alors :

  • le constructeur de la classe interne peut fonctionner de pair avec sa classe récipient (soit Structure/Union) ;
  • en ce qui concerne C/C++, il est possible d'utiliser l'ordre déclaratif des membres de la couche structure (classes internes) afin de définir la disposition de la mémoire.

Il est par conséquent permis d'imiter les couches structures et union de C en prolongeant soit la classe Structure soit la classe Union et en créant des membres d'instances de classes internes.

Cette création de classes internes peut suivre les règles d'alignement ainsi que les capacités propres au langage C (par exemple les champs de bits, paqueté/non paqueté, systèmes big/little endian) afin de faciliter l'échange de données entre les langages C/C++ et Java.

Le code exposé ci-dessous illustre la conversion d'une classe Structure C vers une classe Structure Java. Comme la correspondance ne s'effectue qu'un à un, les conversions des fichiers bibliographiques C/C++ vers des classes Java peuvent être réalisées de manière automatique au moyen d'un simple outil d'analyse syntaxique.

Il existe de nombreux domaines d'applications vers les classes Java Structure/Union, parmi lesquelles :

  • codage/décodage direct de messages issus du flot de données pour lesquels la structure est définie par le code patrimonial de C/C++ ;
  • utilitaires de flux de données orientés blocs (canaux de communication java.nio.channels) ;
  • partage de la mémoire entre des applications Java et des applications natives ;
  • sérialisation/désérialisation des objets Java (contrôle complet, absence d'en-têtes de classes) ;
  • mise en correspondance des objets Java avec des adresses physiques (au moyen des Interfaces Natives Java).

Il existe de nombreux domaines d'applications vers les classes Java Structure/Union, parmi lesquelles :

  • codage/décodage direct de messages issus du flot de données pour lesquels la structure est définie par le code patrimonial de C/C++ ;
  • utilitaires de flux de données orientés blocs (canaux de communication java.nio.channels) ;
  • partage de la mémoire entre des applications Java et des applications natives ;
  • sérialisation/désérialisation des objets Java (contrôle complet, absence d'en-têtes de classes) ;
  • mise en correspondance des objets Java avec des adresses physiques (au moyen des Interfaces Natives Java).

VIII. Conclusion

Il est possible de rendre Java aussi efficace que les langages C/C++ en matière de déterminisme. L'utilisation du compilateur Gnu Java pour des applications en temps réel, par exemple, peut être compilée et le collecteur d'informations parasites non déterministe peut être ignoré de l'application à l'aide des contextes de réserves (pool context en anglais).

L'écart de rapidité et de performance entre le code natif généré par C/C++ et celui généré à partir du code Java est alors réduit considérablement. Enfin, si vous ajoutez les classes Structure/Union de Java, vous obtenez une véritable Solution Java dotée d'à peu près tous les éléments fournis par C/C++ et enrichie d'une bibliothèque standard complète pour écrire de réelles applications adaptées aux contextes des multiplateformes.

IX. Listings de code

IX-A. Listing 1. Exemple de classe de base dotée d'un support pour le compte de références

Listing 1. Exemple de classe de base dotée d'un support pour le compte de références
Sélectionnez
public abstract class SceneGraphObject {
    private int refCount;
    // Affecte les ressources matérielles 
    // (par exemple Id liste d'affichage).
    protected abstract void allocate();
    // Libère les ressources matérielles,
    // détache les fils et retourne cet 
    // objet à la réserve.
    protected abstract void dispose();
    protected synchronized final void attach() {
        if (refCount++ == 0) {
            allocate();
        }
    }
    protected synchronized final void detach() {
        if (--refCount == 0) {
            dispose();
        }
    }
}

IX-B. Listing 2. Définition du contexte d'une réserve locale

Listing 2. Définition du contexte d'une réserve locale
Sélectionnez
// Entre dans le contexte d'une réserve.
PoolContext.enter();
try {
    ... // Affecte les objets à partir du groupe
        // de fils (autrement dit la pile).
} finally {
    // Sort du contexte de la réserve (recycle tout)
    PoolContext.exit();
}

IX-C. Listing 3. Gestionnaire d'évènements asynchrones ayant recours au contexte de la réserve

Listing 3. Gestionnaire d'évènements asynchrones ayant recours au contexte de la réserve
Sélectionnez
class MyAction extends ActionListener {
    public void actionPerformed(ActionEvent e) {
        PoolContext.enter();
        try {
            ... // Action réalisée dans
                // le contexte de la réserve.
        } finally {
            PoolContext.exit(); // Recycle.
        }
    }
}

IX-D. Listing 4. Affectation de la pile pour des classes de la bibliothèque standard

Listing 4. Affectation de la pile pour des classes de la bibliothèque standard
Sélectionnez
static final ObjectFactory LIST_FACTORY =
    new ObjectFactory() {
    public Object create() {
        return new ArrayList(256);
    }
};
ArrayList list = (ArrayList) LIST_FACTORY.object();
// Pile affectée

IX-E. Listing 5. Matrice exponentielle ayant recours à un contexte de réserve local (absence d'informations parasites générées)

Listing 5. Matrice exponentielle ayant recours à un contexte de réserve local (absence d'informations parasites générées)
Sélectionnez
public Matrix pow(int exp) {// exp > 0
    PoolContext.enter();
    try {
        Matrix pow2 = this;
        Matrix result = null;
        while (exp >=1) {// Itération.
            if ((exp &1) ==1) {
                result = (result == null)?
                    pow2 : result.multiply(pow2);
            }
            pow2 = pow2.multiply(pow2);
            exp >>>=1;
        }
        return (Matrix) result.export();
    } finally {
        PoolContext.exit();
    }
}

IX-F. Listing 6. Les objets statiques devraient être déplacés vers le tas

Listing 6. Les objets statiques devraient être déplacés vers le tas
Sélectionnez
public class Complex extends RealtimeObject {
    public static final Complex I 
        = (Complex) valueOf(0.0, 1.0).moveHeap();
    ...
}

IX-G. Listing 7. Partage des objets entre les fils d'exécution sans avoir recours au tas

Listing 7. Partage des objets entre les fils d'exécution sans avoir recours au tas
Sélectionnez
class Navigator extends Thread {
    private Coordinates position = Coordinates.valueOf(0, 0);
    public void run() {
        while (true) {
            PoolContext.enter();
            try {
                // Calcule la position (sur la pile).
                Coordinates newPosition =
                    calculatePosition(newTime);
                synchronized (this) { // Mise à jour atomique.
                    // Permet le recyclage de l'ancienne position.
                    position.preserve(false);
                    // Met à jour la position.
                    position = newPosition;
                    // Empêche le recyclage de la nouvelle 
                    // position. 
                    position.preserve(true); 
                }
            } finally {
                PoolContext.exit();
            }
        }
    }
    public synchronized Position
        getPosition() { // Sur la pile.
        return position.copy(); // Copie locale sur la pile.
    }
}

IX-H. Listing 8. Conversion d'une classe Structure C/C++ vers une classe Structure Java

Listing 8. Conversion d'une classe Structure C/C++ vers une classe Structure Java
Sélectionnez
struct Date {
    unsigned short year;
    unsigned char month;
    unsigned char day; };
struct Student {
    char  name[64];
    struct  Date birth;
    float  grades[10];
    Student*  next; };

// Java 
public static class Date extends Struct {
    public final Unsigned16 year = new Unsigned16();
    public final Unsigned8 month = new Unsigned8();
    public final Unsigned8 day  = new Unsigned8(); }
public static class Student extends Struct {
    public final UTF8String
        name = new UTF8String(64);
    public final Date
        birth = (Date) new StructMember(Date.class).get();
    public final Float32[]
        grades = (Float32[]) new ArrayMember(
            new Float32[10]).get();
    public final public final Reference32
        next = new Reference32(Student.class); }

X. Note et remerciement du gabarisateur

Cet article a été mis au gabarit de developpez.com. Dans la mesure du possible, l'esprit d'origine de l'article a été conservé. Cependant, certaines adaptations ont été nécessaires. Voici le lien vers le PDF d'origine : temps-reel.pdf.

Le gabarisateur remercie Claude LELOUP pour sa correction orthographique.