Depuis quelques années, les usages des programmes informatiques évoluent, les services cloud se multiplient, les langages offrent toujours plus de fonctionnalités.
Dans l’éco-système JAVA, beaucoup de choses ont évolués. Les entreprises commencent enfin à sortir des versions 8 et inférieures pour profiter des apports des dernières versions. Avec l’arrivée du JAVA Natif et de GraalVM, c’est une autre facette du JAVA qui est mise à disposition des projets. Découvrons ensemble les apports et les points de vigilance du Java Natif avec GraalVM.
Sommaire
Rappel du fonctionnement de la compilation d’une application JAVA
Une application JAVA n’est pas directement compréhensible par le processeur d’un ordinateur. Elle a besoin d’un intermédiaire pour se faire comprendre : La JVM ou Java Virtual Machine.
Cette JVM va s’adapter à la machine (Windows, macOS, Linux, processeur x86, ARM, Power…). C’est ce qui va permettre de créer un seul paquet applicatif compatible avec une grande diversité d’ordinateur.
Le code source (vos fichiers .java) sont compilé en « bytecode » (vos fichiers au format .class). C’est ce bytecode qui sera ensuite interprété par la JVM afin de faire tourner votre application.
Des performances en retrait
Le JAVA est aussi connu pour ses performances moindre par rapport à d’autres langages comme le C++, le Go ou tout autre langage directement interpretable par un processeur.
Aujourd’hui, les principaux reproches fait aux applications JAVA sont :
- Un temps de démarrage long, dû au démarrage de la JVM, au chargement des classes…
- Une empreinte mémoire élevée, dû à la JVM, au système Garbage Collector
- Une utilisation du processeur supérieure, car le code n’est pas directement interpretable
L’arrivée du cloud
Les services cloud, présents depuis plusieurs années maintenant ont démocratisé un nouveau fonctionnement économique : on paie ce qu’on consomme.
Et dans la continuité de ce modèle économique, les services de type « serverless » ou « scale at 0 » ont également gagné en popularité. Ces services vous proposent de ne lancer votre application que lorsqu’elle est accédée par un utilisateur. Ainsi ils vous permettent d’économiser dans les moments ou elle ne l’est pas.
L’optimisation des performances devient donc un enjeu économique encore plus important.
Le Java Natif GraalVM à la rescousse
Le JAVA tel que vous le connaissez ne semble donc pas vraiment bien adapté à ces usages cloud ou serverless. En effet, une application qui met 5 secondes à démarrer lorsque quelqu’un souhaite y accéder est un délais bien trop élevé.
GraalVM est un JDK avancé développé par Oracle permettant à votre application de
- Moins consommer en ressource
- Démarrer plus rapidement
- Avoir une moins grande surface d’attaque
Une histoire de moment de compilation
Just In Time compilation (JIT)
Une application JAVA n’est pas directement interprétée par le processeur, la JVM fait intermédiaire pour la ‘traduire ». La JVM va donc « traduire » en temps réel (Just In Time) le bytecode Java en code machine.
Pour ce faire, elle va s’appuyer sur des compilateurs de différents niveau (C1 et C2). Ils vont optimiser la compilation du code en fonction de l’utilisation de l’application. C’est ce qui va permettre a une application JAVA d’atteindre son pic de performance après une certaine durée d’utilisation.
Si du code compilé n’est plus nécessaire, il sera « dé-optimisé »
Ahead of Time compilation (AoT)
Les fameuses étapes d’optimisations faites par la JVM dans le cas d’une compilation JIT vont être faite en amont, au moment de la compilation du code source de l’application.
Ce cache de « code optimisé » sera finalement construit dès le début et intégré au livrable final, afin de ne plus avoir avec le traitement de compilation temps réel pendant le fonctionnement de l’application.
Quelques points de vigilance cependant :
- Cela peut nécessiter l’adaptation des librairies tierces que vous utilisez
- Tout ce qui n’est pas détecté dans la compilation AoT sera exclu du paquet applicatif final
- Les performances de l’application compilé en AoT sont « figées » et peuvent être inférieures à celles d’une application avec compilation JIT
Au final, le parti pris est de perdre du temps à la compilation pour le gagner à l’exécution. C’est ce qui est recherché dans le cadre d’un cas d’utilisation ou le temps de démarrage est critique par exemple.
Au final, chaque mode de compilation a ses avantages et ses inconvénients. Il est crucial de bien comprendre le cas d’utilisation avant de faire un choix.
Quels gains en Java Natif avec GraalVM ?
Quelques exemples des performances atteintes par des applications Java Natif GraalVM comparées à des applications « standard ».
Les deux captures ci dessous ont été faite sur une application Springboot d’un projet personnel. Celle ci possède les caractéristiques suivantes :
- Exposition de endpoint REST
- Utilisation du framework de templating HTML Thymleaf
- Configuration OAuth2 avec Keycloak (pour le front intégré et la validation des Bearer via API)
- Spring session avec Redis
- Spring Security
- BDD H2 avec Liquibase lancé au démarrage
- Lib tierce pour interagir avec l’API Gitlab
- Webjars pour la partie Thymleaf
Le tableau ci dessous concerne une application Springboot exposant un endpoint REST.
Le premier élément qui saute aux yeux est le temps de démarrage. Selon les exemples, on est entre 20x et 50x plus rapide avec des démarrages sous la seconde voir en dixième/centièmes de seconde ! C’est exactement ce qui est recherché dans le cadre d’application serverless.
La consommation CPU et mémoire est elle aussi améliorée avec une division par 3 ce qui reste très interessant pour faire des économies lorsqu’on paie à l’usage.
La promesse est tenu, le gain est important et peut permettre de nouveaux cas d’utilisation avec Java là où un autre langage aurait pu être préférés auparavant.
Quels impacts pour les équipes ?
Des durées de compilations grandement augmentées
Comme nous l’avons vu avant, la compilation AoT réalise des traitements au moment de la compilation du code afin d’éviter de les réaliser au moment de l’exécution de l’application.
Le premier impact est donc une durée de compilation décuplée.
Pour une application compilée en moins de 10 secondes en mode « standard », la compilation AoT peut atteindre 4 à 5mn sur une machine correctement équipée à 7 minutes sur une VM avec suffisamment de RAM. Si la machine qui compile ne possède pas assez de mémoire, la compilation peut échouer.
Cette compilation n’est pas seulement longue, elle est aussi très gourmande en ressource. Des machines avec 8Go de RAM au minimum sont nécessaires. Mais pour assurer un confort il vaut mieux viser 16Go de RAM au minimum.
Le paquet applicatif devient spécialisé pour un type de machine
Là où la JVM permet de faire tourner un même paquet applicatif sur une multitude de machine, la compilation native va au contraire spécialiser l’application sur le type de machine ayant servi à la construire.
Si vous compilez une app Java native GraalVM sur Linux, elle ne pourra tourner que sur une machine Linux. Mais la spécialisation ne s’arrête pas là. Car l’architecture du processeur est aussi un élément déterminant.
Une application compilée sur une machine Linux avec processeur x86 ne pourra pas tourner sur une machine Linux avec processeur ARM (sans sur-couche d’émulation x86).
Cette nuance devient importante lorsqu’on utilise la conteneurisation. En effet, la conteneurisation passe par une émulation Linux dans les coulisses sur macOS et Windows par exemple.
Il devient donc impossible de créer le paquet applicatif sur un Mac avec architecture ARM et de le « copier » dans une image de conteneur, car vous vous retrouvez avec un conteneur Linux embarquant un binaire mac qui ne sera pas exécutable.
Une compilation « multi-stage » sera obligatoire, c’est-à-dire que vous devrez utiliser un conteneur docker pour compiler afin d’obtenir un binaire Linux. Ce binaire Linux pourra ensuite être lancé dans un conteneur Linux.
Le compilateur natif élimine ce qu’il ne voit pas
En compilation AoT, le compilateur va parcourir l’ensemble des chemins d’exécution de votre application afin d’éliminer ce qui n’est pas nécessaire. Cela va permettre de réduire le volume de votre application et aussi de réduire la surface d’attaque en embarquant que le nécessaire. Le point positif est que cela se traduira par une réduction de vulnérabilités trouvées par un scanner comme Trivy – Un scanner de vulnérabilité simple et rapide
Cependant, beaucoup de framework Java reposent sur des concepts dynamiques qui ne sont pas encore bien gérés par GraalVM comme :
- Annotations dynamiques Spring (@Profile par ex)
- Reflexion, Serialisation
- Lecture de fichier du classpath (la notion de classpath n’existe plus en natif)
Il est donc nécessaire « d’aider » le compilateur en lui indiquant via des fichiers de configuration les classes à utiliser meme s’il ne les détecte pas.
Oracle met à disposition l’outil « Metadata reachability » qui est un agent qui se lance en même temps que votre application et qui détecte les classes et autres objets utilisés. Ces elements sont ensuite repris lors de la compilation AoT.
Les binaires Java Natif GraalVM ne sont pas tous égaux
L’utilisation de binaires natifs implique la connaissance de concepts connu des developpeurs sur des langages natifs comme le C++ mais pas forcément des développeurs Java.
Un binaire natif va devoir s’appuyer sur des bibliothèques système afin de fonctionner (gestion des entrées/sorties, gestion de la mémoire, utilisation de l’affichage graphique…)
Ces librairies peuvent être portées par le système d’exploitation ou bien être directement intégrées au binaire natif.
Les applications sont donc liées (linkées) aux bibliothèques
- Link « dynamique » : C’est le mode par défaut, le binaire natif s’appuie sur des librairies extérieures pour fonctionner.
- Link « mostly static » ou « presque statique » : le binaire natif embarque toutes les librairies nécessaires SAUF la librairie appelée « LibC » qui contient les fonctions bas niveau (entrée/sortie, gestion mémoire…)
- Link « static » ou statique : Toutes les librairies sont embarquées dans le binaire cependant, LibC est remplacée par une autre librairie appelée Musl
Comme pour tout questionnement sur les avantages et inconvénients de mutualiser des choses, les impacts sont les suivants :
Lib mutualisées :
- Avantage : Votre binaire est moins lourd, plusieurs binaires peuvent utiliser une même lib.
- Inconvénient : Difficile de mettre à jour une librairie partagée sans casser les binaires qui s’appuient dessus
Lib embarquées :
- Avantage : Autonomie complète sur le binaire, aucune dépendances
- Inconvénient : Binaire plus lourd et si une mise à jour de la lib doit être faite il faut recompiler le binaire.
LibC et Musl
La « lib C » est une bibliothèque de fonctions C de base qui fournit des fonctionnalités de bas niveau telles que l’entrée/sortie, la gestion de la mémoire et la manipulation de chaînes.
La lib « musl » est une alternative à la lib c. Elle est plus légère et plus rapide que la lib c, mais ne prend pas en charge toutes les fonctionnalités de la lib c. Lors de la recherche de performance et légèreté la lib musl peut être une option intéressante.
Cette nuance est importante, car certaines distributions linux n’ont pas la LibC et donc ne sont pas compatibles avec des binaires dynamiques ou presque statiques générés par GraalVM (exemple les distributions Alpine)
L’avantage d’un binaire statique Linux est qu’il aura une portabilité maximale étant compatible avec tous les variantes Linux. Cependant les fonctions de la lib Musl sont moindre que la lib C donc selon les cas d’utilisation elle ne serait pas envisageable.
Pour conclure
La compilation en Java Natif GraalVM est idéale dans les cas d’utilisation
- De petits périmètres type Microservice
- Avec peu de dépendances tierces
- Facile à tester
Cette nouvelle fonctionnalités offerte à la communauté JAVA amène un gap de performance crucial selon les cas d’utilisation.
Il faut être vigilant à avoir un patrimoine de test suffisant afin de maximiser les passages dans les chemins d’exécutions et collecter avec l’agent de collecte de méta données le maximum d’information
Au quotidien, afin d’éviter de perdre 5mn à chaque compilation, il est possible d’utilisation un mode hybride « JVM + AoT ». Cela permet au final d’utiliser les données AoT au RUN tout en ayant une compilation rapide
Il faut être vigilant aux ressources disponible sur les postes de développement mais aussi les chaines CI/CD ainsi qu’aux architectures qui doivent être au plus proche de la production
La compilation native est disponible sur Springboot, Quarkus, Micronaut de manière aisée.
Quelques liens utiles
Lors de la rédaction de cet articles j’ai pu trouver beaucoup d’informations sur les sites ci dessous. Je vous encourage à aller les consulter.
https://www.graalvm.org/latest/reference-manual/native-image/
https://www.graalvm.org/22.0/reference-manual/native-image/StaticImages/
https://www.cesarsotovalero.net/blog/aot-vs-jit-compilation-in-java.html
https://developers.redhat.com/articles/2021/06/23/how-jit-compiler-boosts-java-performance-openjdk#
https://medium.com/graalvm/graalvm-community-survey-2022-results-328d0404d36e
https://medium.com/graalvm/graalvm-in-2023-a-year-in-review-60f7f0635671
https://tech-stack.com/blog/using-graalvm-in-a-real-world-scenario-techstacks-experience/