Published by et

Il y a 7 mois -

Temps de lecture 24 minutes

GraalVM – Native-image par l’exemple

Lors d’un précédent article, nous avons vu en quoi consiste GraalVM et son apport à l’écosystème Java. Une des fonctionnalités les plus prometteuses de GraalVM est la génération d’image native. En effet, à l’aide de SubstrateVM, le code Java peut désormais être compilé directement vers du binaire natif, là où la tradition du monde Java a toujours été de compiler vers du bytecode interprétable par la JVM.

Dans cet article, nous nous concentrons sur la commande native-image, qui est une commande fournie optionnellement par le SDK de GraalVM et dont le but est, comme son nom l’indique, de générer des images natives à partir de programmes Java via une commande utilisable par un simple Shell. Nous allons ainsi voir un ensemble d’exemples permettant de comprendre comment, dans la pratique, générer des images natives depuis un code source écrit en Java.

Tous les exemples de code qui vont suivre sont disponibles sur un dépôt GitHub.

Installer GraalVM & native-image

Maintenant que l’on a compris ce que sont GraalVM et native-image, il est temps de mettre les mains dans le cambouis et d’essayer de voir à quoi ressemble la compilation native. Mais, avant de pouvoir utiliser native-image, il faut l’installer ! En effet, GraalVM étant une alternative à la JVM classique, native-image n’utilise pas le SDK Java classique mais un SDK spécifique. Donc, avant toute chose, il faut commencer par installer le SDK de GraalVM que l’on trouvera à l’adresse suivante : https://www.graalvm.org/downloads/.

Attention : pour que l’outil native-image fonctionne sous un poste UNIX (Mac ou Linux), les dépendances « glibc-devel« , « zlib-devel » et « gcc » doivent être préalablement installées (ce qui ne devrait pas poser de problème si vous avez déjà compilé du C/C++ sur votre poste). Sur un poste Windows, vous devez avoir MSVC (Microsoft Visual C++) installé sur votre poste (pour plus d’informations, reportez-vous à la documentation officielle).

Pour la suite de cet article, nous supposerons que :

  • vous avez installé la version Community de GraalVM ;
  • vous avez décompressé l’archive téléchargée ;
  • vous avez accès à un shell de type ‘bash’;
  • vous utilisez un système de type UNIX (Mac ou Linux) : mais avec quelques aménagements, ce qui suit devrait aussi fonctionner sous Windows ;
  • enfin, avant toutes les commandes de cet article, nous vous invitons à exécuter la commande suivante au moins une fois par session de Bash :
export GRAALVM_HOME=<Le dossier dans lequel vous avez installé Graalvm>

(Sur Mac OS X, il ne faudra pas oublier d’ajouter /Contents/Home à la fin de la variable GRAALVM_HOME car l’archive n’a pas exactement le même format)

Une fois que GraalVM est installé, on ne peut pas encore utiliser native-image. En effet, native-image n’est pas installé par défaut dans le SDK de GraalVM : il faut l’installer séparément. Heureusement GraalVM fournit un moyen simple d’installer des briques additionnelles : gu. En effet, gu, qui signifie « GraalVM Updater », est une commande qui permet d’installer automatiquement les modules optionnels de GraalVM. Ainsi pour installer native-image, il suffit de faire :

$GRAALVM_HOME/bin/gu install native-image

Et c’est tout : native-image est installé sur votre système !

Premier exemple : Un HelloWorld avec native-image

Maintenant que GraalVM est installé sur votre système, on peut utiliser native-image pour générer des exécutables natifs à partir de nos projets. D’ailleurs, créons un premier projet de type Hello World. Pour cela, on crée un projet Maven très classique, contenant d’une part un fichier pom.xml très simple :

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>fr.publicis-sapient-enginering.graalvm</groupId>
    <artifactId>native-image.example</artifactId>
    <version>1.0-SNAPSHOT</version>
    <properties>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>
    </properties>
</project>

et d’autre part une classe très simple dans le fichier src/main/java/somepackage/Main.java :

package somepackage;

public class Main {
    public static void main(String[] args) {
        System.out.println("Hello");
    }
}

Ensuite, il nous suffit de générer le JAR via la commande Maven :

cd <repertoire racine>
mvn clean package

Jusque là, nous avons réalisé un simple Hello World, mais on peut désormais générer notre exécutable natif :

  $GRAALVM_HOME/bin/native-image -H:Class=somepackage.Main  -cp target/native-image.example-1.0-SNAPSHOT.jar  -H:Name=Application

Dans cette commande, somepackage.Main est notre classe Java principale, Application sera le nom de notre exécutable, enfin target/native-image.example-1.0-SNAPSHOT.jar est l’unique JAR qui compose notre ClassPath. Dans native-image, le ClassPath devra contenir tous les JARs et toutes les classes composant notre application et ses dépendances. Mais, comme on le verra dans le cinquième exemple, il pourra aussi contenir certains fichiers de configuration qui seront utilisés par native-image lui-même.

Enfin, après de longues minutes d’attente, un exécutable Application a dû apparaître dans votre dossier courant. Félicitations, vous avez créé votre premier exécutable natif.

Remarquons que native-image ne compile pas directement des fichiers Java vers des exécutables natifs, mais compile plutôt des fichiers JAR, c’est-à-dire du bytecode. Native-image ne permet donc pas de se passer des outils de build classiques, mais arrive en complément de ceux-ci.

L’exécutable ainsi construit peut être lancé dans des machines qui n’ont pas de JVM, mais cette machine devra avoir le même système que la machine avec laquelle l’exécutable a été construit. Ainsi, on ne peut pas exécuter sous Linux un exécutable compilé sous Mac OS.

Deuxième exemple : Un exemple avec de la réflexion Java

En théorie, la méthode précédente pourrait être utilisée quasiment telle quelle pour n’importe quel projet, aussi gros soit-il. Si le projet contient des dépendances, il suffira juste d’ajouter les JARs dans le ClassPath (ou de faire un uber-jar).

En pratique, plus vous utilisez des dépendances externes, plus il y a de chances pour qu’elles utilisent des mécanismes de Java non couverts ou partiellement/différemment couverts par les images natives.
Un exemple typique d’un tel mécanisme est la réflexion Java. Ce mécanisme est très fréquent dans de nombreuses bibliothèques et framework Java : Java EE, Spring, Hibernate, Jackson, etc … utilisent tous la réflexion Java. Au vu de la popularité de ce mécanisme, native-image se devait de le supporter.

Toutefois, comme nous l’avons vu dans un article précédent, et contrairement à la JVM classique, native-image ne peut pas supporter directement la réflexion. La faute en revient à la Closed World Assumption qui nécessite de connaître tout le code qui va être exécuté au moment de la compilation. Si cela permet d’optimiser les applications Java, cela interdit d’utiliser la réflexion car native-image ne peut connaître à l’avance les classes qui seront exécutées puisque celles-ci seront potentiellement chargées au runtime.

Du coup, l’astuce pour activer la réflexion avec native-image, est de le “prévenir” au moment de la compilation. On indiquera ainsi via un fichier de configuration sur quelles classes on veut activer la réflexion. Ceci permettra à native-image de toujours savoir lors de la compilation quel code sera exécuté et de ne pas déroger à la Closed Word Assumption.

Pour voir ce que cela donne en pratique, créons une deuxième classe très simple dans notre projet :

package somepackage;

public class OtherClass {

    public String someMethod() {
        return "Another Hello World";
    }
}

Ensuite, revenons dans notre classe Main, et utilisons notre classe OtherClass, mais au lieu d’utiliser cette dernière de manière classique, tentons de l’utiliser via l’Api Reflection de Java :

package somepackage;

import java.lang.reflect.Method;

public class Main {
    public static void main(String[] args) throws Exception {
        String packageName = "some";
        packageName += "package";
        Class clazz = Class.forName(packageName + ".OtherClass");
        Method method = clazz.getMethod("someMethod");
        System.out.println(method.invoke(clazz.newInstance()));
    }
}

En relisant le code, on remarque que le nom de la classe et du package sont découpés : en effet, native-image est plutôt intelligent et s’il remarque qu’une chaine de caractère a le même nom qu’une classe, il peut activer automatiquement la réflexion pour cette classe (dans les faits, il provoquera une erreur mais à un autre endroit). Du coup, pour illustrer la différence JVM/image native, nous avons découpé le nom du package pour éviter que native-image n’active automatiquement la réflexion pour cette classe.
En dehors de cette subtilité, notre nouvelle classe Main utilise la réflexion Java de manière tout à fait classique. Tentons donc de la compiler nativement avec la même méthode que précédemment :

mvn clean package
$GRAALVM_HOME/bin/native-image -H:Class=somepackage.Main  -cp target/native-image.example-1.0-SNAPSHOT.jar  -H:Name=Application

Au bout d’un certain temps, on devrait avoir l’erreur suivante :

Warning: Aborting stand-alone image build due to reflection use without configuration.

Et la dernière ligne devrait être :

Warning: Image 'Application' is a fallback image that requires a JDK for execution (use --no-fallback to suppress fallback image generation).

Cela signifie que même si un fichier Application a été créé dans votre répertoire, celui-ci utilise la JVM et n’est pas vraiment un exécutable natif.

Pour outrepasser cette erreur, il suffit d’activer la réflexion sur la classe OtherClass. Pour cela ajoutons le fichier reflect-config.json suivant dans notre répertoire courant :

[
  {
    "name":"somepackage.OtherClass",
    "allPublicConstructors": true,
    "methods": [{
      "name": "someMethod",
      "parameterTypes": []
    }]
  }
]

Ce fichier indique qu’il faut activer la réflexion sur la méthode someMethod de notre classe. Utilisons ce fichier pour compiler nativement notre JAR :

$GRAALVM_HOME/bin/native-image -H:Class=somepackage.Main  -cp target/native-image.example-1.0-SNAPSHOT.jar  -H:Name=Application -H:ReflectionConfigurationFiles=./reflect-config.json

Cette fois-ci la compilation devrait avoir fonctionné, le fichier Application est exécutable.

Pour connaitre les options de configuration possibles avec le fichier reflect.json, on pourra consulter le paragraphe « Manual configuration » de la page https://github.com/oracle/graal/blob/master/substratevm/Reflection.md

Troisième exemple : Un exemple avec lecture de ressources

Une autre victime de la « Closed World Assumption », en plus de la réflexion, est l’inclusion de ressources. Pour rappel, une ressource est toute forme de données (image, texte, etc…) qui peut être accédée indépendamment du dossier dans lequel la JVM s’exécute. En pratique, la ressource sera recherchée dans le ClassPath (pour plus d’informations, on pourra consulter la documentation officielle d’Oracle). Ainsi, un moyen simple de transformer un fichier en ressource est de l’ajouter dans un JAR du ClassPath. C’est pourquoi, dans un projet Maven, pour ajouter un fichier en tant que ressource, il suffit de l’ajouter au répertoire src/main/resources, il sera automatiquement ajouté dans le JAR généré.

Par exemple, créons un fichier file.txt dans le dossier src/main/resources contenant n’importe quel texte :

Some text

Ensuite modifions notre classe Main pour récupérer cette ressource :

package somepackage;

public class Main {

    public static void main(String[] args)  {
        ClassLoader classLoader = ClassLoader.getSystemClassLoader();
        boolean ressourceIsFound = classLoader.getResource("file.txt") != null;
        if(ressourceIsFound) {
            System.out.println("La ressource existe");
        }else{
            System.out.println("La ressource n'a pas été trouvée");
        }
    }
}

Construisons notre JAR avec la méthode classique :

mvn clean package

Si nous exécutons cette classe avec Java :

java -cp target/native-image.example-1.0-SNAPSHOT.jar somepackage.Main

On obtient la chaîne de caractères suivante : « La ressource existe ».

Si maintenant, on tente d’utiliser native-image :

$GRAALVM_HOME/bin/native-image -H:Class=somepackage.Main  -cp target/native-image.example-1.0-SNAPSHOT.jar  -H:Name=Application

On obtient le même genre d’erreur que précédemment :

Warning: Aborting stand-alone image build due to accessing resources without configuration.

Cela signifie que la compilation n’a pas été une « compilation native ». Ce qui s’est passé ici, c’est que native-image a détecté l’utilisation de la fonction getResource, alors qu’aucune des ressources n’a été ajoutée lorsque que l’on a appelé native-image. De ce fait, pour que native-image prenne en compte les ressources, il faut l’ajouter à la ligne de commande :

$GRAALVM_HOME/bin/native-image -H:Class=somepackage.Main  -cp target/native-image.example-1.0-SNAPSHOT.jar  -H:Name=Application -H:IncludeResources='file.txt'

Cette fois ci, la compilation et l’exécution fonctionnent.

Avant de conclure cet exemple, faisons trois remarques :

  • Si le fichier indiqué dans le paramètre -H:IncludeResources= n’existe pas, la compilation fonctionne (pas de fallback images), mais l’exécution indique « La ressource n’a pas été trouvée ».
  • S’il y a plusieurs fichiers à inclure en tant que ressources, on peut soit utiliser plusieurs fois le paramètre IncludeResources (-H:IncludeResources='file.txt' -H:IncludeResources='file2.txt') soit utiliser une expression rationnelle (-H:IncludeResources='fil.*txt').
  • Il existe aussi une autre méthode pour ajouter des ressources qui ressemblent à ce que l’on a fait pour la réflexion : on peut utiliser le paramètre -H:ResourceConfigurationFiles=/path/to/resource-config.json et créer un fichier JSON qui contient toutes les ressources à ajouter (cf https://github.com/oracle/graal/blob/master/substratevm/Resources.md ).

Quatrième exemple : Initialisation statique des classes Java

Dans les exemples précédents, nous avons vu quelles sont les limitations de native-image par rapport à une stack Java classique et comment on peut les contourner avec la configuration adéquate. Pour cet exemple, au contraire, nous allons voir une fonctionnalité de native-image qui n’a pas d’équivalent dans le monde Java classique : l’initialisation statique des classes au build-time.
Pour comprendre de quoi il s’agit, reprenons et modifions notre exemple :

package somepackage;

import java.util.ArrayList;
import java.util.List;

public class Main {

    static int NB_LETTERS = 26;
    static List<Character> ALPHABET;

    static {
        try {
            // Ici, on peut faire un traitement potentiellement beaucoup plus long
            ALPHABET = new ArrayList<>(NB_LETTERS);
            for (char i = 'a'; i <= 'z'; i++) {
                ALPHABET.add(i);
            }
        }catch (Exception e){}
        System.out.println("End of static initialisation");
    }


    public static void main(String[] args) throws Exception {
        for (char c : ALPHABET){
            System.out.println(c);
        }
    }
}

Cette fois-ci, pas de réflexion ni de ressources. On peut tout à fait compiler cet exemple comme dans le tout premier… Mais on peut faire mieux.

En effet, on remarque qu’il y a une portion de code qui est statique, c’est-à-dire qu’elle est exécutée au chargement de la classe et donc au moment de l’exécution de l’application, (au runtime comme on dit au pays de l’oncle Sam).
Or, en regardant bien, vu que l’on ne fait appel à aucune ressource externe, il n’y a rien qui empêche ce code d’être exécuté au moment de la compilation (au build-time). Cela tombe bien, native-image propose un mécanisme qui permet justement cela.

Ainsi, pour tester cette fonctionnalité, on commence par construire notre projet de manière classique :

mvn clean package

Et on exécute ensuite la commande native-image avec des paramètres qui permettent d’exécuter le code statique au build-time :

$GRAALVM_HOME/bin/native-image -H:Class=somepackage.Main  -cp target/native-image.example-1.0-SNAPSHOT.jar  -H:Name=Application --initialize-at-build-time=somepackage.Main

Ainsi, au milieu des lignes indiquant la progression de la compilation, on devrait voir apparaître la chaîne suivante :

End of static initialisation

Celle-ci indique que le code statique a été exécuté. Si on lance notre application, cette ligne ne devrait pas apparaître, signe que le code statique n’est plus exécuté au runtime.
Dans le cas des applications qui ont de gros blocs statiques, on peut ainsi gagner énormément de temps au démarrage de l’application car tous ces blocs statiques auront déjà été traités. Par contre, on comprend bien que le défaut de cette méthode est qu’elle peut augmenter significativement le temps de build, mais dans les faits, cela est rarement un problème, la génération d’image native étant en général bien moins fréquente que la génération de bytecode.

Pour être complet, il faut préciser deux choses :

  • La première, c’est que le paramètre --initialize-at-build-time peut prendre en paramètre soit une classe (comme dans notre exemple) soit un package. Ainsi, si l’on avait mis --initialize-at-build-time=somepackage, notre exemple aurait aussi marché, car on aurait exécuté les blocs statiques de toutes les classes du package somepackage.
  • Il existe aussi un paramètre --initialize-at-run-time qui exclut certaines classes d’avoir leur bloc statique exécuté au build-time. Ainsi, si on avait pris comme paramètre --initialize-at-build-time=somepackage --initialize-at-run-time=somepackage.subpackage, cela signifierait que tous les blocs statiques des classes du package somepackage devront être exécutés au build-time sauf celles qui sont dans le sous-package somepackage.subpackage.

Cinquième exemple : Utilisation des fichiers native-image.properties

Tout ce qui qui a été vu dans les exemples précédents est très utile mais cela ne permet pas de faire des applications d’envergure. En effet, les applications qui ont très peu de dépendances et qui se déploient sous forme d’un seul JAR sont extrêmement rares en pratique.

Or, si on veut construire une application avec ses dépendances en utilisant native-image, on se heurtera vite à un problème : comment faire pour prendre en compte toutes les classes utilisant la réflexion (ou des ressources) dans toutes les dépendances ?

Une solution serait :

  • de répertorier toutes les dépendances ;
  • de lister toutes les classes utilisant la réflexion ou des ressources ;
  • puis de créer le fichier reflect.json qui contiendra toutes les classes sur lesquelles la réflexion doit être activée ;
  • et enfin d’ajouter toutes les ressources lors de l’appel à la ligne de commande de native-image.

Mais cette méthode devient vite fastidieuse, voire même irréalisable : nous n’avons pas forcément les moyens ni l’envie d’aller voir le code source de toutes nos dépendances.

Du coup, une solution plus réaliste serait que chaque dépendance indique quelles sont les classes sur lesquelles on veut activer la réflexion, ainsi que les ressources à utiliser. Il se trouve que native-image propose justement un mécanisme de ce type.

L’idée est la suivante : au lieu d’indiquer la configuration voulue via la ligne de commande (ce qu’on a fait jusqu’à présent), on l’indique via un fichier de configuration situé dans le ClassPath. Ainsi, chaque dépendance du ClassPath pourra contenir ce fichier pour indiquer sa configuration.
En pratique, native-image va chercher tous les fichiers nommés native-image.properties situés quelque part sous le chemin META-INF/native-image/ du ClassPath, et va y récupérer tous les paramètres nécessaires. Du coup, chaque dépendance pourra ajouter un fichier sous ce chemin pour indiquer sa configuration.

Concrètement, un fichier native-image.properties se présente sous la forme suivante :

Args:  -H:IncludeResources='file.txt'

Et si ce fichier se trouve dans le ClassPath au bon endroit, le paramètre -H:IncludeResources='file.txt' sera automatiquement ajouté à la commande native-image. Ainsi la présence de ce fichier META-INF/native-image/native-image.properties dans le ClassPath rendra la commande

$GRAALVM_HOME/bin/native-image -H:Class=somepackage.Main  -cp target/native-image.example-1.0-SNAPSHOT.jar  -H:Name=Application

équivalente à la commande :

$GRAALVM_HOME/bin/native-image -H:Class=somepackage.Main  -cp target/native-image.example-1.0-SNAPSHOT.jar  -H:Name=Application -H:IncludeResources='file.txt'

Ainsi, la convention, quand on veut créer un JAR qui ajoute ses propres paramètres à native-image, est de placer le fichier sous le dossier META-INF/native-image/{groupId}/{artifactId}groupId et artifactId sont les identifiants Maven du JAR. Cette convention permet donc d’éviter les conflits entre les différents fichiers tout en garantissant que native-image va tous les prendre en compte (car ils sont sous le chemin META-INF/native-image).

Maintenant, si on veut activer la réflexion sur certaines classes, on a besoin, comme nous l’avons vu dans le deuxième exemple, d’un fichier JSON et d’y faire référence via les paramètres de la commande native-image. Avec le mécanisme des fichiers native-image.properties, on peut inclure le nom de ce ficher JSON dans les paramètres inscrits dans native-image.properties.
Par exemple, on pourra écrire le fichier META-INF/native-image/some-folder/native-image.properties suivant :

Args = -H:ReflectionConfigurationFiles=${.}/some_reflect.json

Le seul point notable du fichier précédent est la présence du symbole ${.} : il s’agit d’un raccourci qui représente le dossier dans lequel se trouve le fichier native-image.properties courant. Dans cet exemple, on indique donc que le fichier some_reflect.json se trouvant dans le même répertoire que le fichier de configuration est un fichier permettant d’activer la réflexion sur certaines classes.

Armé de tout ce qu’on a vu jusqu’ici, on peut rédiger un exemple résumant toutes les fonctionnalités présentées jusqu’à présent :

Ficher src/main/java/somepackage/Main.java :

package somepackage;

import java.lang.reflect.Method;

public class Main {

    static {
        // use of static initialisation :
        System.out.println("End of static initialisation");
    }

    public static void main(String[] args) throws Exception {
        // use of reflection :
        String className = "somepackage.OtherClass";
        Class clazz = Class.forName(className);
        Method method = clazz.getMethod("someMethod");
        System.out.println(method.invoke(clazz.newInstance()));

        // use of resources :
        ClassLoader classLoader = ClassLoader.getSystemClassLoader();
        boolean ressourceIsFound = classLoader.getResource("file.txt") != null;
        if(ressourceIsFound) {
            System.out.println("La ressource existe");
        }else{
            System.out.println("La ressource n'a pas été trouvée");
        }
    }
}

Fichier src/main/java/somepackage/OtherClass.java :

package somepackage;

public class OtherClass {

    public String someMethod() {
        return "Another Hello World";
    }
}

Fichier resources/file.txt :

Some text

Fichier src/main/resources/META-INF/native-image/somegroupid/someartifactid/reflect.json :

[
  {
    "name":"somepackage.OtherClass",
    "allPublicConstructors": true,
    "methods": [{
      "name": "someMethod",
      "parameterTypes": []
    }]
  }
]

Et enfin, le fichier stratégique src/main/resources/META-INF/native-image/somegroupid/someartifactid/native-image.properties :

Args = -H:ReflectionConfigurationResources=${.}/reflect.json --initialize-at-build-time=somepackage.Main -H:IncludeResources=file.txt

Une fois que ces fichiers sont initialisés, on peut lancer les commandes suivantes pour récupérer une image native :

mvn clean package 

$GRAALVM_HOME/bin/native-image  -cp target/native-image.example-1.0-SNAPSHOT.jar  -H:Class=somepackage.Main  -H:Name=Application

Ainsi, on a obtenu une image native qui a mélangé tout ce que nous avions vu auparavant, même l’initialisation des blocs statiques au build-time !

Le contenu des JARs étant inclus dans le ClassPath, il est important de noter que si nous faisions une dépendance JAR au lieu d’un exécutable, le contenu du fichier native-image.properties serait pris en compte sans que l’utilisateur (qui appelle la commande native-image) n’ait besoin de connaître les détails de la dépendance. Pour cela, il suffit juste d’ajouter le JAR dans le ClassPath et donc dans les JARs inclus après le paramètre -cp dans la commande précédente.
On répond ainsi au problème posé au début de cet exemple.

Le framework Micronaut s’appuie sur ce mécanisme pour fournir des packages adaptant les librairies Java existantes à la compilation native.

Sixième exemple : Substituer du code spécifiquement pour native-image

Les exemples que nous vous avons vus jusqu’ici avaient pour but de montrer comment, à l’aide d’un peu de configuration, on pouvait outrepasser les limitations de GraalVM. Mais comme nous l’avons vu dans un précédent article, il y a des limitations de GraalVM qui ne se surmontent pas, même avec de la configuration.

Mais alors que faire si on a du code qui dépend de ces mécanismes ? Une solution serait de réécrire complètement le code.

Mais peut-on faire mieux ? Peux-t-on écrire du code qui cible uniquement SubstrateVM (et pas la JVM classique) ? La réponse est oui, SubstrateVM (et donc native-image) dispose d’un mécanisme pour tout simplement remplacer un code destiné à une JVM classique. Ce genre de mécanisme ne doit être utilisé que si aucun autre mécanisme n’est applicable.

Ce mécanisme reposant sur des annotations Java, celles-ci doivent être importées depuis une dépendance Maven :

    <dependencies>
        <dependency>
            <groupId>com.oracle.substratevm</groupId>
            <artifactId>svm</artifactId>
            <version>19.1.1</version>
        </dependency>
    </dependencies>

Une fois, que l’on a ajouté la dépendance Maven, on peut illustrer ce mécanisme à l’aide d’un exemple très simple composé uniquement de deux classes :

Fichier src/main/java/somepackage/Main.java :

package somepackage;

public class Main {

   public static void main(String[] args) throws Exception {
       JavaClass otherObject = new JavaClass();
       otherObject.printMyself();
   }
}

Fichier src/main/java/somepackage/JavaClass.java :

package somepackage;

public class JavaClass {
    public JavaClass(){ }

    public void printMyself(){
        System.out.println("Hello I'm classical java code");
    }
}

Ce projet, très simple, une fois exécuté, doit juste afficher « Hello I’m classical java code ».

mvn clean package 

java -cp target/native-image.example-1.0-SNAPSHOT.jar somepackage.Main

Maintenant, supposons, que pour une raison ou une autre, on veuille remplacer la méthode JavaClass.printMyself quand on compile via native-image ; cela ne fait pas tellement de sens ici, vu que cette méthode fait un simple println, mais tentons de la remplacer pour l’exemple. Pour cela, il suffit de créer une troisième classe dont le but sera de remplacer JavaClass :

Fichier src/main/java/somepackage/SubstrateClass.java :

package somepackage;

import com.oracle.svm.core.annotate.Substitute;
import com.oracle.svm.core.annotate.TargetClass;

@TargetClass(JavaClass.class)
final public class SubstrateClass {
    @Substitute
    public void printMyself(){
        System.out.println("Hello I'm native-image code");
    }
}

Si maintenant, on build le projet et on qu’on l’exécute via une JVM classique :

mvn clean package 

java -cp target/native-image.example-1.0-SNAPSHOT.jar somepackage.Main

On obtient toujours, « Hello I’m classical java code ». Alors que si on rebuild et qu’on exécute l’image via native-image :

$GRAALVM_HOME/bin/native-image  -cp target/native-image.example-1.0-SNAPSHOT.jar  -H:Class=somepackage.Main  -H:Name=Application 
 
./Application

On obtient « Hello I’m native-image code ». Le code a donc bel et bien été remplacé.

Pour conclure cet exemple, précisons que le package com.oracle.svm.core.annotate contient d’autres annotations qui indiquent à la commande native-image (en fait SubstrateVM utilisé par native-image) comment gérer les classes problématiques : ainsi à la place de remplacer une méthode, on peut seulement lui ajouter des annotations (via @AnnotateOriginal) ou même tout simplement supprimer la méthode (via @Delete).

Ce genre d’annotation est souvent utilisé pour l’écriture d’extensions dans Quarkus.

Conclusion

La compilation native de projets écrits en Java a de nombreux avantages : en théorie, elle permet de créer des programmes qui démarrent bien plus rapidement et utilisent en général moins de mémoire.
Mais cela ne se fait pas sans inconvénients : certains mécanismes de Java ne sont pas utilisables ou nécessitent de la configuration supplémentaire. On perd également la portabilité de l’exécutable. De plus, on doit ajouter que, dans certains cas, les performances peuvent être moindres que celles de la JVM lorsque celle-ci est “chaude” (c’est-à-dire une fois que le compilateur Just-In-Time a fait toutes les optimisations possibles après le démarrage de l’application), mais ces considérations dépassent le cadre de cet article.

Cela dit, le démarrage plus rapide et surtout la plus faible empreinte mémoire sont essentiels pour de nombreuses architectures “modernes” comme les micro-services ou les Function As A Service (comme les fonctions “Lambda” d’Amazon). C’est pourquoi, de nouveaux frameworks ont décidé d’aller encore plus loin : en plus d’utiliser native-image, ils en fournissent une surcouche mettant encore plus en pratique la philosophie qui consiste à déplacer au build-time des traitements qui jusque là ont été faits au runtime. Les frameworks les plus connus ayant cette philosophie sont Micronaut et Quarkus.

Published by et

Commentaire

1 réponses pour " GraalVM – Native-image par l’exemple "

  1. Published by , Il y a 5 mois

    Nice article.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Nous recrutons

Être un Sapient, c'est faire partie d'un groupe de passionnés ; C'est l'opportunité de travailler et de partager avec des pairs parmi les plus talentueux.