Manipuler les classes Java avec ASM et Javassist : bytecode, instrumentation et fichiers JAR

Apprenez a manipuler les classes Java avec ASM et Javassist : chargement, modification du bytecode, instrumentation et creation de fichiers JAR.

Mahmoud DEVO
Mahmoud DEVO
December 27, 2025 10 min read
Manipuler les classes Java avec ASM et Javassist : bytecode, instrumentation et fichiers JAR

Introduction : L’importance de la manipulation de bytecode Java

La manipulation de bytecode Java est une technique avancee qui permet d’intervenir directement sur le code compile des classes Java (fichiers .class). Cette approche ouvre des possibilites considerables pour les developpeurs qui souhaitent aller au-dela des limites du code source traditionnel.

Qu’est-ce que le bytecode Java ?

Le bytecode Java est le code intermediaire genere par le compilateur Java (javac) a partir du code source. Ce code est ensuite interprete par la JVM (Java Virtual Machine). Contrairement au code machine natif, le bytecode est portable et peut s’executer sur n’importe quelle plateforme disposant d’une JVM.

La structure d’un fichier .class comprend :

  • Le magic number (0xCAFEBABE) - identifiant unique des fichiers de classe Java
  • La version du format - compatibilite avec les versions de Java
  • Le constant pool - table des constantes (strings, noms de classes, methodes)
  • Les informations de classe - modificateurs, nom, superclasse, interfaces
  • Les champs et methodes - avec leurs attributs et le bytecode

Pourquoi manipuler les classes Java ?

Manipuler les classes Java est une technique essentielle pour diverses applications :

  • Debogage avance : vous pouvez ajouter du code pour afficher des informations sur la methode qui est en cours d’execution, tracer les appels, mesurer les temps d’execution.
  • Instrumentation : vous pouvez modifier le comportement d’une classe sans avoir besoin de reecrire son code source, injecter de la logique metier, ajouter des logs.
  • Optimisation : vous pouvez optimiser le bytecode d’une classe pour ameliorer sa performance, supprimer du code mort, inliner des methodes.
  • Securite : analyse statique du bytecode pour detecter des vulnerabilites, obfuscation du code pour proteger la propriete intellectuelle.
  • AOP (Aspect-Oriented Programming) : implementation de la programmation orientee aspects via la modification dynamique du bytecode.

Les deux approches principales

Il existe deux bibliotheques majeures pour manipuler le bytecode Java :

  1. ASM - Approche bas niveau, travaille directement avec les instructions bytecode
  2. Javassist - Approche haut niveau, permet d’utiliser du code Java comme String

Comparaison detaillee : ASM vs Javassist

Avant de plonger dans les exemples, il est crucial de comprendre les differences entre ces deux bibliotheques pour choisir celle qui convient le mieux a votre cas d’utilisation.

CritereASMJavassist
Niveau d’abstractionBas niveau (instructions bytecode)Haut niveau (code source Java)
PerformanceTres rapide, faible empreinte memoirePlus lent, consommation memoire plus elevee
Courbe d’apprentissageDifficile (connaissance du bytecode requise)Facile (syntaxe Java familiere)
FlexibiliteTotale - acces a toutes les instructionsLimitee par les capacites du compilateur
Taille de la bibliotheque~50 KB~750 KB
APIVisitor pattern (event-based ou tree-based)API orientee objet intuitive
Modification de methodesInstruction par instructionVia des Strings de code Java
Cas d’utilisation typiqueFrameworks performants, profilersPrototypage rapide, AOP simple
Utilise parSpring, Hibernate, MockitoJBoss, Play Framework
Support des lambdasCompletLimite

Quand utiliser ASM ?

  • Vous avez besoin de performances maximales
  • Vous developpez un framework ou un outil de build
  • Vous devez manipuler des instructions bytecode specifiques
  • La taille du JAR final est critique

Quand utiliser Javassist ?

  • Vous voulez un developpement rapide
  • La manipulation est occasionnelle ou pour du prototypage
  • Votre equipe n’a pas d’expertise en bytecode
  • Vous avez besoin d’injecter du code Java complexe facilement

Chargement et manipulation des classes avec ASM

ASM est une bibliotheque puissante pour manipuler les classes Java. Elle offre deux APIs : l’API Core (basee sur les visiteurs) et l’API Tree (representation en arbre).

Charger une classe

Pour charger une classe, nous utilisons la methode loadClass de la classe ClassLoader.

public static Class<?> load(ClassNode cn) {
    ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
    return new ClassDefiner(ClassLoader.getSystemClassLoader())
        .get(cn.name.replace("/", "."), cw.toByteArray());
}

Cette methode utilise un ClassDefiner pour charger la classe. Le ClassDefiner est une classe qui etend ClassLoader et permet de charger une classe a partir d’un tableau de bytes.

Modifier une classe (Renommage)

Pour modifier une classe, nous utilisons un objet ClassWriter pour creer du bytecode modifie.

public static void main(String[] args) throws Exception {
    File jarFile = new File("Input.jar");
    Map<String, ClassNode> nodes = JarUtils.loadClasses(jarFile);
    Map<String, byte[]> out = JarUtils.loadNonClassEntries(jarFile);
    Map<String, String> mappings = new HashMap<String, String>();
    mappings.put("me/example/ExampleClass", "me/example/ExampleRenamed");
    out.putAll(process(nodes, mappings));
    JarUtils.saveAsJar(out, "Input-new.jar");
}

static Map<String, byte[]> process(Map<String, ClassNode> nodes, Map<String, String> mappings) {
    Map<String, byte[]> out = new HashMap<String, byte[]>();
    Remapper mapper = new SimpleRemapper(mappings);
    for (ClassNode cn : nodes.values()) {
        ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
        ClassVisitor remapper = new ClassRemapper(cw, mapper);
        cn.accept(remapper);
        out.put(mappings.containsKey(cn.name) ? mappings.get(cn.name) : cn.name, cw.toByteArray());
    }
    return out;
}

Ajouter une methode avec ASM

Voici comment ajouter une nouvelle methode a une classe existante :

public class AddMethodVisitor extends ClassVisitor {

    public AddMethodVisitor(ClassVisitor cv) {
        super(Opcodes.ASM9, cv);
    }

    @Override
    public void visitEnd() {
        // Ajouter une nouvelle methode "getTimestamp"
        MethodVisitor mv = cv.visitMethod(
            Opcodes.ACC_PUBLIC,           // modificateurs
            "getTimestamp",               // nom de la methode
            "()J",                        // descripteur (retourne un long)
            null,                         // signature generique
            null                          // exceptions
        );

        mv.visitCode();
        // Appeler System.currentTimeMillis()
        mv.visitMethodInsn(
            Opcodes.INVOKESTATIC,
            "java/lang/System",
            "currentTimeMillis",
            "()J",
            false
        );
        mv.visitInsn(Opcodes.LRETURN);
        mv.visitMaxs(2, 1);
        mv.visitEnd();

        super.visitEnd();
    }
}

Modifier un champ avec ASM

public class FieldModifierVisitor extends ClassVisitor {

    private String targetField;
    private int newAccess;

    public FieldModifierVisitor(ClassVisitor cv, String fieldName, int access) {
        super(Opcodes.ASM9, cv);
        this.targetField = fieldName;
        this.newAccess = access;
    }

    @Override
    public FieldVisitor visitField(int access, String name, String descriptor,
                                   String signature, Object value) {
        if (name.equals(targetField)) {
            // Changer le modificateur d'acces (ex: private -> public)
            return super.visitField(newAccess, name, descriptor, signature, value);
        }
        return super.visitField(access, name, descriptor, signature, value);
    }
}

Injecter du code au debut d’une methode avec ASM

public class MethodEnterVisitor extends ClassVisitor {

    public MethodEnterVisitor(ClassVisitor cv) {
        super(Opcodes.ASM9, cv);
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor,
                                     String signature, String[] exceptions) {
        MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);

        if (name.equals("processData")) {
            return new MethodVisitor(Opcodes.ASM9, mv) {
                @Override
                public void visitCode() {
                    // Injecter un log au debut de la methode
                    mv.visitFieldInsn(Opcodes.GETSTATIC,
                        "java/lang/System", "out", "Ljava/io/PrintStream;");
                    mv.visitLdcInsn("Entering processData method");
                    mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL,
                        "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
                    super.visitCode();
                }
            };
        }
        return mv;
    }
}

Javassist : Manipulation simplifiee

Javassist est une autre bibliotheque puissante pour manipuler les classes Java avec une syntaxe plus accessible.

Charger et transformer une classe

public class DynamicTransformer implements ClassFileTransformer {
    public byte[] transform(ClassLoader loader, String className,
                           Class classBeingRedefined,
                           ProtectionDomain protectionDomain,
                           byte[] classfileBuffer) throws IllegalClassFormatException {
        byte[] byteCode = classfileBuffer;
        if (className.equals("com/my/to/be/instrumented/MyClass")) {
            try {
                // Recuperer le ClassPool par defaut de Javassist
                ClassPool cp = ClassPool.getDefault();
                // Obtenir la classe avec son nom qualifie
                CtClass cc = cp.get("com.my.to.be.instrumented.MyClass");
                // Obtenir toutes les methodes de la classe
                CtMethod[] methods = cc.getDeclaredMethods();
                for(CtMethod meth : methods) {
                    // Code d'instrumentation a injecter
                    final StringBuffer buffer = new StringBuffer();
                    String name = meth.getName();
                    buffer.append("System.out.println(\"Method " + name + " executed\" );");
                    meth.insertBefore(buffer.toString());
                }
                // Creer le bytecode de la classe
                byteCode = cc.toBytecode();
                // Retirer la CtClass du ClassPool
                cc.detach();
            } catch (Exception ex) {
                ex.printStackTrace();
            }
        }
        return byteCode;
    }
}

Ajouter une methode avec Javassist

public void addMethodWithJavassist() throws Exception {
    ClassPool pool = ClassPool.getDefault();
    CtClass cc = pool.get("com.example.MyClass");

    // Creer une nouvelle methode
    CtMethod newMethod = CtNewMethod.make(
        "public int calculateSum(int a, int b) { return a + b; }",
        cc
    );
    cc.addMethod(newMethod);

    // Sauvegarder la classe modifiee
    cc.writeFile("./output");
    cc.detach();
}

Modifier un champ avec Javassist

public void modifyFieldWithJavassist() throws Exception {
    ClassPool pool = ClassPool.getDefault();
    CtClass cc = pool.get("com.example.MyClass");

    // Obtenir le champ existant
    CtField field = cc.getField("counter");

    // Supprimer l'ancien champ
    cc.removeField(field);

    // Ajouter un nouveau champ avec un type different
    CtField newField = new CtField(CtClass.longType, "counter", cc);
    newField.setModifiers(Modifier.PRIVATE);
    cc.addField(newField, "0L");

    cc.writeFile("./output");
    cc.detach();
}

Ajouter du code avant/apres une methode

public void instrumentMethod() throws Exception {
    ClassPool pool = ClassPool.getDefault();
    CtClass cc = pool.get("com.example.Service");

    CtMethod method = cc.getDeclaredMethod("processRequest");

    // Ajouter du code au debut
    method.insertBefore(
        "{ " +
        "   long startTime = System.currentTimeMillis(); " +
        "   System.out.println(\"Starting processRequest\"); " +
        "}"
    );

    // Ajouter du code a la fin
    method.insertAfter(
        "{ " +
        "   long endTime = System.currentTimeMillis(); " +
        "   System.out.println(\"processRequest took \" + (endTime - startTime) + \"ms\"); " +
        "}"
    );

    // Ajouter un gestionnaire d'exception
    method.addCatch(
        "{ System.err.println(\"Exception in processRequest: \" + $e.getMessage()); throw $e; }",
        pool.get("java.lang.Exception"),
        "$e"
    );

    cc.writeFile("./output");
    cc.detach();
}

Sauvegarder une classe

Pour sauvegarder une classe, nous utilisons la methode saveAsJar de la classe JarUtils.

public static void main(String[] args) throws Exception {
    File jarFile = new File("Input.jar");
    Map<String, ClassNode> nodes = JarUtils.loadClasses(jarFile);
    Map<String, byte[]> outBytes = JarUtils.loadNonClassEntries(jarFile);
    saveAsJar(outBytes, "Input-new.jar");
}

Cas d’utilisation reels

La manipulation de bytecode est utilisee dans de nombreux frameworks et outils populaires.

1. Frameworks de Mocking (Mockito, EasyMock)

Ces frameworks utilisent la manipulation de bytecode pour creer des objets mock a la volee :

// Mockito utilise ByteBuddy (base sur ASM) pour creer des mocks
UserService mockService = Mockito.mock(UserService.class);
when(mockService.getUser(1)).thenReturn(new User("John"));

Sous le capot, Mockito genere dynamiquement une sous-classe qui intercepte tous les appels de methodes.

Les ORM utilisent le bytecode enhancement pour implementer le lazy loading :

@Entity
public class Order {
    @OneToMany(fetch = FetchType.LAZY)
    private List<OrderItem> items; // Charge uniquement quand accede
}

Hibernate modifie le bytecode pour intercepter l’acces aux collections et charger les donnees a la demande.

3. Profilers et APM (Application Performance Monitoring)

Les outils comme New Relic, Datadog, ou Java Flight Recorder utilisent des agents Java :

// Agent Java pour mesurer les temps d'execution
public class ProfilingAgent {
    public static void premain(String args, Instrumentation inst) {
        inst.addTransformer(new ProfilingTransformer());
    }
}

4. Frameworks AOP (AspectJ, Spring AOP)

@Aspect
public class LoggingAspect {
    @Before("execution(* com.example.service.*.*(..))")
    public void logMethodCall(JoinPoint jp) {
        System.out.println("Calling: " + jp.getSignature().getName());
    }
}

Spring AOP utilise CGLIB (base sur ASM) pour generer des proxies au runtime.

5. Obfuscateurs (ProGuard, DexGuard)

Ces outils modifient le bytecode pour proteger le code :

  • Renommage des classes, methodes et champs
  • Suppression du code mort
  • Optimisation du bytecode
  • Chiffrement des chaines de caracteres

Bonnes Pratiques

Voici les recommandations essentielles pour manipuler le bytecode de maniere efficace et securisee.

1. Toujours utiliser COMPUTE_FRAMES avec ASM

// BON : laisse ASM calculer les frames automatiquement
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);

// MAUVAIS : peut causer des VerifyError
ClassWriter cw = new ClassWriter(0);

Le calcul automatique des frames evite les erreurs de verification du bytecode par la JVM.

2. Detacher les CtClass apres utilisation (Javassist)

try {
    CtClass cc = pool.get("com.example.MyClass");
    // ... modifications
    cc.toBytecode();
} finally {
    cc.detach(); // Libere la memoire
}

Sans detach(), les classes s’accumulent dans le ClassPool et causent des fuites memoire.

3. Gerer correctement le ClassLoader

// Utiliser le ClassLoader du contexte plutot que le systeme
ClassPool pool = new ClassPool();
pool.appendClassPath(new LoaderClassPath(Thread.currentThread().getContextClassLoader()));

4. Tester avec differentes versions de la JVM

Le bytecode genere doit etre compatible avec la version ciblee :

// Specifier la version du bytecode genere
cw.visit(Opcodes.V11, // Java 11
    Opcodes.ACC_PUBLIC,
    "com/example/GeneratedClass",
    null,
    "java/lang/Object",
    null
);

5. Utiliser des tests unitaires pour valider les transformations

@Test
public void testMethodInjection() throws Exception {
    byte[] original = getClassBytes(MyClass.class);
    byte[] modified = transformer.transform(original);

    Class<?> loadedClass = new ByteArrayClassLoader().defineClass(modified);
    Method method = loadedClass.getMethod("newMethod");

    assertNotNull(method);
    assertEquals(42, method.invoke(loadedClass.newInstance()));
}

Pieges Courants

Voici les erreurs les plus frequentes a eviter lors de la manipulation de bytecode.

1. Oublier de gerer les exceptions de verification

// PROBLEME : VerifyError a l'execution
// La JVM verifie le bytecode et rejette les classes invalides

// SOLUTION : Activer les logs de verification
java -XX:+UnlockDiagnosticVMOptions -XX:+LogCompilation -jar app.jar

2. Modifier les methodes natives ou abstraites

// ERREUR : Les methodes abstraites n'ont pas de corps
CtMethod abstractMethod = cc.getDeclaredMethod("abstractMethod");
abstractMethod.insertBefore("System.out.println(\"test\");"); // Exception!

// SOLUTION : Verifier le type de methode
if (!Modifier.isAbstract(method.getModifiers())) {
    method.insertBefore("...");
}

3. Ignorer les problemes de ClassLoader

// PROBLEME : ClassNotFoundException ou LinkageError
// Les classes modifiees doivent etre chargees par le bon ClassLoader

// SOLUTION : Utiliser le meme ClassLoader que la classe originale
ClassPool pool = new ClassPool();
pool.insertClassPath(new ClassClassPath(TargetClass.class));

4. Ne pas gerer les classes internes et anonymes

// Les classes internes ont des noms speciaux : OuterClass$InnerClass
// Les classes anonymes : OuterClass$1, OuterClass$2, etc.

for (CtClass inner : cc.getNestedClasses()) {
    // Traiter les classes internes egalement
    processClass(inner);
}

Conclusion

La manipulation de bytecode Java avec ASM et Javassist ouvre des possibilites immenses pour les developpeurs Java avances. Que vous developpiez un framework, un outil de monitoring, ou que vous ayez besoin d’instrumenter du code existant, ces bibliotheques offrent des solutions puissantes.

Resume des points cles

  • ASM est ideal pour les applications necessitant des performances maximales et un controle total sur le bytecode
  • Javassist convient parfaitement au prototypage rapide et aux modifications simples grace a sa syntaxe proche du Java
  • La manipulation de bytecode est au coeur de nombreux frameworks populaires (Spring, Hibernate, Mockito)
  • Respecter les bonnes pratiques evite les pieges courants comme les VerifyError ou les fuites memoire

Pour aller plus loin

Prochaines etapes

  • Debogage avance : ajout de code pour afficher des informations detaillees sur l’execution.
  • Instrumentation de production : modification du comportement des classes en production via des agents.
  • Optimisation de performance : analyse et optimisation du bytecode pour les applications critiques.
  • Securite applicative : utilisation de la manipulation de bytecode pour l’analyse statique de vulnerabilites.

Nous esperons que ce tutoriel approfondi vous aura fourni les bases necessaires pour maitriser la manipulation de bytecode Java. N’hesitez pas a experimenter avec les exemples fournis et a explorer les nombreuses possibilites offertes par ces puissantes bibliotheques.

Advertisement

In-Article Ad

Dev Mode

Share this article

Mahmoud DEVO

Mahmoud DEVO

Senior Full-Stack Developer

I'm a passionate full-stack developer with 10+ years of experience building scalable web applications. I write about Vue.js, Node.js, PostgreSQL, and modern DevOps practices.

Enjoyed this article?

Subscribe to get more tech content delivered to your inbox.

Related Articles