Salut les Javaïste ! 👋
Dans notre épisode introductif précédent, nous avons vu ce qu'était un agent Java et comment le connecter à une application.
Aujourd'hui, on va enfin faire ce que sait faire tout bon agent (secret de préférence), espionner ce qui se passe sous nos yeux.
Pour ce faire, nous allons explorer le sujet plus en profondeur et nous intéresser aux classes et aux méthodes qui sont chargées durant le cycle de vie d'une application témoin.
Jusqu'à mars 2025, il nous aurait fallu pour accomplir cette mission soit l'utilisation d'une librairie comme ASM ou ByteBuddy soit un décodage minutieux du bytecode de chaque classe.
Mais depuis mars 2025, nous avons la possibilité d'exploiter la toute nouvelle API Class-File embarquée avec Java 24 (Java 22 si vous aimez le goût du risque des previews ou Java 25 si vous préférez les LTS)!
Profitons-en pour découvrir cette nouvelle API en soulevant le capot de notre VM préférée !
Notre cobaye : Une application (très) simple
Commençons par le commencement (c'est plus pratique) en créant une application ultra minimaliste contenant juste un appel à Greeter .
static void main() {
System.out.println("[App] Démarrage de target-app");
Greeter greeter = new Greeter("observateur");
System.out.println("[App] " + greeter.greet());
System.out.println("[App] Fin");
}Il est de tradition de toujours saluer au début d'un projet...
où Greeter est une classe tout ce qu'il y a de plus simple
public class Greeter {
private final String name;
public Greeter(String name) {
this.name = name;
}
public String greet() {
return "Bonjour, " + name + " !";
}
public String farewell() {
return "À bientôt, " + name + ".";
}
}... donc autant avoir une classe dédiée
Ensuite, créeons la base de notre agent. Contrairement à notre précédent article, notre fonction premain va ici doubler le nombre de ligne de code en son sein !
import java.lang.instrument.Instrumentation;
public final class ObserverAgent {
public static void premain(String agentArgs, Instrumentation inst) {
System.out.println("[Agent] ObserverAgent démarré");
inst.addTransformer(new ClassLoadObserver());
}
}
Notez qu'il vous est permis de nommer votre Transformer en Autobot
Dans cet exemple, nous utilisons la classe Intrumentation (présente depuis Java 5) pour ajouter un transformateur de classe. Transformateur qu'il nous reste à programmer... :D
Le ClassLoadObserver : l'espion qui m'aimait
Tout transformateur doit implémenter l'interface ClassFileTransformer. Cette interface contient une méthode transform qui sera appellée pour chaque classe Java et qui retournera ... un tableau de byte.
Tout transformateur retourne le tableau de byte relatif au bytecode modifié d'une classe.
import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;
public final class ClassLoadObserver implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) {
// TODO faire un agent
return null;
}
}
Parmi les entrées, on peut déjà remarquer la présence du classLoader, du className et ... du tableau de byte représentant le bytecode de la classe Java chargée.
On peut donc analyser et modifier le bytecode de nos classes Java ... au byte près ! 🤩
Nous pouvons également remarquer que, lorsqu'une classe n'est pas modifiée par le transformateur, la convention est de retourner null au lieu du tableau original.
Vu que notre transformateur applique la méthode transform sur chaque classe chargée par la JVM, une pratique courant est de se restreindre aux classes que nous désirons cibler spécifiquement.
Dans notre exemple simple et sans dépendances, au lieu de tester une correspondance entre la valeur de className et le nom des packages de notre application, nous alons explicitement exclure les classes internes de la JVM.
Nous allons retourner null sur ces classes afin de signaler à l'Instrumentation qu'elles restent non-modifiées.
import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;
public final class ClassLoadObserver implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) {
// Certaines classes internes (ex: classes anonymes générées dynamiquement)
// peuvent avoir un className null.
if (className == null) {
return null;
}
if (isJdkInternal(className)) {
return null;
}
// TODO, penser à faire des trucs sympas ici.
return null;
}
private static boolean isJdkInternal(String internalName) {
return internalName.startsWith("java/")
|| internalName.startsWith("javax/")
|| internalName.startsWith("jdk/")
|| internalName.startsWith("sun/")
|| internalName.startsWith("com/sun/");
}
}
Un peu de nettoyage avant de rentrer dans le vif du sujet
Notez cependant que même si vous ne filtrez pas sur les classes "nulles" ou de la JDK, vous ne serez pas spammés car seules 15 classes au total seront chargées pour l'exécution de notre programme. (Source : Trust me bro. 🕶️)
La nouveauté Java 24
Auparavant, pour pouvoir visualiser le contenu de notre classe (sans décoder le bytecode à la main), il aurait été nécessaire de dépendre d'une librairie comme ASM ou ByteBuddy. Depuis Java 24 et la release officielle de l'API Class-File (en préview depuis Java 22), il vous est à présent possible de manipuler un ClassModel.
Plus globalement, l'API Class-File est dans la continuité de l'évolution du langage Java
- Les objets exposés (représentant les classes, méthodes, champs, attibuts, instructions) sont immutables.
- Le parsing est lazy. Si vous ne désirez obtenir que les modificateurs d'accès de la classe, le parseur ne va pas décoder l'ensemble du bytecode pour des informations que vous n'utiliserez pas.
- Cette API offre également de quoi créer ou modifier des classes. Les éléments exposés sont majoritairement architecturés en interfaces fonctionnelles afin de faciliter leurs manipulations.
import java.lang.classfile.ClassFile;
import java.lang.classfile.ClassModel;
public final class ClassLoadObserver implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) {
// same as before
//...
try {
ClassModel classModel = ClassFile.of().parse(classfileBuffer);
String readableName = classModel.thisClass()
.asInternalName()
.replace('/', '.');
System.out.printf("[Agent] Classe chargée : %s%n", readableName);
//TODO : afficher des trucs sympas.
} catch (Throwable t) {
// Un ClassFileTransformer ne doit JAMAIS laisser échapper une
// exception à moins de bloquer le chargement de la classe.
System.err.println("[Agent] Échec du parse pour " + className
+ " : " + t.getMessage());
}
return null;
}
}
Le parseur peut également être obtenu en passant toute une variété d'option à la méthode ClassFile.of() afin de vous permettre une granularité fine sur l'interprétation du tableau de byte à charger.
It's Sysout time !
Grâce à notre observer, nous avons à présent accès à une véritable mine d'or d'informations pour chaque classe interceptée :
- Le
ClassLoaderresponsable du chargement de la classe Java que l'on examine. - L'empreinte mémoire du bytecode de cette classe.
- Sa classe parente.
- Ses interfaces implémentées.
- Sa visibilité (
public,private, etc.).
Mais ce n'est pas tout ! L'API nous donne également accès à de puissantes techniques d'introspection sur les méthodes elles-mêmes.
Voici les instructions que j'ai ajouté afin de pouvoir espionner au mieux ma JVM en action. 👀
String loaderName = (loader != null) ? loader.getClass().getName() : "Bootstrap ClassLoader";
System.out.printf("[Agent] ClassLoader : %s%n", loaderName);
System.out.printf("[Agent] Taille du bytecode : %d octets%n", classfileBuffer.length);
System.out.printf("[Agent] Super class : %s%n",
classModel.superclass().map(ClassEntry::asInternalName).orElse("Nope"));
System.out.printf("[Agent] Accès %s%n",
classModel.flags().flags().toString());
System.out.printf("[Agent] %2d interfaces(s)%n",
classModel.interfaces().size());
if(!classModel.interfaces().isEmpty()) {
System.out.printf("[Agent] → %s%n",
classModel.interfaces().stream()
.map(ClassEntry::asInternalName)
.collect(Collectors.joining(",")));
}
List<MethodModel> methods = classModel.methods();
System.out.printf("[Agent] %2d méthode(s)%n" , methods.size());
// Affichage des informations relatives à chaque méthode
methods.forEach(m -> System.out.printf("[Agent] → %s - %s - %s%n",
m.methodName().stringValue(),
m.methodTypeSymbol().displayDescriptor(),
m.flags().flags().toString()
));
Classes et méthodes passées sous la loupe 🕵️
Exécution et rappels de bytecode
C'est le moment de tester.
Et, pour faire les malins, nous lançons l'exécution en ligne de commande.
mvn clean package #sur chacun des projets Java (app et agent)
java \
-javaagent:agent-observer/target/agent-observer-1.0-SNAPSHOT.jar \
-cp target-app/target/target-app-1.0-SNAPSHOT.jar \
com.example.app.App
Normalement, tout se déroule sans accroc et le terminal nous donne l'output suivant :
[Agent] ObserverAgent démarré
[Agent] Classe #2 chargée : com.example.app.App
[Agent] ClassLoader : jdk.internal.loader.ClassLoaders$AppClassLoader
[Agent] Taille du bytecode : 1655 octets
[Agent] Super class : java/lang/Object
[Agent] Super class : java/lang/Object
[Agent] Accès [PUBLIC, FINAL, SUPER]
[Agent] 0 interfaces(s)
[Agent] 2 méthode(s)
[Agent] → <init> - ()void - [PUBLIC]
[Agent] → main - (String[])void - [PUBLIC, STATIC]
[App] Démarrage de target-app
[Agent] Classe #4 chargée : com.example.app.Greeter
[Agent] ClassLoader : jdk.internal.loader.ClassLoaders$AppClassLoader
[Agent] Taille du bytecode : 1035 octets
[Agent] Super class : java/lang/Object
[Agent] Super class : java/lang/Object
[Agent] Accès [PUBLIC, SUPER]
[Agent] 0 interfaces(s)
[Agent] 3 méthode(s)
[Agent] → <init> - (String)void - [PUBLIC]
[Agent] → greet - ()String - [PUBLIC]
[Agent] → farewell - ()String - [PUBLIC]
[App] Bonjour, observateur !
[App] FinCette sortie console est l'occasion parfaite pour quelques rappels théoriques par l'exemple sur le fonctionnement interne de Java :
- Le vrai nom des constructeurs : Dans le bytecode, un constructeur ne porte pas le nom de sa classe, il est nommé
<init>. - La signature stricte : La signature d'une méthode est définie par ses arguments en entrée ET son type de retour.
- Le vide originel : Le type de retour d'un constructeur est toujours
void.
C'est également l'occasion d'apprendre ! Par exemple, quel est cet accès SUPER associé à nos classes ?
La documentation Oracle reste assez vague à ce sujet :
The ACC_SUPER flag exists for backward compatibility with code compiled by older compilers for the Java programming language. In JDK releases prior to 1.0.2, the compiler generated access_flags in which the flag now representing ACC_SUPER had no assigned meaning, and Oracle's Java Virtual Machine implementation ignored the flag if it was set.
Pour les curieux, la réponse se situe sur StackOverflow et mériterait un article à part entière.

Et si on mutait notre Greeter en record ?
Modifions un peu le système. Que se passe-t-il si mon Greeter est redéfini avec le mot-clé record ?
En faisant cela, nos logs affichent à présent :
[Agent] ObserverAgent démarré
// ...
[App] Démarrage de target-app
[Agent] Classe #6 chargée : com.example.app.Greeter
[Agent] ClassLoader : jdk.internal.loader.ClassLoaders$AppClassLoader
[Agent] Taille du bytecode : 1845 octets
[Agent] Super class : java/lang/Record
[Agent] Super class : java/lang/Record
[Agent] Accès [PUBLIC, FINAL, SUPER]
[Agent] 0 interfaces(s)
[Agent] 7 méthode(s)
[Agent] → <init> - (String)void - [PUBLIC]
[Agent] → greet - ()String - [PUBLIC]
[Agent] → farewell - ()String - [PUBLIC]
[Agent] → toString - ()String - [PUBLIC, FINAL]
[Agent] → hashCode - ()int - [PUBLIC, FINAL]
[Agent] → equals - (Object)boolean - [PUBLIC, FINAL]
[Agent] → name - ()String - [PUBLIC]
[App] Bonjour, observateur !
[App] FinJeu des 7 différences pour geeks javaïstes
Eh oui ! Outre le fait que notre "classe" est maintenant final, on voit clairement dans le listing généré que les méthodes hashCode, toString, name et equals sont bel et bien définies et "écrites" au sein du record au moment de la compilation.
Vous connaissiez la la magie du sucre syntaxique ? Vous avez à présent accès à l'envers du rideau du magicien !
Notre agent VS Double-(inter)faces
Ne nous arrêtons pas en si bon chemin et complexifions un peu notre application en ajoutant deux interfaces (dont une parente).
public interface MotherOfInterface {
void doStuff();
}
public interface ComplexMachine extends MotherOfInterface {
default void doComplexStuff() {
IO.println("I swear I'm working !");
}
}
et une nouvelle classe finale implémentante
public final class Calculator implements ComplexMachine {
public int add(int a, int b) {
return a + b;
}
//...
@Override
public void doStuff() {
IO.println("Sometimes");
}
}
Ajoutons un appel dans notre classe principale afin que notre classe soit chargée par la JVM (sinon notre agent ne verra rien passer)
Calculator calc = new Calculator();
int sum = calc.add(21, 21);
int product = calc.multiply(6, 7);
System.out.printf("[App] 21 + 21 = %d | 6 * 7 = %d%n", sum, product);Analysons les logs après avoir relancé notre agent sur cette nouvelle application.
// same than before
[App] Bonjour, observateur !
[Agent] Classe #14 chargée : com.example.app.Calculator
[Agent] ClassLoader : jdk.internal.loader.ClassLoaders$AppClassLoader
[Agent] Taille du bytecode : 995 octets
[Agent] Super class : java/lang/Object
[Agent] Super class : java/lang/Object
[Agent] Accès [PUBLIC, FINAL, SUPER]
[Agent] 1 interfaces(s)
[Agent] → com/example/app/ComplexMachine
[Agent] 6 méthode(s)
[Agent] → <init> - ()void - [PUBLIC]
[Agent] → add - (int,int)int - [PUBLIC]
[Agent] → subtract - (int,int)int - [PRIVATE]
[Agent] → multiply - (int,int)int - [PUBLIC]
[Agent] → divide - (int,int)int - [PUBLIC]
[Agent] → doStuff - ()void - [PUBLIC]
[Agent] Classe #15 chargée : com.example.app.ComplexMachine
[Agent] ClassLoader : jdk.internal.loader.ClassLoaders$AppClassLoader
[Agent] Taille du bytecode : 432 octets
[Agent] Super class : java/lang/Object
[Agent] Super class : java/lang/Object
[Agent] Accès [PUBLIC, INTERFACE, ABSTRACT]
[Agent] 1 interfaces(s)
[Agent] → com/example/app/MotherOfInterface
[Agent] 1 méthode(s)
[Agent] → doComplexStuff - ()void - [PUBLIC]
[Agent] Classe #16 chargée : com.example.app.MotherOfInterface
[Agent] ClassLoader : jdk.internal.loader.ClassLoaders$AppClassLoader
[Agent] Taille du bytecode : 155 octets
[Agent] Super class : java/lang/Object
[Agent] Super class : java/lang/Object
[Agent] Accès [PUBLIC, INTERFACE, ABSTRACT]
[Agent] 0 interfaces(s)
[Agent] 1 méthode(s)
[Agent] → doStuff - ()void - [PUBLIC, ABSTRACT]
[App] 21 + 21 = 42 | 6 * 7 = 42
[App] FinL'analyse est riche en observations:
- On constate que notre classe est bien reconnue comme étant de type
FINALet implémentant une interface. - Notre interface ComplexMachine est bien chargée également, possède le acces flag
INTERFACEet la JVM lui reconnaît... son interface parente. - Bien qu'une interface étende une autre (via le mot clé extends), pour la JVM, il ne s'agit que d'un implements d'une interface. Raison pour laquelle une interface peut avoir plusieurs parents.
- ComplexMachine n'a pas doStuff dans la liste de ses méthodes (dans son bytecode).
- La méthode doComplexStuff étant "default" et possédant une véritable implémentation, elle n'est donc pas marquée comme
abstract, au contraire de la méthode doStuff issue de la MotherOfInterface qui, elle, est purement abstraite. - Si vous aviez oublié d'ajouter une instanciation à Calculator, cette classe n'aurait pas été chargée par la JVM. C'est quand même bien fait cette machine. ;-)
That's all Folks!
Pour clôturer cette expérience, ajoutons un shutdown hook sur notre agent. L'idée est de compter le vrai nombre de classes chargées depuis le démarrage de la JVM et de l'afficher juste avant l'extinction.
import java.lang.instrument.Instrumentation;
public final class ObserverAgent {
private static ClassLoadObserver myLoader;
public static void premain(String agentArgs, Instrumentation inst) {
System.out.println("[Agent] ObserverAgent démarré");
registerShutdownHook();
myLoader = new ClassLoadObserver();
inst.addTransformer(myLoader);
}
private static void registerShutdownHook() {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("[Agent] JVM is shutting down.");
System.out.printf("[Agent] %2d classes ont été chargées%n",
myLoader.totalClassCount);
}));
}
}
avec au sein de ClassLoadObserver
public final class ClassLoadObserver implements ClassFileTransformer {
public int totalClassCount = 0;
@Override
public byte[] transform(ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) {
totalClassCount++;
//same than beforeImpossible de faire plus simple
Vous pouvez à présent comptez le nombre de classes réellement chargées pour l'exécution de mon programme.
Vous avez le pouvoir d'inspection, vous n'être plus obligé de me faire confiance. :D
Vous trouverez les sources de ce mini-projet sur GitHub.
À bientôt pour d'autres aventures sur les agents Java (et désolé pour ceux qui ont cru qu'on allait parler d'IA) !