Accueil Nos publications Blog Java 9 : la révolution des modules

Java 9 : la révolution des modules

La sortie de Java 9 est annoncée pour le 27/07/2017. Le plus gros changement apporté par cette nouvelle version est l’arrivée d’une JVM modulaire. Cette révolution a pour nom de code : le projet JIGSAW (JSR 376) !

Nous suivons le sujet depuis longtemps et nous avons eu la chance d’assister à la présentation de Remi Forax à Devoxx France 2017 autour de ces grands changements. Alors faisons un tour d’horizon pour nous préparer et découvrir tout le potentiel qui se cache derrière ce nom de code en réalisant un distributeur de boissons : vous savez, les machines qui vous donnent votre dose de caféine quand vous appuyez sur ses boutons !

Mais avant de voir les avantages apportés par la modularisation de la JVM, il faut revenir en arrière et bien comprendre ce que sont le classpath et le mécanisme de chargement des classes en Java afin d’en appréhender les limites et la nécessité de ce changement profond.

Le classpath de Java 8

À l’exécution…

En Java 8 et antérieur, à l’exécution d’une application, la JVM recherche les classes utilisées par l’application dans :

  • Les classes de la plate-forme Java : stockées dans le fichier rt.jar qui n’a cessé de grossir au fil des versions et fait désormais 53 Mo en Java 8.
  • Le classpath : un ensemble de chemins relatifs ou absolus vers :
  • des répertoires contenant des arborescences en package contenant des fichiers “.class”
  • des fichiers jar.

Il est important de noter qu’il n’est pas possible d’utiliser de caractère joker ni de spécifier un répertoire pour fournir un ensemble de jar. On doit les lister exhaustivement.

Exemple de classpath
Sous Windows:


-classpath C:\path\to\classes;C:\path\to\lib\log4j-1.2.17.jar;C:\path\to\lib\mycompany-api-1.0.jar

Sous Unix:


-classpath /path/to/classes:/path/to/lib/log4j-1.2.17.jar:/path/to/lib/mycompany-api-1.0.jar

À la compilation…

Et oui, n’oublions pas que notre application avant d’être exécutée, a d’abord été compilée. Pendant cette phase, le compilateur a eu besoin lui aussi d’un classpath pour valider notre code et détecter les erreurs de compilation. Heureusement, nos outils de build (Maven, Gradle ou encore nos IDE) gèrent pour nous cette étape et construisent le classpath nécessaire à la compilation de l’application de manière transparente. Mais qu’en est-il de l’ordinateur que nous utilisons en développement avec pourquoi pas un tomcat embarqué, du serveur d’intégration continue qui build l’application, des serveurs de recette et de prod qui tournent sous tomcat, jboss, glassfish ou encore websphere ? Est-on sûr qu’ils ont exactement le même classpath ? Est-on sûr qu’ils embarquent exactement les mêmes versions de chaque jar ?

Que se passe-t-il si le classpath utilisé à la compilation n’est pas le même qu’à l’exécution ?

La JVM va démarrer l’application et peut-être qu’à un moment donné, votre application va cesser de fonctionner en levant une erreur de type java.lang.NoClassDefFoundError !

Que faut-il retenir de ce classpath Java 8 :

  • le rt.jar est un énorme monolithe de plus de 53 Mo,
  • le classpath n’est pas forcément le même à la compilation et à l’exécution,
  • le classpath est rudimentaire, il n’accepte pas de joker ou de répertoire contenant des jars,
  • on s’appuie sur nos outils de build car il est laborieux de construire un classpath à la main,
  • la JVM ne fait aucun contrôle au lancement de l’application sur les classes/jar présents et des erreurs peuvent survenir plus tard au runtime.

Et le classloader ?

Bien, maintenant que nous avons mieux cerné ce qu’est le classpath, il nous faut comprendre comment sont chargées les classes par la JVM. Ce mécanisme est effectué par une hiérarchie de ClassLoader. Le but ici n’est pas de faire un cours sur ceux-ci, nous simplifierons donc l’exposé. Nous nous concentrerons donc sur le classloader “applicatif” qui charge les classes à partir du classpath.

Les classloader sont des poupées russes : s’ils ne réussissent pas à charger une classe, ils passent la main au classloader suivant (je vous fais grâce de la distinction parent-first ou parent-last), si le dernier classloader de la chaine n’a pas réussi à charger la classe, une erreur java.lang.NoClassDefFoundError est levée.

De plus, à l’exécution, une classe est uniquement chargée à la demande lors d’un appel à new ou lors d’un appel à une méthode statique de cette classe.

Un chargement très linéaire

Lorsqu’on lui demande une classe en précisant son nom complet, le classloader la retourne immédiatement si elle est déjà chargée. Dans le cas contraire, il va chercher linéairement dans le classpath un fichier « .class » correspondant au nom demandé. Dès qu’il la trouve, il charge en mémoire le bytecode et retourne la classe.

Admettons que nous ayons le classpath suivant :


.:mylib-1.1.jar:foo.jar:bar.jar:mylib-1.2.jar

mylib-1.1.jar contient :


package fr.soat.blog;
public class MySuperClass {
}

mylib-1.2.jar contient :


package fr.soat.blog;
public class MySuperClass {
    public void aNewMethod(){}
}
package fr.soat.blog;
public class AnotherClass {
    public void something(){
        MySuperClass mySup = new MySuperClass();
        mySup.aNewMethod();
    }
}

Si l’on demande au classloader de charger fr.soat.blog.AnotherClass, il va parcourir le classpath et trouver la classe dans mylib-1.2.jar. Maintenant, à l’appel de something(), le classloader va tenter de charger MySuperClass, et là, il va la trouver dans le jar mylib-1.1.jar ! Aie, il ne va pas charger la bonne version de la classe, l’appel à aNewMethod va donc lancer une erreur java.lang.NoSuchMethodError.

Si l’on résume :

  • le classloader charge les classes sur demande lors d’un new ou d’un appel à une méthode statique.
  • le classloader charge les classes linéairement et s’arrête à la première classe correspondant au nom complet demandé.
  • aucune vérification n’est faite à l’exécution sur l’existence de plusieurs occurrences d’une même classe.
  • aucune vérification n’est faite au démarrage de l’application sur la présence de tous les jars / classes nécessaires au bon fonctionnement de l’application.
  • le classpath est plat sans aucune notion de dépendances entre jar.
  • l’ordre des jars dans le classpath est important.

Modularisation de la JVM en Java 9 : Le projet JIGSAW

Maintenant que nous savons comment fonctionne le classpath et les classloader en Java 8 et que nous avons vu leurs limites, voyons ce qu’apporte la modularisation de la JVM en Java 9 :

  • un classpath sous forme d’arbre de dépendances
  • une vérification de la présence de tous les modules nécessaires à notre application au démarrage, sans quoi l’application ne démarre pas.
  • un renforcement de la sécurité, seuls les packages exportés explicitement par un module sont visibles par un autre
  • la JVM elle-même est modulaire

Un graphe de dépendances

Effectivement, nos outils de build savent depuis longtemps gérer un graphe de dépendances et finalement, c’est tout naturel que la JVM puisse le faire aussi.
Au démarrage, la JVM va traverser ce graphe de dépendances et vérifier que les différents modules requis par l’application sont tous présents. Dans le cas contraire, un message explicite (un gros effort a été fait dans ce sens) est affiché pour expliquer quel module est manquant et quel module en a besoin.

Renforcement de la sécurité

Oubliez ce que vous savez : un type public (interface, classe, enum, …) n’est plus si public que cela ! Si vous ne précisez rien, vos types publics sont visibles par l’ensemble des types du module dans lequel ils sont définis mais ne seront pas visibles depuis un autre module.

Java 9 introduit un nouveau niveau de visibilité : un package peut être « exporté » explicitement. Les types public de ce package (mais pas de ses sous packages) seront alors visibles depuis un autre module.

De plus, un même package ne peut pas être présent dans plusieurs modules.

Une JVM modulaire

Fini donc l’énorme rt.jar ! Il a été découpé en modules. Charge à chaque application de définir explicitement de quels modules elle a besoin pour fonctionner. Le but de ce découpage est multiple :

  • réduire la taille physique de la JRE sur les devices embarqués notamment,
  • rendre les classes internes de la JVM réellement privées,
  • augmenter la sécurité des applications : moins de classes chargées par la JVM = une surface d’attaque plus faible pour les hackers.

En effet, pourquoi par exemple embarquer les classes swing et awt quand on n’en a pas besoin ? Et pourquoi s’exposer à une faille de sécurité dans ces classes alors que l’on ne les utilise pas ?

Des outils ont été mis à disposition dans le JDK pour packager une JRE personnalisée avec uniquement les modules requis par une application spécifique.

Du code, du code, du code !

Voyons ensemble en détail comment déclarer nos modules et différents cas d’usage.

module-info.java

Tout d’abord, il faut garder à l’esprit qu’un module reste un jar tel qu’on le connait avec un fichier supplémentaire qui est au cœur de Jigsaw : module-info.java.

Ce fichier java est compilé en module-info.class et doit être présent à la racine du jar.

Le module le plus simple ne déclare que son nom :


module soat.vending.machine.gui {
}

Nommage des modules

Le nommage des modules était encore en discussion sur la mailing liste il y a quelques semaines. Le but étant de se mettre d’accord sur un nommage standardisé et d’éviter si possible les collisions notamment lorsque nous tirons des dépendances du repo Maven Central.

  • soit un nommage court (exemple : hibernate.core ou google.guava)
  • soit un nommage à la sauce “reverse DNS prefix” (exemple: org.hibernate.core)
  • soit introduire une écriture groupId:artifactId (exemple: org.hibernate:hibernate.core) mais ce n’est pas encore supporté (build 9-ea+164).

Ligne de commande et –module-path

Nous ne parlons plus à présent d’un classpath mais d’un module-path. Il est désormais possible de fournir des répertoires contenant nos modules. À partir du module racine, le graphe de dépendances va être parcouru pour vérifier dans ce module-path que les modules sont bien tous présents et qu’il n’y a pas de doublons.

Exemple : tous nos modules sont présents dans le sous répertoire libs du répertoire courant. Nous souhaitons exécuter le main de la classe fr.soat.vending.machine.VendingMachine dans le module soat.vending.machine.gui


java --module-path ./libs --module soat.vending.machine.gui/fr.soat.vending.machine.VendingMachine

Si le module path contient my-lib-1.1.jar et my-lib-1.2.jar avec tous deux le même module name, une erreur sera levée au démarrage de l’application.

À noter : le module path ne contient que des répertoires.

requires <module>

Par défaut, un module ne connait pas les autres modules présents dans le module-path. Il est donc nécessaire d’ajouter une ligne dans notre module-info.java : requires à chaque fois que nous voulons accéder à un autre module.

Heureusement pour nous, il n’est pas nécessaire d’importer le module java.base (qui contient entre autre Object) vu que tous les modules ont besoin de lui.


module soat.vending.machine.gui {
    requires java.desktop;
    requires soat.vending.machine.model;
}

requires transitive <module>

Prenons le cas de notre module soat.vending.machine.model : il retourne dans ces interfaces exportées des types du module soat.core. De ce fait, n’importe quel module voulant l’utiliser devra également faire un requires soat.core pour pouvoir accéder aux classes de ce deuxième module sous peine d’erreurs de compilation.

Mais pourquoi les utilisateurs de notre module devraient connaitre le détail d’implémentation et avoir connaissance de cette dépendance ?

Java 9 permet grâce au mot clé transitive d’indiquer que par transitivité, les utilisateurs de soat.vending.machine.model vont pouvoir accéder à soat.core. Cela permettra plus facilement des changements d’implémentation et une facilité d’utilisation.


module soat.vending.machine.model {
    requires transitive soat.core;
}

requires static <module>

Qu’en est-il est des dépendances optionnelles ? Comment faire pour laisser le choix à l’utilisateur de nos librairies d’utiliser une fonctionnalité ou une autre à l’exécution sans pour autant l’obliger à fournir tous les modules, même ceux dont il n’a pas l’utilité ? Pour faire un parallèle, comment faire en Java 9 la même chose que propose Maven avec les dépendances optionnelles ?

Le mot clé requires static représente ce concept de dépendance optionnelle ; un tel module est :

  • obligatoire à la compilation : une erreur de compilation sera levée si le module n’est pas présent dans le module path à la compilation.
  • optionnel à l’exécution : le module ne sera pas pris en compte dans la phase de sanity check au démarrage de l’application. L’application pourra démarrer même si le module n’est pas présent.

Prenons un exemple, nous voulons proposer la persistance des données de l’application, soit dans une base oracle, soit avec h2database.


module soat.vending.machine.service {
    requires static ojdbc;
    requires static h2database.h2;
}

Ensuite dans le code de notre module de service, nous pouvons utiliser les classes de ojdbc ou h2 en gardant en tête qu’une NoClassDefFoundError pourra être levée au runtime si l’un des modules (et donc la classe) n’est pas présent :


try {
    new oracle.jdbc.driver.OracleDriver();
} catch (NoClassDefFoundError er) {
    // TODO something
}

export <package>

Par défaut, les types public d’un module ne sont plus visibles à l’extérieur du module.

Pour rendre les types public d’un package donné visibles depuis les autres modules, vous devez exporter ce package. Il faut garder en tête que nous sommes au niveau package et non au niveau unitaire d’un type : ce serait vraiment trop laborieux ! De plus, les sous packages ne sont pas exportés.

Par exemple, pour permettre à d’autres modules d’utiliser les classes et interfaces du package fr.soat.vending.machine.model, vous devez écrire :


module soat.vending.machine.model {
    exports fr.soat.vending.machine.model;
}

Attention : il est très important de comprendre qu’un package ne peut être présent que dans un et un seul module. Dans le cas contraire, vous aurez des erreurs de ce type :


Error:(1, 1) java: package exists in another module: soat.blog.api

export <package> to <module>

Vous pouvez renforcer la sécurité de vos modules en réduisant la visibilité de certains packages à une liste finie de module : seuls les modules listés pourront accéder à ces classes.


module soat.vending.machine.model {
    exports fr.soat.vending.machine.model
        to soat.vending.machine.gui;
}

module acme.foo {
    requires soat.vending.machine.model; //Pas d’erreur ici
}
package com.acme.foo.bar;
import fr.soat.vending.machine.model.Drink;
public AcmeFooBar {
    private Drink drink; // Erreur le type n’est pas visible
}

provides <type> with <type> et uses <type>

Le mécanisme de ServiceLoader existe depuis Java 6 mais ne semble pas très utilisé notamment vu les frameworks d’injection (Spring, CDI). Il a été revu et prend plus de sens avec les modules.

Prenons un exemple : nous voulons toujours développer notre distributeur de boissons. Mais nous ne connaissons pas à l’avance quelles boissons pourront être proposées. Il nous faut donc créer une application extensible. On pourrait avoir les modules suivants :

  • un module définissant et exportant une interface ou une classe (exemple : une interface fr.soat.vending.machine.services.DrinksService). Elle n’a pas besoin d’implémenter quoi que ce soit ni même d’être annotée et rien de particulier dans la définition du module. On s’assure juste d’exporter le package contenant notre interface et tous les packages contenant les types utilisés dans les signatures de nos méthodes.

module soat.vending.machine.model {
    exports fr.soat.vending.machine.services;
}
  • des modules proposant des implémentations de l’interface : coffee-maker, chocolate-maker, … Un même module peut définir plusieurs implémentation de l’interface et dans ce cas, chaque implémentation doit avoir son propre provide … with … ;.

module soat.coffee.maker {
    requires soat.vending.machine.model;
    provides fr.soat.vending.machine.services.DrinksService
        with fr.soat.coffee.maker.CoffeeService;
}

À noter : on doit faire un requires du module exposant l’interface, mais nous n’avons pas à exporter le package proposant l’implémentation du service.

  • un module qui consomme les services exposés. Notre module soat.vending.machine.gui va utiliser au runtime les implémentations disponibles pour proposer des boissons aux utilisateurs. Le module doit faire des requires sur le module exposant l’interface et tous les modules proposant des implémentations et, très important, faire un uses pour que le mécanisme de ServiceLoader fonctionne.

module soat.vending.machine.gui {
    requires soat.vending.machine.model;
    requires soat.coffee.maker;
    uses fr.soat.vending.machine.services.DrinksService;
}

Pour récupérer l’ensemble des implémentations disponibles, il vous suffit de faire :


ServiceLoader&amp;amp;amp;lt;DrinksService&amp;amp;amp;gt; drinkServiceProviders = ServiceLoader.load(DrinksService.class);

Vous pouvez ensuite parcourir le stream et choisir l’instance qui vous intéresse.

Attention: sans ‘uses’ le ServiceLoader ne fonctionnera pas et vous aurez une exception du type:


Exception in thread "main" java.util.ServiceConfigurationError: fr.soat.vending.machine.services.DrinksService: module soat.vending.machine.gui does not declare `uses`

La reflexivité en java 9 : open module <module> et opens <package>

Jusqu’ici nous n’avons pas parlé de réflexion. Pour accéder par réflexion à des champs privés, faire des choses pas jolies jolies avec setAccessible(true) par exemple, vous devez demander la permission ! La sécurité de la JVM a été renforcée en ce sens. Vous ne pouvez plus faire n’importe quoi.

Pour que les types contenus dans un package soient manipulables par réflexion, vous devez les ouvrir avec opens.


module soat.coffee.maker {
    opens fr.soat.coffee.maker;
}

Dans le cas contraire vous aurez des exceptions :


Exception in thread "main" java.lang.IllegalAccessError: class fr.soat.vending.machine.VendingMachine (in module soat.vending.machine.gui) cannot access class fr.soat.coffee.maker.CoffeeService (in module soat.coffee.maker) because module soat.coffee.maker does not export fr.soat.coffee.maker to module soat.vending.machine.gui`

À noter : il faut également faire un exports du package si les types doivent être utilisés normalement. Sinon vous ne pouvez que les utiliser par réflexion.


module soat.coffee.maker {
    exports fr.soat.coffee.maker; // utilisation standard
    opens fr.soat.coffee.maker; // utilisation par réflexion
}

Si c’est l’ensemble du module qui doit être ouvert à la réflexion, il faudra écrire open module et il n’y aura pas besoin de préciser opens sur les packages :


open module soat.model {
    …
}

Modifier un module existant

Un module est confiné dans le cadre très strict défini par java 9 : il ne peut accéder qu’à ce qui est explicitement déclaré dans son fichier de description et ce qu’exposent les autres modules.

Certaines options de la ligne de commande peuvent nous aider à modifier des modules existants et leur ajouter des dépendances, exporter des packages supplémentaires, les ouvrir à la réflexion, etc…


    --add-reads <module>=<target-module>(,<target-module>)*
                      met à jour <module> pour lire <target-module>, sans tenir compte
                      de la déclaration de module.
                      <target-module> peut être ALL-UNNAMED pour lire tous les modules
                      sans nom.
    --add-exports <module>/<package>=<target-module>(,<target-module>)*
                      met à jour <module> pour exporter <package> vers <target-module>,
                      sans tenir compte de la déclaration de module.
                      <target-module> peut être ALL-UNNAMED pour effectuer un export vers tous
                      les modules sans nom.
    --add-opens <module>/<package>=<target-module>(,<target-module>)*
                      met à jour <module> pour ouvrir <package> vers
                      <target-module>, sans tenir compte de la déclaration de module
    --patch-module <module>=<file>(;<file>)*
                      Remplacement ou augmentation d'un module avec des classes et des ressources
                      dans des fichiers ou des répertoires JAR.

Code legacy, modules automatiques, unnamed module et classpath

Java 9, les modules, c’est bien ! Mais que faire de toutes ces librairies que nous utilisons au quotidien et qui ne vont pas migrer par magie dès la sortie officielle de Java 9 ? Le repo maven central est plein de librairies qui sont parfois peu maintenues et surtout, nos entreprises ne pourront pas faire la migration de tout leur code base en un claquement de doigt.

Premièrement, nous pouvons ajouter dans notre module-path un jar legacy sans module-info.class ! Il devient un module automatique.

Par exemple, nous avons un jar sans module-info : libs-legacy/tea-maker-legacy-1.0-SNAPSHOT.jar. Ajoutons-le à notre module-path, il va être chargé comme un module automatique.


%JAVA_HOME%\java --module-path ./libs:./libs-legacy --module soat.vending.machine.gui/fr.soat.vending.machine.VendingMachine

Nommage des modules automatiques

La JVM lui donne alors un nom généré à partir du nom du jar :

  • L’extension “.jar” est supprimée.
  • Le numéro de version est retiré (Ex: mylib-1.2.3 -> mylib ; mylib-1.2.3-SNAPSHOT -> mylib)
  • Les caractères non alphanumériques sont remplacés par des points.
  • Les points répétitifs sont remplacés par un seul point, les points en début et fin de chaine sont supprimés.

Ici, notre module va s’appeler tea.maker.legacy et nous pouvons donc faire un requires tea.maker.legacy; si nous en avons besoin.

Les types publics sont exportés

Les modules automatiques voient tous leurs types publics exportés. Un module utilisant un module automatique peut donc utiliser n’importe quel type public, de la même manière qu’en java 8 nous utilisions un jar.

Classpath et unnamed module

Le classpath n’a pas totalement disparu de Java 9. En effet, l’option de la ligne de commande --class-path n’a pas disparu. Les jars chargés de cette manière sont placés dans un module particulier appelé unnamed module.

Il a la particularité d’avoir accès à :

  • tous les packages exportés par tous les autres modules disponibles dans le module-path
  • tous les jars du classpath (ie: tous les autres types présents dans cet unnamed module)

Les modules ont accès à l’unnamed module. Et ils peuvent donc accéder aux classes publiques présentes dans le classpath.

Nous pourrions réécrire la ligne de commande précédente autrement :


%JAVA_HOME%\java --module-path ./libs --class-path ./libs-legacy/tea-maker-legacy-1.0-SNAPSHOT.jar --module soat.vending.machine.gui/fr.soat.vending.machine.VendingMachine

Le jar ne serait plus chargé en tant que module tea.maker.legacy mais appartiendrait à l’unnamed module. Il n’y aurait donc plus besoin de faire de requires sur ce module, et notre code modulaire pourrait avoir accès à ces classes.

Conclusion

Java 8 était riche en évolutions du langage, ce n’est pas le cas avec Jigsaw : comme nous avons pu le voir, c’est la structure interne de java qui subit un profond changement. C’était nécessaire.

Ces évolutions ne seront pas aussi transparentes que les montées de version précédentes mais un gros effort a été fait (modules automatiques, unname module) pour que la transition puisse être faite graduellement. Des discussions sont toujours en cours pour régler les derniers détails. En espérant que java 9 ne soit pas décalé et que Jigsaw voie le jour rapidement.

Mais de toute façon, avec toutes ces clés en main, vous n’avez plus de raison d’attendre : lancez-vous dans la modularité avec Java 9 !

Les exemples de cet article sont disponibles ici.

© SOAT
Toute reproduction interdite sans autorisation de la société SOAT