Il y a 5 ans -
Temps de lecture 16 minutes
Back Java : Tour d’horizon des ClassLoaders
Quand on s’intéresse à ce qui se passe à l’intérieur de la JVM, un concept clé qui revient souvent est la notion de ClassLoader (ou chargeur de classe, en bon français).
Mais qu’est-ce qu’un ClassLoader au juste ?
Cet article a pour but d’expliquer ce qu’est un ClassLoader, ce à quoi il sert, comment l’utiliser et pourquoi c’est important.
La majeure partie de celui-ci sera basée sur le fonctionnement des ClassLoaders sous Java 8 ou moins, mais nous finirons par exposer brièvement quels sont les changements que le système de module de Java 9 apporte aux ClassLoaders.
Qu’est-ce qu’un ClassLoader ?
La JVM ne charge pas en mémoire le code de toutes les classes disponibles avant de démarrer l’application, ce serait trop long. À la place, elle charge le code d’une classe dès qu’elle en a besoin. Le mécanisme qui permet de récupérer le bytecode d’une classe pour qu’il soit utilisé par la JVM s’appelle le « chargement d’une classe ».
Ce qui est étonnant dans ce mécanisme, c’est qu’il est partiellement implémenté dans des classes Java : en effet, la JVM délègue une grande partie du travail de chargement des classes à une instance d’une classe spéciale que l’on appelle un ClassLoader.
Répondons tout de suite à une question qui doit vous brûler les lèvres : si un ClassLoader est une instance d’une classe Java, qui charge la classe du ClassLoader ? En réalité, il y a tout de même un ClassLoader interne à la JVM (qui n’est donc pas un objet Java à proprement parler) qui permet de charger les classes de base de Java ce qui comprend les classes java.*, ainsi que les premiers ClassLoaders.
Un ClassLoader est donc simplement un objet d’une sous-classe de la classe abstraite java.lang.ClassLoader. Cette classe contient des méthodes natives qui appellent directement certains mécanismes internes de la JVM.
Il y a donc deux façons de charger une classe :
- La première est de charger une classe explicitement à l’aide des fonctions d’un ClassLoader.
[java]
ClassLoader customClassLoader = getCustomClassLoader();
Class classA = customClassLoader.loadClass("custom.ClassA");
Object object = classA.newInstance();
// Ensuite on peut manipuler l’objet "object" à l’aide de l’API de reflexion de Java
[/java]
- La seconde, plus naturelle, consiste simplement à utiliser une classe. Dans ce dernier cas, le ClassLoader utilisé sera celui de la classe appelante. Par exemple, si on charge une classe ClassA en appelant explicitement un ClassLoader, et que celle-ci utilise un objet d’une autre classe non chargée (par exemple la classe ClassB), cette dernière classe sera elle aussi chargée par le ClassLoader de la classe ClassA.
[java]
public class ClassA {
public void someMethod() {
// Il est sémantiquement équivalent d’écrire à l’intérieur d’une
// méthode non statique d’une classe quelconque :
ClassB b = new ClassB();
// que d’écrire :
ClassB b = (ClassB) this.getClass().getClassLoader().loadClass("ClassB").newInstance();
}
}
[/java]
Fonctionnement classique d’un ClassLoader
La fonction centrale de la classe ClassLoader est la fonction loadClass. Cette fonction permet d’associer une classe à partir d’une chaine de caractères. Son fonctionnement est le suivant :
- Elle vérifie d’abord que la classe n’a pas déjà été chargée (via la méthode findLoadedClass)
- Puis elle demande à son ClassLoader parent (voir plus loin) s’il peut charger la classe (en appelant la méthode loadClass du ClassLoader parent). Si le ClassLoader trouve la classe, alors on renvoie cette classe. Sinon, on continue
- Si le ClassLoader parent n’a pas trouvé la classe, alors il tente de la trouver par ses propres moyens en appelant la méthode findClass .
Remarquons deux choses :
- Chaque ClassLoader a un parent. Ce parent est défini à l’initialisation d’un ClassLoader et, à chaque fois que le ClassLoader doit charger une classe, il demande d’abord à son parent s’il la connait.
- Le parent d’un ClassLoader est accessible via la fonction getParent. Si ledit parent vaut null, cela signifie que celui-ci est le « Bootstrap ClassLoader », c’est-à-dire le ClassLoader qui est interne à la JVM. Ce système de parent permet donc d’avoir toute une hiérarchie des ClassLoaders.
- Le fonctionnement du chargement des classes est Parent-First : un ClassLoader demande d’abord à son parent de charger la classe avant que le ClassLoader essaye lui-même de charger la classe.
Ce mécanisme est implémenté dans les méthodes de la classe java.lang.ClassLoader, mais grâce à la magie de l’héritage il est tout à fait possible de redéfinir le comportement de ces méthodes en spécialisant cette dernière classe.
Ainsi, la documentation officielle de la classe ClassLoader recommande de redéfinir la méthode findClass (qui par défaut ne fait que lancer l’exception ClassNotFoundException) mais de laisser indemne la méthode loadClass afin de préserver le mécanisme de recherche de classe « Parent- First »
Toutefois, il est tout-à-fait possible de redéfinir la méthode loadClass afin d’utiliser une stratégie « Parent-Last » pour charger les classes : le ClassLoader essaye d’abord par lui-même de charger une classe avant de demander au parent de charger celle-ci.
Cette stratégie est par exemple implémentée dans Tomcat afin d’isoler les classes chargées par les webapps, des classes utilisées par le système Tomcat. En réalité, c’est un peu plus complexe mais l’essentiel est de comprendre que la méthode loadClass peut être redéfinie pour implémenter toutes sortes de stratégie de chargement de classe.
Précision : si vous connaissez la méthode Class.forName, cette méthode n’est rien d’autre qu’un appel au ClassLoader courant déguisé, excepté qu’en plus de charger la classe, elle l’initialise aussi (initialiser signifie remplir ses champs statiques et exécuter le code statique).
Les ClassLoaders classiques
À ce niveau-là de lecture, la question que l’on pourrait se poser est la suivante : quand j’écris un programme Java simple (comme un HelloWorld), quels ClassLoaders sont utilisés ?
Pour une application Java classique (Java SE, sans framework particulier) sous Java 8 ou moins, il y a par défaut trois ClassLoaders qui sont utilisés :
- Le Bootstrap ClassLoader : il s’agit d’un ClassLoader interne à la JVM. Il se contente de charger les classes principales du langage Java (la plupart des classes java.*, mais aussi les premiers ClassLoaders afin d’éviter le paradoxe de l’œuf et de la poule). Ce ClassLoader n’est donc pas un objet d’une classe Java. Les classes principales du langage qui sont chargées par le Bootstrap ClassLoader sont contenues dans plusieurs fichiers .jar dont le principal est le rt.jar.
- L’ Extension ClassLoader : ce ClassLoader est responsable du chargement des classes d’extensions. Les classes d’extensions sont des classes qui sont contenues dans le dossier lib/ext du JRE utilisé.
- Le System ClassLoader (aussi appelé l’Application ClassLoader) : ce ClassLoader est responsable du chargement de toutes les classes du « ClassPath ». En effet, lorsqu’on lui demande une classe, il va regarder dans tous les JARs et les dossiers du ClassPath s’il y a un fichier .class dont le nom correspond à la classe demandée.
Ces trois ClassLoaders ont été présentés de manière généalogique : le Bootstrap ClassLoader est le parent de l’Extension ClassLoader qui est lui-même le parent du System ClassLoader.
Vu que les ClassLoaders fonctionnent sous une stratégie Parent-First, on comprend pourquoi il est impossible de redéfinir des classes systèmes dans une application Java : le BootStrap ClassLoader aura la priorité. De même, une classe présente dans le dossier lib/ext du dossier du JRE sera toujours prioritaire sur toutes les classes du ClassPath.
L’implémentation des ClassLoaders d’extension et de système sont des classes dépendantes de l’implémentation mais dans une JVM Oracle classique, les classes qui implémentent ces ClassLoaders sont respectivement les classes sun.misc.Launcher$ExtClassLoader et sun.misc.Launcher$AppClassLoader. Pour vérifier que ce sont bien ces classes qui sont utilisées en tant que ClassLoader, on pourra utiliser le code suivant :
[java]
class Main {
public static void main(String[] args) {
ClassLoader classLoader = Main.class.getClassLoader();
while(classLoader != null){
System.out.println(classLoader.getClass().getName());
classLoader = classLoader.getParent();
}
}
}
[/java]
Ces deux classes sont des classes internes et donc ne doivent pas être utilisées directement. Mais ce sont des classes filles de la classe java.net.URLClassLoader qui est l’une des classes de ClassLoaders les plus utiles.
En effet, cette classe implémente tout le mécanisme qui permet de charger les classes à partir d’un fichier JAR ou d’un dossier contenant des fichiers .class. En effet, la classe URLClassLoader permet de récupérer des dossiers ou des fichiers JAR à partir d’URL pointant sur un fichier local ou sur une adresse Internet (d’où son nom).
Exemples d’utilisations des ClassLoaders
Comment pourrait-on se servir des ClassLoaders et quels exemples d’utilisation réelle a-t-on ?
L’utilité principale d’un ClassLoader est qu’il permet de personnaliser le chargement de classes : il rend ainsi la JVM indépendante du système de fichiers :
- Le bytecode des classes peut aussi bien venir d’un système de fichiers que d’un serveur distant ou même d’une base de données.
- Les classes peuvent aussi bien venir d’un fichier JAR classique que d’un fichier chiffré.
- Un ClassLoader peut aussi permettre de ne lire un fichier JAR que si celui-ci est signé.
- Il peut aussi permettre de charger un fichier JAR de manière dynamique.
La possibilité de personnalisation du chargement des classes a été utilisée par un certain nombre de frameworks, solutions et outils de l’écosystème Java. Ainsi on peut citer :
- Les solutions J2EE (Websphere, JBoss, etc..) qui, en général, utilisent un ClassLoader par application entreprise (fichier .ear) et par application web (fichier .war). Certains de ces ClassLoaders peuvent être configurés en Parent-First.
- Tomcat, en tant qu’implémentation d’une sous partie de Java EE, a aussi ses propres ClassLoaders
- Le framework OSGi (sur lequel sont basés des programmes comme Eclipse) a son propre système de classloading leur permettant d’implémenter un système de bundle (qui est une sorte de module) qui n’expose qu’une partie de ses classes aux autres bundles.
- Les langages liés à la JVM (comme Groovy) utilisent eux aussi leurs propres ClassLoaders afin de personnaliser le chargement des classes aux besoins du langage.
Ces ClassLoaders personnalisés permettent par exemple d’ajouter les classes de base du langage aux classes accessibles des programmes.
Enfin, une dernière utilité des ClassLoaders que je n’ai pas citée, est qu’en plus de s’occuper de charger des classes, ils s’occupent aussi de charger des ressources, c’est-à-dire toutes sortes de fichiers ou autres.
En effet, la méthode getResource renvoie l’URL (qui peut pointer vers un fichier local) d’une ressource à partir de son nom.
Cette recherche se fait de manière analogue au chargement de classe : on commence par demander au ClassLoader parent de charger la ressource, et s’il ne la trouve pas, il va rechercher celle-ci à l’aide de la méthode findResource (analogue à findClass). Ainsi, à chaque fois que l’on recherche une ressource dans le ClassPath, c’est le System ClassLoader qui assure cette recherche.
Précision : la méthode Class.getResource est en fait un raccourci à Class.getClassLoader().getResource.
Par exemple, log4j fait appel à ce mécanisme lorsqu’il recherche le fichier log4j.xml dans le ClassPath pour s’initialiser.
Enfin, comme pour le chargement des classes, les méthodes getResource et findResources sont parfaitement redéfinissables lorsque l’on sous-classe la classe java.lang.ClassLoader.
La méthode defineClass et la génération dynamique du code
Nous avons vu que la fonction centrale de la classe abstraite ClassLoader était la méthode loadClass qui charge une classe dès que la JVM en a besoin et que cette méthode appelait, si besoin, la méthode findClass pour rechercher les classes.
La méthode findClass prend une chaine de caractères (représentant le nom de la classe) en argument et renvoie une classe. Elle fait donc en réalité deux choses : elle cherche le bytecode de la classe à partir de son nom, puis elle transforme ce bytecode en objet Class de Java.
La recherche du bytecode des classes dépend de l’implémentation du ClassLoader (on peut fouiller dans des JARs, dans des dossiers contenant des fichiers .class, etc… – cf parties précédentes) mais elle ne présente pas de difficultés intrinsèques.
Mais une fois que l’on a un bytecode (obtenu par exemple en lisant un fichier .class pour le cas des ClassLoaders classiques), comment peut-on obtenir une classe Java, c’est-à-dire un objet Class utilisable et fonctionnel ?
C’est la fonction defineClass que findClass utilise pour transformer du bytecode en classe.
Pour l’essentiel, cette méthode prend en argument un tableau de bytes représentant le bytecode d’une classe et renvoie en retour un objet Class fonctionnel.
C’est une méthode protégée, finale et native. En effet, cette classe appelle directement les mécanismes de lecture de bytecode internes à la JVM.
Mais puisque defineClass prend comme paramètre un tableau de bytes, rien n’empêche de générer ce dernier pendant l’exécution.
Ainsi cette méthode est l’ingrédient essentiel qui permet de générer des classes de manière dynamique. Comme la méthode est protégée, il faut ruser pour l’utiliser : soit dériver la classe ClassLoader et envelopper la fonction dans une fonction publique, soit utiliser les outils d’introspection de Java pour avoir accès à cette méthode dans le ClassLoader courant. Cette dernière méthode est plutôt complexe (pour un exemple, on pourra voir comment faisait une ancienne version de Mockito), et le devient encore plus sous Java 9.
Ce mécanisme est très utilisé au sein des frameworks de l’écosystème Java, car ils permet de générer de créer des classes « à la demande ».
- Par exemple, Mockito utilise ce mécanisme pour créer ses fameux mocks : en donnant une classe à la méthode Mockito.mock, celle-ci nous renvoie une instance d’une classe qui est générée dans la foulée : cette classe sera une classe fille de celle donnée en paramètre mais toutes ses méthodes ne seront que des mocks. La génération de cette classe dépend d’une manière ou d’une autre de la méthode defineClass.
- Ce mécanisme est aussi utilisé par Spring qui, pour un certain nombre de ses fonctionnalités (aspects, transactions jpa, etc…) a besoin de compléter les beans qu’il gère par du code supplémentaire. Pour cela, il créé à la volée des classes qui enveloppent les classes des beans, et ces créations de classes se font encore une fois via la méthode defineClass.
En réalité ces frameworks font appel à des bibliothèques Java de génération de code à la volée (CGLib, ASM, voire ByteBuddy) puis créent la classe via la méthode defineClass (sauf les dernières versions de Mockito qui utilisent ByteBuddy qui s’occupe aussi de générer la classe en plus de générer le bytecode).
Les ClassLoaders sous Java 9
Si le fonctionnement des ClassLoaders a très peu changé entre Java 1.2 et Java 8, la dernière mouture de Java 9 a changé en profondeur de nombreux pans du fonctionnement des ClassLoaders.
En effet la grande nouveauté de Java 9 est Jigsaw, le système de Modules. Sous Java 9, les classes et les packages sont regroupés au sein d’une nouvelle entité nommée « module » qui gère la visibilité de ces classes ; ainsi une classe a beau être publique, elle n’est visible qu’au sein de son propre module sauf si le module est configuré pour rendre visible cette classe.
Expliquer entièrement comment fonctionne le système de module n’est pas le but de l’article. Toutefois, nous allons présenter succinctement comment ce système de module affecte le fonctionnement des ClassLoaders.
Pour commencer, la hiérarchie des ClassLoaders a été modifiée. En effet, les ClassLoaders décrits dans le paragraphe « les ClassLoader classiques » ne sont plus les mêmes.
Les nouveaux ClassLoaders sont :
- Le Bootstrap ClassLoader qui ressemble beaucoup au Bootstrap ClassLoader de Java 8: c’est un ClassLoader interne à la JVM, et n’est pas classique. La principale différence est qu’il gère moins de classes. En effet, le JDK ayant été modularisé, ce ClassLoader ne gère que les modules les plus fondamentaux de la JVM. (D’ailleurs, le fichier rt.jar n’existe plus car il a été découpé en plusieurs modules.)
- Le Platform ClassLoader : il est le remplaçant de l’Extension ClassLoader mais fonctionne différemment. Le mécanisme d’extension ayant été abandonné car trop peu utilisé, il ne gère plus les classes d’extension mais gère les modules du JDK autrefois gérés par le Bootstrap ClassLoader. Par exemple, la classe java.sql.Date est gérée au niveau de ce ClassLoader alors qu’elle était gérée au niveau du Bootstrap ClassLoader sous Java 8. Il y a aussi d’autres changements techniques sur ce ClassLoader.
- L‘Application ClassLoader (ou System ClassLoader), s’occupe désormais de gérer les modules contenus dans les JARs du ModulePath et, à des fins de rétro-compatibilité, les classes contenues dans les JARs du ClassPath.
Autre différence importante : le Platform ClassLoader et l’Application ClassLoader ne sont plus des instances de sous-classes de la classe URLClassLoader, mais d’une autre classe interne (BuiltinClassLoader) qui est, en raison du système de module, totalement inaccessible aux applications tierces.
Une autre différence est que les ClassLoaders gèrent désormais le fait que les classes sont contenues dans des modules. Du coup, la classe abstraite ClassLoader définit de nouvelles méthodes pour prendre en compte ces modules. Par exemple, la méthode findClass a desormais deux signatures : la première est la même que sous Java 8, la seconde prend un paramètre supplémentaire : le nom du module où chercher la classe.
Plus important encore, la méthode loadClass des nouveaux ClassLoaders « classiques » (cf paragraphe précédent) commence par rechercher une classe dans leur modules : cela permet d’implémenter le nouveau mécanisme de Java 9 de rechercher d’abord les classes dans le « ModulePath » (qui est un dossier contenant les JARs correspondant à des modules) avant de rechercher dans le ClassPath classique.
Le système de modules impose encore d’autres différences sur le fonctionnement des ClassLoaders par rapport à Java 8. Mais entrer dans les détails nécéssiterait une explication plus appronfondie du système de modules, ce qui n’est pas l’objet de cet article.
Conclusion
On comprend donc que les ClassLoaders sont l’un des mécanismes qui sont au cœur de la JVM. Pourtant, les applications finales utilisent assez peu les fonctionnalités des ClassLoaders, contrairement aux frameworks et autres bibliothèques (OSGi, Spring, Java EE, Mockito…) qui eux, les utilisent couramment. En effet, ce mécanisme leur procure une grande flexibilité au niveau des fonctionnalités qu’ils peuvent apporter aux applications Java.
Enfin, cet article n’a pas épuisé toutes les utilités des ClassLoaders. Par exemple, en rusant avec le système de chargement de classe, on peut implémenter un mécanisme de rechargement dynamique de classe comme comme on peut le voir dans l’article A guide to Java class reloading.
Autres Liens :
The basics of Java class loaders : Explication des ClassLoaders par l’implémentation d’un ClassLoader très simple
Do You Really Get Classloaders : Un article qui se concentre surtout sur les erreurs dues aux ClassLoaders sous Java EE
Commentaire