Points Clés
- Java natif est essentiel pour que Java reste pertinent dans le monde du cloud en évolution.
- Java natif n'est pas encore un problème résolu.
- Le cycle de vie du développement doit également s'adapter.
- La standardisation via le projet Leyden est la clé du succès de Java natif.
- Java natif a besoin d'être intégré à OpenJDK pour permettre la co-évolution avec d'autres améliorations en cours.
Cet article d'InfoQ fait partie de la série "Les compilations natives boostent Java". Vous pouvez vous abonner pour recevoir des notifications via RSS. Java domine les applications d'entreprise. Mais dans le cloud, Java est plus cher que certains concurrents. La compilation native rend Java dans le cloud moins cher : elle crée des applications qui démarrent beaucoup plus rapidement et utilisent moins de mémoire. La compilation native soulève donc de nombreuses questions pour tous les utilisateurs de Java : comment Java natif change-t-il le développement ? Quand faut-il passer au Java natif ? Quand ne devrions-nous pas ? Et quel framework devrions-nous utiliser pour Java natif ? Cette série apportera des réponses à ces questions. |
Java natif est essentiel pour que Java reste pertinent dans le monde du cloud en évolution
Java est le langage de choix pour les applications d'entreprise et les services en réseau depuis plus de deux décennies. Il dispose d'un écosystème extrêmement riche de middleware, de bibliothèques et d'outils et d'une très grande communauté de développeurs expérimentés. Cela en fait un choix évident pour développer des applications basées sur le cloud ou déplacer des applications Java existantes vers le cloud. Cependant, il existe un décalage entre le développement historique de Java et son environnement d'exécution et les exigences actuelles du cloud. Java doit donc changer pour rester pertinent dans le cloud ! Java natif est l'option la plus prometteuse pour cela. Expliquons le décalage entre Java traditionnel et le cloud.
La machine virtuelle Java (JVM) utilise une compilation adaptative Just-In-Time (JIT) pour maximiser le débit des processus à longue durée de vie. Le débit de pointe a été la priorité, la mémoire était supposée être bon marché et extensible, et les temps de démarrage n'avaient pas beaucoup d'importance.
Désormais, des infrastructures telles que Kubernetes ou OpenShift, et les offres cloud d'Amazon, Microsoft ou Google, évoluent via de petits conteneurs bon marché avec peu de ressources CPU et mémoire. Étant donné que ces conteneurs démarrent plus souvent, les coûts fixes de démarrage de la JVM deviennent beaucoup plus importants en pourcentage du temps d'exécution total. Et les applications Java ont encore besoin de mémoire pour la compilation JIT. Alors, comment les applications Java peuvent-elles s'exécuter efficacement dans des conteneurs ?
Premièrement, les applications Java fonctionnent de plus en plus comme des microservices, effectuant moins de travail que les monolithes qu'elles remplacent. C'est pourquoi ils ont des ensembles de données applicatives plus petits et ont besoin de moins de mémoire.
Deuxièmement, des frameworks comme Quarkus et Micronaut ont remplacé l'injection et la transformation dynamiques gourmandes en ressources au démarrage par une transformation hors ligne pour injecter les services requis.
Troisièmement, diminuer les temps de démarrage longs de la JVM s'est avéré très difficile. Il serait préférable d'éviter de précalculer le code compilé pertinent, les métadonnées et les données constantes d'objet à chaque démarrage. Le projet OpenJDK a tenté cela plusieurs fois, notamment avec le compilateur AOT jaotc et Class Data Sharing. Mais jaotc a été abandonné et le Class Data Sharing est toujours un travail en cours. OpenJ9, une implémentation Java différente d'OpenJDK, a connu un succès notable avec la compilation AOT. Mais cela n'a pas atteint une large adoption.
Ces optimisations sont difficiles car le runtime du JDK est également une couche d'abstraction et de portabilité sur le matériel et le système d'exploitation sous-jacents. Le précalcul risque de se replier sur des hypothèses de construction qui ne sont plus valides au moment de l'exécution. Ce problème est sans doute le plus grand défi pour Java natif. C'est pourquoi les efforts antérieurs se sont concentrés sur la pré-génération de contenu pour le même runtime. Pourtant, la nature dynamique de Java crée deux autres problèmes bloquants.
Premièrement, la JVM et le JDK maintiennent un modèle de métadonnées relativement riche par rapport aux autres langages compilés par AOT. La conservation des informations sur la structure de classe et le code permet la compilation et la recompilation de la base de code lorsque de nouvelles classes sont chargées dans le runtime. C'est pourquoi, pour certaines applications, l'empreinte des métadonnées est importante par rapport à l'empreinte des données d'application.
Le deuxième problème est que la plupart des liaisons de code et de métadonnées pré-générées doivent être indirectes afin qu'elles puissent être réécrites pour des modifications ultérieures. Le coût est double : le chargement en mémoire du contenu pré-généré nécessite des liens de références, et l'exécution utilise des accès indirects et des transferts de contrôle qui ralentissent l'application.
Java natif propose de remédier à tous ces problèmes avec une simplification importante : ne pas supporter une application évoluant dynamiquement. Cette stratégie offre un démarrage rapide et un faible encombrement grâce à un petit exécutable étroitement lié avec tout le code, les données et les métadonnées initiaux précalculés au démarrage. Il s'agit bien d'une solution mais s'accompagne de coûts qu'il faut comprendre. Cela ne résout pas non plus le problème de faire correspondre les hypothèses de construction à la configuration d'exécution.
Java natif n'est pas encore un problème résolu
À première vue, le packaging semble être la différence majeure entre GraalVM Native et la JVM. Une application JVM a besoin d'un environnement d'exécution Java pour l'hôte cible, y compris le binaire Java, diverses bibliothèques JVM, les classes d'exécution du JDK et les fichiers JAR de l'application.
En revanche, GraalVM Native prend tous ces fichiers JAR en entrée au moment de la construction et ajoute les classes d'exécution du JDK ainsi que du code Java supplémentaire fournissant des fonctionnalités équivalentes à celles de la JVM. Il compile et relie tout cela dans un binaire natif pour un processeur cible et un système d'exploitation. Le binaire n'a pas besoin de charger des classes ou d'un compilateur JIT.
Cette compilation AOT complète a deux détails critiques : premièrement, elle nécessite une connaissance complète de toutes les classes dont les méthodes seront exécutées. Deuxièmement, il a besoin d'une connaissance détaillée du processeur cible et du système d'exploitation. Ces deux exigences soulèvent des défis importants.
L'hypothèse du monde fermé (Closed-World)
La première exigence est connue sous le nom d'hypothèse du monde fermé. Ce monde fermé ne devrait inclure que du code qui s'exécutera réellement. La fermeture d'une application commence par l'identification des classes et des méthodes explicitement référencées à partir de la méthode principale de la classe d'entrée. Il s'agit d'une analyse relativement simple, bien que complexe, de tout le bytecode du classpath et dans l'environnement d'exécution du JDK. Malheureusement, tracer les références aux classes, méthodes et champs par leur nom ne suffit pas.
Linkage – Java fournit un lien indirect entre les classes, les méthodes et les champs sans mentionner explicitement leurs noms. Ce qui est réellement lié peut dépendre d'une logique d'application arbitrairement complexe, opaque à l'analyse AOT. Des méthodes comme Class.forName()
peuvent charger des classes, éventuellement avec un nom calculé au moment de l'exécution. Les champs et les méthodes sont accessibles à l'aide de la réflexion ou des method ou des var handles, encore une fois pouvant-être dérivés de noms calculés. Une analyse intelligente peut détecter des cas où des littéraux de chaîne sont utilisés, mais pas des valeurs calculées.
Générateurs de bytecode - Un problème pire est qu'une classe peut être définie via un bytecode généré par l'application en fonction des données d'entrée ou de l'environnement d'exécution. Un problème connexe est la transformation à l'exécution du bytecode. Au mieux, il pourrait être possible de modifier certaines de ces applications avec un équivalent compilé par AOT. Cependant, cela est impossible pour l'intégralité des classes de l'application.
Chargeur et Délégation de module - Il ne s'agit pas seulement de savoir quels types ou code sont disponibles. Même lorsque l'on sait précisément quelles classes peuvent être chargées, la logique d'application peut déterminer la liaison et la visibilité des classes. Encore une fois, ces applications ne peuvent pas utiliser la compilation AOT.
Chargement de ressources et de services – Une difficulté similaire survient lors du chargement des ressources du classpath. Il est possible d'identifier les ressources dans les JAR du classpath et de les mettre dans le binaire natif. Mais il n'est peut-être pas clair lesquels seront réellement utilisés, et ils peuvent avoir un nom calculé. Ceci est particulièrement important car cela affecte le modèle d'exécution du JDK pour la fourniture de services, y compris le chargement dynamique de fonctions telles que les implémentations FileSystemProvider ou LocaleProvider. Un compilateur AOT pourrait compiler en prenant en charge chaque option, mais au détriment de la taille et de l'empreinte mémoire de l'exécutable.
Impact de l'exigence du monde fermé sur les développeurs
Tout cela signifie que les développeurs doivent désormais garantir que tout le code requis pour le système cible est disponible au moment de la construction. Le récent changement de GraalVM dans la gestion des ressources du classpath est un exemple de cette charge supplémentaire. À l'origine, les classes manquantes au moment de la construction interrompaient la construction. L'option --allow-incomplete-classpath
a contourné ce problème, transformant les erreurs de configuration au moment de la construction en erreurs d'exécution. GraalVM a récemment fait de cette solution de contournement le comportement par défaut. Bien que cela puisse faciliter l'intégration d'une application en Java natif, les erreurs d'exécution qui en résultent prolongent le cycle de compilation-test-exception-correction.
Et puis il y a les coûts du "jour 2" du monde fermé. Les outils de surveillance instrumentent généralement les classes au moment de l'exécution. Cette instrumentation peut, en théorie, se produire au moment de la construction. Mais cela peut être difficile, voire impossible, en particulier avec du code spécifique à la configuration d'exécution actuelle ou à la saisie de données au moment de l'exécution. La surveillance s'améliore pour les exécutables natifs, mais aujourd'hui, les développeurs ne peuvent pas compter sur leurs outils et workflows habituels pour surveiller les déploiements natifs.
Configuration du compilateur au moment de la construction vs à l'exécution
La deuxième exigence est un problème courant pour la compilation AOT : elle vise soit des capacités matérielles et d'exécution spécifiques de l'environnement cible, soit génère du code vanilla pour une plage d'environnement cible. Cela ajoute de la complexité au processus de compilation : les développeurs doivent désormais sélectionner et configurer les options du compilateur au moment de la construction qui seraient normalement définies par défaut ou configurées au démarrage du programme.
Il ne s'agit pas simplement de cibler, par exemple, la prise en charge de Linux ou de vecteurs matériels, comme avec d'autres langages compilés par AOT comme C ou Go. Cela nécessite également une configuration préalable de choix Java spécifiques, tels que le ramasse-miettes ou les paramètres régionaux de l'application.
Ces derniers choix sont nécessaires car la compilation de toutes les fonctionnalités dans l'exécutable généré le rendrait beaucoup plus volumineux et plus lent que Java dynamique. Le compilateur JIT génère du code pour les capacités spécifiques du matériel et de l'environnement d'exécution actuels. En revanche, un compilateur AOT devrait introduire du code conditionnel ou générer plusieurs variantes de méthodes compilées pour permettre tous les cas possibles.
Impact de l'exigence de configuration du compilateur au moment de la construction sur les développeurs
La compilation AOT rend l'intégration continue (CI) plus complexe. Vous cherchez à prendre en charge les déploiements Linux sur x86-64 et aarch64 ? Cela double le temps de compilation dans le système CI. Construire également des exécutables natifs pour les développeurs sous Windows et macOS ? Un autre doublement du temps de compilation CI. Tout cela augmente le temps jusqu'à ce que la pull request soit prête à fusionner.
Et cela ne fera qu'empirer à l'avenir. Vous testez une autre politique de GC ? C'est un cycle de compilation complet plutôt qu'un commutateur de ligne de commande. La validation des effets des références compressées sur la taille du tas de l'application ? C'est un autre cycle de compilation complet.
Ces compilations continues dans le cycle de développement privent les développeurs de leur joie. Ils ralentissent l'expérimentation et rendent la collecte des résultats onéreuse. Les temps de déploiement augmentent, ce qui entraîne des retards dans la mise en production des modifications et la restauration des pannes.
Initialisation au moment de la construction
Il existe en fait une troisième exigence critique pour réduire le temps de démarrage et l'encombrement avec la compilation AOT. Les exécutables natifs n'ont pas de chargeurs de classe ni de compilateur JIT, une machine virtuelle plus légère et moins de métadonnées pour les classes et le code. Mais la compilation AOT ne signifie pas nécessairement moins de classes ou de méthodes : pour la plupart, le runtime JVM ne charge déjà que le code nécessaire. Ainsi, le compilateur AOT ne réduira pas considérablement la quantité de code au moment de l'exécution ou le temps qu'il faut pour l'exécuter. Cette réduction nécessite une politique plus agressive qui supprime le code ou le remplace par un équivalent qui nécessite moins d'espace et de temps pour s'exécuter.
L'innovation la plus cruciale de la compilation AOT fait précisément cela : la majeure partie du travail de la JVM lors du démarrage de l'application est le code d'initialisation pour l'état d'exécution statique du JDK - une grande partie se répète exactement de la même manière à chaque fois. Calculer cet état au moment de la construction et l'inclure dans l'exécutable natif peut améliorer considérablement le démarrage. Il en va de même pour le middleware et l'état de l'application.
Ainsi, l'initialisation au moment de la construction évite le travail d'exécution en le faisant au moment de la construction. Mais cela permet également de supprimer ce code d'initialisation au moment de la construction de l'exécutable natif car il ne s'exécute qu'au moment de la construction. Dans de nombreux cas, cela a pour effet de supprimer d'autres méthodes et classes car elles n'ont été appelées qu'au démarrage. Cet effet combiné réduit le plus le temps de démarrage et l'encombrement dans GraalVM.
Malheureusement, l'initialisation au moment de la construction est confrontée à autant, sinon plus, de problèmes que les deux premières exigences. La plupart des initialisations statiques sont simples, définissant un champ sur une constante ou le résultat d'un calcul déterminé. La valeur est la même dans n'importe quel environnement d'exécution sur n'importe quel matériel.
Mais certains champs statiques dépendent des spécificités de l'exécution. Les initialiseurs statiques peuvent exécuter du code arbitraire, y compris du code qui dépend de l'ordre ou du moment précis de l'initialisation, de la configuration du matériel ou du système d'exploitation, ou même de l'entrée de données dans l'application. Si l'initialisation au moment de la construction est impossible, l'initialisation au moment de l'exécution intervient. C'est une décision par classe : un seul champ qui ne peut pas être initialisé au moment de la construction déplace toute la classe vers l'initialisation au moment de l'exécution.
Les valeurs de champs statiques peuvent également dépendre d'autres champs statiques. Ainsi, la validation de l'initialisation au moment de la construction nécessite une analyse globale et non locale.
Impact de l'initialisation au moment de la construction sur les développeurs
Bien que l'initialisation au moment de la construction soit une superpuissance de Java natif, elle peut facilement créer continuellement de la complexité pour les développeurs. Chaque champ statique initialisé au moment de la construction force l'initialisation au moment de la construction à se déplacer comme une vague à travers les classes accessibles requises pour créer sa valeur.
Voici un exemple :
class A implements IFoo {
static final String name = "A";
void foo() { … }
}
class B extends A {
private static final Logger LOGGER = Logger.getLogger(B.class.getName());
static int important_constant = calculate_constant();
...
}
class BTIExample {
static final B myB = new B();
}
Supposons que la classe BTIExample
soit initialisée au moment de la construction. Cela nécessite que toutes ses superclasses et interfaces implémentées et les classes référencées par ses initialiseurs statiques soient initialisées au moment de la construction : les super-classes de BTIExample, Object, B, A, IFoo, Logger, String, Logger
. Et les classes utilisées dans la méthode calculate_constant()
et dans Logger.getLogger()
, ainsi que dans le constructeur de la classe B
(non affiché), doivent également être compatibles au moment de la construction.
Les modifications apportées à l'une de ces classes - ou aux classes dont elles dépendent - peuvent rendre impossible l'initialisation au moment de la construction de BTIExample
. L'initialisation au moment de la construction peut être considérée comme une condition virale se propageant à travers le graphe de dépendance de la classe. Des corrections de bugs, des refactorisations ou des mises à niveau de bibliothèque apparemment innocentes peuvent forcer les classes à s'initialiser au moment de la construction pour prendre en charge une autre classe ou limiter une classe à l'initialisation à l'exécution à la place.
Mais l'initialisation au moment de la construction peut également capturer trop d'informations sur l'environnement de construction. Un exemple consiste à capturer une variable d'environnement représentant la machine de construction et à la stocker dans un champ statique d'une classe initialisée au moment de la construction. Cela se fait systématiquement dans les classes Java existantes pour garantir une vue cohérente de la valeur et éviter de la récupérer à plusieurs reprises. Mais en Java natif, cela peut poser un risque de sécurité.
Le cycle de vie du développement doit également s'adapter
Java natif ne change pas seulement le déploiement des applications. L'ensemble du processus de développement change : en tant que développeur, vous devez non seulement penser à adopter de nouveaux frameworks, à minimiser la réflexion et d'autres comportements dynamiques, et à tirer le meilleur parti de l'initialisation au moment de la construction. Vous devez également examiner vos processus de construction et de test.
Vous devez réfléchir à la façon dont vos bibliothèques sont initialisées car l'initialisation au moment de la construction d'une bibliothèque peut nécessiter (ou être bloquée par !) une autre bibliothèque. Chaque élément d'état capturé au moment de la construction doit être validé pour s'assurer qu'il ne capture pas d'informations sensibles pour la sécurité et qu'il est valide pour toutes les exécutions futures.
Déplacer le travail vers le temps de construction signifie également que la construction de votre application prendra plus de temps, localement et dans la CI. La compilation AOT nécessite des machines avec beaucoup de CPU et de mémoire pour analyser complètement chaque élément de votre programme. Java natif exige explicitement ce compromis - le temps de compilation ne disparaît pas. Il passe simplement de la compilation JIT au moment de l'exécution à la compilation AOT au moment de la construction. Cela prend également beaucoup plus de temps car l'analyse du monde fermé et la validation de l'initialisation au moment de la construction sont beaucoup plus complexes que la compilation JIT.
Comme l'a montré l'article sur Quarkus, il est préférable d'exécuter vos tests sur la JVM dynamique. Cela exerce votre logique métier, exécute des tests unitaires et d'intégration et garantit que tous les éléments fonctionnent correctement sur la JVM dynamique.
Pourtant, il reste indispensable de tester l'exécutable natif : comme l'ont montré les autres articles, la version en monde fermé de votre application, construite par votre framework avec l'aide de GraalVM Native Image, peut ne pas contenir certaines pièces. Et Java natif ne promet pas de compatibilité bug pour bug avec la JVM dynamique.
Désormais, les tests unitaires ne peuvent généralement pas s'exécuter sur l'exécutable natif séparé, car les méthodes requises peuvent ne pas être enregistrées pour la réflexion ou peuvent avoir été éliminées du code natif. Mais l'inclusion des tests unitaires dans l'exécutable natif maintient des méthodes supplémentaires en vie, augmentant la taille du fichier et la surface d'attaque de sécurité. Après tout, personne n'expédie ses tests unitaires sur la JVM dynamique !
Les tests doivent donc être effectués à la fois sur la JVM dynamique et sur l'exécutable natif. Que ferez-vous pour le développement au jour le jour ? Juste tester sur la JVM dynamique ? Compiler en natif avant d'ouvrir une pull request ? Les changements ici affecteront la vitesse de votre boucle interne.
Et en parlant de vitesse, ces temps de compilation plus longs affecteront la rapidité d'exécution de votre pipeline CI. Est-ce qu'un cycle de build et de test plus long modifie vos métriques DevOps comme le temps moyen de récupération (mean-time-to-recovery MTTR) ? Peut-être. Cela augmente-t-il vos coûts de CI en raison de machines plus puissantes pour la compilation ? Peut-être. Cela complique-t-il l'utilisation des outils de surveillance des performances des applications (APM) existants (comme Datadog) et d'autres agents d'instrumentation ? Assurément.
Il y a des compromis ici. Déplacer le travail vers le temps de construction (et, par extension, vers le temps de développement) est un choix : cela offre d'excellents avantages en termes d'exécution, mais les coûts ne disparaissent pas simplement. L'adoption de Java natif nécessite de nombreux changements. Les avantages, bien qu'impressionnants, n'en valent pas la peine pour chaque cas d'utilisation. Réfléchissez bien et préparez-vous à apporter des modifications non seulement à votre logiciel, mais également à votre façon de travailler.
La standardisation via le projet Leyden est la clé du succès de Java natif
L'une des superpuissances de Java est quelque chose d'assez ennuyeux : la standardisation. C'est le fondement stable de tout l'écosystème Java. La standardisation garantit que, quel que soit le nombre d'implémentations différentes du JDK, il existe conceptuellement un langage Java et un modèle d'exécution Java à cibler. La même application s'exécutera sur n'importe quel JDK, qu'il s'agisse d'un dérivé d'OpenJDK ou d'une implémentation indépendante comme Eclipse OpenJ9. Cela renforce la confiance des développeurs : la plateforme "fonctionne tout simplement". Couplé à l'engagement de longue date de Java en faveur de la rétrocompatibilité - cet ancien fichier JAR Java 1.4 fonctionne toujours aujourd'hui sur Java 18 - nous avons vu l'écosystème de frameworks, de bibliothèques et d'applications prospérer. L'évolution continue, prudente et basée sur les normes de Java a été la clé de cette croissance.
La standardisation de Java fait la promesse que votre application continuera à fonctionner. Qu'il vaut la peine d'investir dans cet écosystème. Que les bibliothèques continuent de fonctionner sans nécessiter de modifications importantes pour chaque version. Que les frameworks n'ont pas à réinvestir pour se recréer à chaque version. Que les développeurs d'applications puissent se concentrer sur l'ajout de valeur métier plutôt que de s'adapter en permanence à des changements incompatibles. Tous les écosystèmes de langage de programmation n'offrent pas ces garanties. Et avec les versions de Java qui arrivent rapidement et fréquemment, ces garanties sont essentielles pour que Java continue d'évoluer sans vous perdre, le développeur.
Ces promesses sonnent bien. Mais comment la "normalisation" fonctionne-t-elle réellement ici ? Lorsque nous parlons de "la norme Java", nous parlons en fait de la spécification du langage Java (JLS) et de la spécification JVM (JVMS), ainsi que de la spécification Javadoc complète des classes d'exécution principales du JDK. Ces deux spécifications sont au cœur des garanties de Java car, plutôt qu'une implémentation, elles définissent ce que signifie être "Java". Ils définissent le langage et le comportement d'exécution avec suffisamment de détails pour que les implémenteurs puissent créer des implémentations indépendantes du compilateur source JVM et Java. En revanche, de nombreux langages ont une norme mais la traitent davantage comme une documentation de ce que leur implémentation fait que comme des directives sur ce qu'elle doit faire.
Chaque version du JDK est basée sur une mise à jour des spécifications. Cette révision régulière crée deux livrables Java critiques : une déclaration claire du comportement définitif de Java et du JDK pour une version donnée et un compte rendu visible des différences de comportement entre les versions. Cette clarté et cette visibilité sont vitales pour quiconque implémente et maintient des applications, middleware et bibliothèques Java.
Les mises à jour de la spécification Java sont plus cruciales que même les nouvelles versions d'OpenJDK. Il faut un soin incroyable pour que chaque nouvelle fonctionnalité fasse évoluer la spécification de manière cohérente sans casser les applications existantes. Des suites de tests exhaustives vérifient la conformité des implémentations à la spécification. Surtout, ces tests sont basés sur la spécification, pas sur l'implémentaion.
Java natif a existé en dehors de ce processus de spécification et divise donc l'écosystème aujourd'hui. Ce n'est pas intentionnel, mais la scission persistera si l'évolution de Java natif se poursuit indépendamment du reste de la plate-forme Java. Désormais, il est impossible d'éviter que les développeurs de frameworks et les auteurs de bibliothèques doivent travailler dur pour masquer la scission. Pour ce faire, ils s'appuient uniquement sur des fonctionnalités qui fonctionnent en Java natif, évitant la plupart des utilisations de fonctionnalités dynamiques telles que la réflexion, les MethodHandles ou même le chargement de classe dynamique. Cela forme effectivement un sous-ensemble de Java dynamique. Couplé à la sémantique modifiée des fonctionnalités, telles que les Finalizers, Signal Handlers ou class initialization, le résultat est une divergence croissante entre Java sur la VM dynamique et Java natif.
Et il n'y a aucune garantie qu'une application construite avec Java natif aujourd'hui sera construite et se comportera de la même manière avec la prochaine version de Java natif. Les principaux comportements, tels que le changement --allow-incomplete-classpath
dont nous avons parlé plus tôt et le passage de l'initialisation du temps de construction à l'exécution par défaut, inversent leurs valeurs par défaut entre les versions. Les choix sont des décisions pratiques et pragmatiques pour accroître l'adoption aux dépens des utilisateurs actuels. Ce ne sont pas de mauvaises décisions en soi. Mais ils compromettent la stabilité de l'écosystème Java natif car ils sapent les promesses de la normalisation Java.
De nombreux comportements, en particulier des fonctionnalités critiques telles que l'initialisation au moment de la construction, sont toujours en évolution pour Java natif. Et c'est bien ! Même Java dynamique change aussi ! Ce qui manque avec Java natif, c'est une déclaration définitive de ce qui devrait fonctionner, de ce qui ne devrait pas fonctionner et de la façon dont cela évolue. Si la limite de Java natif avait juste de larges marges, ce serait ok. Mais nous ne savons pas vraiment où se situe la frontière. Et il évolue de manière inconnue.
Ce manque de standardisation n'est pas seulement un problème pour les auteurs de frameworks et de bibliothèques, car ces changements apparemment pragmatiques affectent la stabilité de Java natif et ses garanties. Les développeurs d'applications ressentent cette douleur lorsqu'ils doivent valider leur application pour chaque version, en particulier son utilisation des ressources. Désormais, Java dynamique a également besoin d'une certaine validation des nouvelles versions. Mais cela nécessite généralement une réponse utilisateur très spécifique et n'impose que des coûts de performance marginaux. Java natif peut nécessiter des efforts de réglage continus ou subir des augmentations des coûts de déploiement - une taxe que les développeurs paieront à chaque mise à jour.
Le projet Leyden a pour mandat de remédier au "temps de démarrage lent, au temps long pour atteindre des performances optimales et à une grande empreinte mémoire" de Java, comme l'a dit Mark Reinhold en avril 2020. Initialement, son mandat consistait à apporter le "concept d'images statiques à la plate-forme Java et au JDK". Désormais, la récente publication de Mark a réaffirmé l'accent mis sur ces mêmes points douloureux tout en reconnaissant un "éventail de contraintes " entre la JVM entièrement dynamique et Java natif. Les objectifs de Leyden sont d'explorer ce spectre et d'identifier et de quantifier comment les positions intermédiaires entre une image native entièrement compilée AOT et un environnement d'exécution entièrement dynamique et compilé JIT peuvent apporter des améliorations progressives à l'empreinte mémoire et au temps de démarrage, tout en permettant aux utilisateurs de conserver certains des comportements dynamiques requis par leurs applications.
Leyden étendra les spécifications Java existantes pour prendre en charge les différents points de ce spectre, y compris éventuellement la contrainte du monde fermé requise par Java natif. Toutes les propriétés clés de Java natif - l'hypothèse du monde fermé, la compilation au moment de la construction et l'initialisation au moment de la construction - seront explorées, dotées d'une sémantique précise et définie et soumises au processus de normalisation. Alors que le Project Leyden n'a créé que récemment sa liste de diffusion, des explorations ont eu lieu autour de ces thèmes dans l'écosystème Java et la communauté GraalVM.
L'intégration de Java natif dans le processus de normalisation Java existant via le Project Leyden fournira ces mêmes bases solides qui ont permis à Java traditionnel, à son écosystème, à ses bibliothèques et à son middleware de prospérer. La standardisation est le remède à la dette technique grandissante entre Java natif et dynamique. Le chemin incrémental décrit par Leyden aidera à soulager la douleur de la migration pour tous les développeurs.
Java natif doit être intégré à OpenJDK pour une co-évolution avec des améliorations en cours
Cette série d'articles a démontré les avantages de Java natif. Il est essentiel que Java reste pertinent dans le cloud. Il y a beaucoup de bien pour la communauté Java là-dedans. Et pourtant, Java natif nécessite des changements massifs dans la façon dont les applications sont développées et déployées. Comme il existe en dehors des garanties de stabilité de la plate-forme principale et du processus de normalisation, il risque de bifurquer la définition de Java.
Pendant ce temps, Java dynamique continue d'évoluer dans OpenJDK. De grands projets sont en cours : Loom ajoute des threads légers et une concurrence structurée, Valhalla introduit des types qui "se codent comme une classe et fonctionnent comme un int", Panama améliore le fonctionnement de Java avec du code non Java et Amber publie des fonctionnalités plus petites qui rendent les développeurs plus productif.
Ces projets apportent de nouvelles fonctionnalités à Java, augmentant la nature dynamique de la plate-forme grâce à une utilisation plus poussée des MethodHandles, de l'appel dynamique et de la génération de code à l'exécution. Ils sont conçus pour s'intégrer dans un ensemble cohérent. Java natif ne fait pas encore partie de cet ensemble cohérent. L'introduction de Java natif dans OpenJDK via le projet Leyden permet une co-évolution de Java dynamique, de ses nouvelles fonctionnalités et de Java natif.
Cette co-évolution est essentielle au succès à long terme de Java natif. Sans cela, Java natif sera perpétuellement en retard sur Java dynamique. Un exemple récent d'une telle prise en charge retardée est la sérialisation JSON cassée des records Java en Java natif lorsque des annotations sont utilisées. Mais GraalVM manque également actuellement l'opportunité d'influencer la conception de nouvelles fonctionnalités en Java. Des ajustements mineurs et des adaptations aux spécifications peuvent faire la différence entre une implémentation native simple et efficace, une implémentation coûteuse en mémoire et en temps de compilation, et quelque chose qui ne peut tout simplement pas être implémenté en Java natif.
Jusqu'à présent, Java natif a remarquablement réussi à suivre la plate-forme. Mais il n'a réussi qu'en adaptant la plate-forme Java et les bibliothèques du JDK de base à l'aide de substitutions : ce sont des classes Java compagnons qui modifient les classes pour fonctionner avec Java natif. Mais ils risquent de casser les invariants du code qu'ils modifient. Les substitutions sont une solution éminemment pragmatique lorsque le code d'origine ne peut pas être modifié. Mais ils ne sont pas à l'échelle. Et ils souffrent des mêmes problèmes que les solutions de "monkey-patching" dans d'autres langues - puissantes mais dangereuses. Ils peuvent devenir incorrects en raison des changements des classes qu'ils modifient. Un exemple récent a été une substitution du runtime JDK qui est devenue invalide après les modifications du JDK. Heureusement, l'équipe Quarkus a détecté et corrigé ce problème.
L'intégration de Java natif dans OpenJDK offre la possibilité de "faire mieux", en modifiant la plate-forme Java au lieu d'utiliser des astuces telles que la substitution : mettre à jour non seulement les bibliothèques de classes JDK directement, mais potentiellement aussi le modèle de programmation. Il garantit que les projets OpenJDK existants examinent l'ensemble de la plate-forme - à la fois les cas d'utilisation dynamiques et natifs - lorsque les fonctionnalités sont développées. Et cela garantit que les applications bénéficient du fait que Java natif est un élément de premier ordre dans la plate-forme, apportant de meilleures solutions aux deux modèles de déploiement.
Conclusion
Java a été le langage d'entreprise dominant au cours des 20 dernières années, s'appuyant sur la stabilité fournie par son processus de normalisation. La co-évolution entre le langage, le runtime et les bibliothèques, tous historiquement à la recherche d'améliorations matérielles rapides grâce à la loi de Moore, a simplifié le travail des développeurs alors qu'ils s'efforçaient d'optimiser les performances de leurs applications.
Java natif s'est développé pour adapter Java aux déploiements cloud à ressources limitées. Mais il se trouve maintenant à la croisée des chemins. Il peut continuer à évoluer par lui-même et risquer de diverger de Java dynamique à chaque version jusqu'à ce qu'il devienne effectivement une entité distincte avec ses propres parties prenantes, sa communauté et ses bibliothèques. Alternativement, Java natif peut rejoindre sous la bannière des normes Java et évoluer avec le reste de la plate-forme en quelque chose qui profite à tous les cas d'utilisation. Cela apporterait de la stabilité aux capacités de Java natif et permettrait l'émergence de pratiques de déploiement courantes.
Alors que le projet Leyden commence à prendre forme, nous nous attendons à ce qu'il devienne un lieu où Java dynamique et natif convergent vers un avenir commun de démarrage rapide et une empreinte réduite pour tous les utilisateurs Java. Aujourd'hui, GraalVM continue d'être le choix pragmatique pour Java natif. Dans un avenir proche, une seule spécification Java déterminera ce que votre programme signifie lorsqu'il s'exécute n'importe où sur ce spectre, du dynamique au natif, indépendamment de l'implémentation sous-jacente.
Cet article d'InfoQ fait partie de la série "Les compilations natives boostent Java". Vous pouvez vous abonner pour recevoir des notifications via RSS. Java domine les applications d'entreprise. Mais dans le cloud, Java est plus cher que certains concurrents. La compilation native rend Java dans le cloud moins cher : elle crée des applications qui démarrent beaucoup plus rapidement et utilisent moins de mémoire. La compilation native soulève donc de nombreuses questions pour tous les utilisateurs de Java : comment Java natif change-t-il le développement ? Quand faut-il passer au Java natif ? Quand ne devrions-nous pas ? Et quel framework devrions-nous utiliser pour Java natif ? Cette série apportera des réponses à ces questions. |