Processus de chargement des classes
Le mécanisme de chargement constitue la fondation de la réflexion. Lorsqu'une application nécessite l'utilisation d'un type, la machine virtuelle exécute trois phases distinctes : chargement binaire, liaison et initialisation. Ces étapes forment généralement une séquence indivisible désignée sous le terme de chargement de classe.
Phase de chargement : le fichier bytecode (.class) est lu depuis le système de fichiers ou le réseau, puis stocké en mémoire. Cette opération génère obligatoirement une instance de java.lang.Class dans le heap. Chaque type utilisé possède sa propre représentation Class unique.
Phase de liaison : comprend trois sous-étapes — la vérification structurelle du bytecode, l'allocation mémoire pour les variables statiques avec leurs valeurs par défaut, et la résolution des références symboliques en adresses directes.
Phase d'initialisation : exécution des blocs d'initialisation statique et affectation des valeurs déclaratives aux champs de classe. L'initialisation respecte une règle hiérarchique stricte : la classe parente est toujours initialisée avant sa descendante.
Les déclencheurs d'initialisation incluent : instanciation via new, invocation de méthodes statiques, accès à des champs statiques (lecture ou écriture), utilisation de méthodes réflectives de la classe Class, instanciation d'une sous-classe, et exécution d'une méthode main.
Architecture des chargeurs de classes
Le rôle principal du chargeur est d'ingérer les ressources binaires et de matérialiser l'objet Class correspondant. La JVM implémente trois principes fondamentaux :
- Responsabilité totale : un chargeur qui charge une classe gère également toutes ses dépendances transitives, sauf indication contraire explicite.
- Délégation ascendante : avant de tenter le chargement, le chargeur délègue systématiquement à son ancêtre. Le chargement local n'intervient qu'en cas d'échec de la chaîne parente.
- Mise en cache : toute classe chargée est mémorisée. Les demandes ultérieures consultent d'abord ce cache avant toute opération d'I/O.
La hiérarchie des chargeurs intégrés s'organise ainsi :
| Chargeur | Responsabilité | Représentation parente |
|---|---|---|
| Bootstrap | Classes fondamentales du JDK (java.lang.*) | null |
| Platform (anciennement Extension) | APIs de la plateforme SE et etxensions | Bootstrap |
| System (ou Application) | Classpath applicatif et modules utilisateur | Platform |
API essentielle de ClassLoader :
// Récupération du chargeur système
ClassLoader appLoader = ClassLoader.getSystemClassLoader();
// Remontée dans la hiérarchie
ClassLoader platformLoader = appLoader.getParent(); // Platform
ClassLoader bootstrapLoader = platformLoader.getParent(); // null (native)
Programmation réflexive
Principe fondamental
La réflexion permet d'inspecter et de manipuler la structure des types à l'exécution. Cette capacité d'introspection dynamique offre une flexibilité maximale : le programme peut découvrir et utiliser des éléments inconnus au moment de la compilation, ouvrant la voie à l'injection de dépendances, à la sérialisation personnalisée et aux frameworks d'ORM.
Acqusiition des métadonnées de classe
Trois voies d'accès à l'objet Class :
// 1. Accès direct via le littéral de classe
Class<Personne> meta1 = Personne.class;
// 2. Extraction depuis une instance existante
Personne individu = new Personne();
Class<? extends Personne> meta2 = individu.getClass();
// 3. Chargement dynamique par nom qualifié
Class<?> meta3 = Class.forName("org.exemple.Personne");
Ces trois références désignent identiquement le même objet Class en mémoire.
Manipulation des constructeurs
Méthodes d'inspection de Class pour les constructeurs :
Constructor<?>[] getConstructors(): constructeurs publics uniquementConstructor<?>[] getDeclaredConstructors(): tous les constructeurs déclarésConstructor<T> getConstructor(Class<?>... types): constructeur public spécifiqueConstructor<T> getDeclaredConstructor(Class<?>... types): constructeur déclaré spécifique
Instanciation via réflexion :
Class<?> classe = Class.forName("org.exemple.Etudiant");
// Récupération du constructeur à trois paramètres
Constructor<?> ctor = classe.getConstructor(String.class, int.class, String.class);
// Création d'instance avec arguments
Object instance = ctor.newInstance("Marie Dupont", 22, "Lyon");
Pour accéder à un constructeur non public, il faut lever la protection d'accès :
Constructor<?> prive = classe.getDeclaredConstructor(String.class);
prive.setAccessible(true); // Contournement du contrôle d'accès
Object objet = prive.newInstance("Identifiant");
Accès aux champs de classe
API d'inspection des attributs :
Field[] getFields(): champs publics hérités inclusField[] getDeclaredFields(): tous les champs déclarésField getField(String nom): champ public spécifiqueField getDeclaredField(String nom): champ déclaré spécifique
Modification de valeurs par réflexion :
Class<?> meta = Class.forName("org.exemple.Etudiant");
Field champAge = meta.getDeclaredField("age");
// Désactivation du contrôle d'accès pour un champ privé
champAge.setAccessible(true);
// Affectation sur l'instance cible
champAge.set(instance, 25);
Invocation dynamique des méthodes
Exploration du comportement :
Method[] getMethods(): méthodes publiques avec héritageMethod[] getDeclaredMethods(): méthodes déclarées sans héritageMethod getMethod(String nom, Class<?>... paramTypes): méthode publique spécifiqueMethod getDeclaredMethod(String nom, Class<?>... paramTypes): méthode déclarée spécifique
Exécution réflexive :
// Récupération de la méthode avec signature complète
Method calculer = meta.getMethod("calculerMoyenne", double[].class);
// Invocation avec récupération du résultat
Object resultat = calculer.invoke(instance, new double[]{14.5, 16.0, 13.5});
Contournement des vérifications de type
La réflexion permet de surpasser les contraintes du système de généricité, qui n'existent qu'au niveau du compilateur. L'exemple suivant illustre l'insertion d'une chaîne dans une liste typée Integer :
ArrayList<Integer> nombres = new ArrayList<>();
nombres.add(42);
// Accès à la méthode brute non généfiée
Method ajout = nombres.getClass().getMethod("add", Object.class);
ajout.invoke(nombres, "Texte arbitraire");
// La liste contient désormais [42, "Texte arbitraire"]
System.out.println(nombres);
Configuration externe et extensibilité
Un pattern architectural puissant consiste à externaliser la configuration dans des propriétés, permettant de modifier le comportement sans recompilation :
// Chargement de la configuration
Properties config = new Properties();
config.load(new FileReader("application.properties"));
String nomClasse = config.getProperty("service.impl");
String nomMethode = config.getProperty("service.operation");
// Instanciation et invocation configurables
Class<?> service = Class.forName(nomClasse);
Object impl = service.getConstructor().newInstance();
Method operation = service.getMethod(nomMethode);
operation.invoke(impl);
Cette approche constitue la base des frameworks d'inversion de contrôle et des architectures plugin.