Artisan Numérique

/développement/java/javassist/ Javassist et Instrumentation dynamique

Lorsqu’il s’agit dénicher les fuites de performances, le développeur java prend rapidement peur devant la perspective de journées entières passées à attendre devant un Profiler surpuissant qui va mouliner à deux à l’heure. Et lorsque les hypothétiques résultats tombent, c’est généralement la panique devant l'avalanche de listes et de chiffres.

Fort heureusement, il existe une alternative à ses outils d’étude de code, puissants mais lourds : l'instrumentation dynamique.

Lorsqu’il s’agit dénicher les fuites de performances, le développeur java prend rapidement peur devant la perspective de journées entières passées à attendre devant un Profiler surpuissant qui va mouliner à deux à l’heure. Et lorsque les hypothétiques résultats tombent, c’est généralement la panique devant l'avalanche de listes et de chiffres.

Fort heureusement, il existe une alternative à ses outils d’étude de code, puissants mais lourds : l'instrumentation dynamique.

La prise de mesure

L’objectif ici n'est pas de remplacer des outils très utiles comme JProfiler ou la « Test & Performance Tools Platform » (aka TPTP) d'Eclipse. Mais force est d’avouer que pour des cas d’études simples, c'est-à-dire la majorité, ces applications deviennent de véritables marteaux à écraser les mouches

Du coup, qui ne s'est pas retrouvé à coller un peu partout un code du genre :

public class Test {
   public void ma_methode() {
      // On regarde le temps au départ 
      long time = System. currentTimeMillis() ;

      // le corps de ma méthode
      int res=0;
      for (int i=0; i < 30000; i++) { Point p = new Point(i,i); res+=p.x+p.y; }

      // Récupération du résultat
      System.out.println("Temps passé dans ma_methode :"+( System. currentTimeMillis()-time)) ;
   }
}
Test.java

Là nous étudions le temps passé dans la procédure mais cela pourrait tout aussi bien être la mémoire consommée, le nombre d'objets crées ou tout autre mesure jugée pertinente. Mais si cette approche est efficace (au sens de rapide), elle n'en présente pas moins quelques défauts :

  • Impossible de laisser ces prises de mesure une fois les tests effectués sous peine de rendre le code illisible et encore moins performant.
  • Il est « relativement » fastidieux de passer dans chaque méthode pour insérer ce genre d’instrument.

Instrumentation à chaud

La solution à ces problèmes serait de pouvoir insérer automatiquement le code supplémentaire dans le corps de toutes les méthodes que l'on cherche à étudier. Une première manière de faire est simplement de parser et de modifier les sources. Certaines librairies comme instr, ou encore, AspectJ peuvent faire cela très bien. Mais dans les faits, se repérer dans du code (instr) ou écrire des règles de transformations (AspectJ) reste assez compliqué et bien peu pratique.

Une seconde méthode consiste, elle, à injecter, à l’exécution, du code binaire dans les classes compilées et ainsi mettre en place notre instrumentation à la volée. Et c’est parfaitement réalisable vu que Java nous offre deux belles portes d’accès : le ClassLoader, et, depuis le JDK 1.5, les agents de transformation de classe.

Chacune de ces deux approches permettent, au moment de leur chargement en mémoire, de modifier le tableau de byte d’une classe compilée «avant» qu’elle soit utilisée par les programmes. Ensuite, des librairies comme asm permettent d'insérer des instructions supplémentaires en assembleur java (le bytecode) avant de "rendre" la classe au système. C'est un pas dans la bonne voie mais avec un "hic" de taille : l’obligation de manipuler du bytecode, ce qui est tout sauf simple.

Javassist à la rescousse

Fort heureusement existe une excellent alternative au fait de jouer à l'assembleur Java : Javassist. Cette librairie nous simplifie la tache comme ce n’est pas permit puisqu’elle intègre en un même ensemble :

  • Une API permettant d’injecter du byteCode comme ASM ou BECL.
  • Un compilateur optimisé et une API permettant d’injecter directement du code Java dans les classes.
  • Un classloader conçu pour faire tout cela « à la volée ».
  • Et enfin un Loader permettant d’instancier et d’exécuter, à travers ce classloader, une class principale (avec un main()).

Instrumentation à chaud d'une méthode

Utiliser javassist est d'une simplicité déconcertante et son API ressemble beaucoup à ce que l'on peut trouver dans java.lang.reflect.*. Comme premier exemple, nous allons simplement modifier notre méthode Test.ma_methode pour y insérer, au début, l’affichage d’un petit message. Pour cela, nous allons créer une classe Profiler qui va avoir pour charge d’instrumenter la méthode ma_methode de la classe Test.

public class Profiler {
    // Insertion à chaud de code Java dans la methode ctMethode d'une classe ctClass. Notez que le code est un bloc entre { .. }
    public static void addInstrumentation(CtClass ctClass, CtMethod ctMethod) {
        ctMethod.insertBefore("{ System.out.println(\"Salut par ici\"); }");
    }

    public static void main(String[] args) throws Exception {
        // Création d'un pool de classes par javassist. Le pool peut être vu comme un cache. 
        ClassPool pool = ClassPool.getDefault();

        // extraction de notre classe Test.
        CtClass ctClass = pool.get("Test");

        // Recherche de la méthode à modifier
        CtMethod ctMethod = ctClass.getDeclaredMethod("ma_methode");

        // Ajout du code à chaud
        addInstrumentation(ctClass, ctMethod);

        // On transforme le CtClass Javassist en Class Java classique et on fabrique une nouvelle instance
        Class c = ctClass.toClass();
        Test test = (Test) c.newInstance();

        // Appel de ma_methode
        test.ma_methode();
    }
}
Profiler.java

Et là miracle, à l'exécution apparaît le message "Salut par ici". Le fonctionnement est simple. Une fois que nous avons récupéré l'objet de notre classe (CtClass), puis de notre méthode (CtMethod), la méthode addImplementation va y a injecté le supplémentaire sous la forme d’un fragment de syntaxe Java. Javassist va le compiler et l’insérer « avant » le corps de la fonction, permettant ainsi notre affichage de message à l’exécution.

Ceci dit, on n'est pas encore très avancé car pour que notre instrumentation fonctionne, il faut déclarer une variable locale time en entrée de procédure. Or comme vous l'aurez remarqué, Javassist ne permet d'insérer que des blocks, et notre variable serait alors invisible dans le second bloque que nous aurions du insérer à la fin. La « bonne » solution consiste donc à :

  1. Renommer la méthode ma_methode en ma_methode$impl
  2. Dupliquer ma_methode$impl et la renommer ma_methode
  3. Remplacer le corps de ma_methode avec l'instrumentation (time=..) suivi d'un appel à l'ancienne méthode renommée, suivi du calcul du temps. Nous allons donc modifier la procédure addImplémentation pour effectuer ce travail :
public void addInstrumentation(CtClass ctClass, CtMethod ctMethod) throws NotFoundException, CannotCompileException {
    String methodName = ctMethod.getName();
    String newMethodName = methodName + "$impl";

    // 1/ On renomme l'ancienne méthode
    ctMethod.setName(newMethodName);

    // 2/ On fait une copie de cette méthode au nom de l'ancienne méthode
    CtMethod newMethod = CtNewMethod.copy(ctMethod, methodName, ctClass, null);

    // 3/ On fabrique un nouveau corps à notre nouvelle méthode
    StringBuffer body = new StringBuffer();

    // Instrumentation de départ (notez le début de block { )
    body.append("{long start = System.currentTimeMillis();");

    // ON fait gaffe à ne pas oublier les fonctions qui renvoient une valeur
    String type = ctMethod.getReturnType().getName();
    if (!"void".equals(type)) {
        body.append(type + " result = ");
    }

    // Utilisation de la macro Javassist ($$) qui signife "tous les paramétres d'appel"
    body.append(newMethodName + "($$);\n");

    // Insertion de l'instrumentation de sortie : calcul et affichage du temps
    body.append("System.out.println(\"" + methodName + " :"+(System.currentTimeMillis()-start)+"ms\");");

    // Si l'ancienne méthode était une fonction, on renvoie son résultat
    if (!"void".equals(type)) {
        body.append("return result;\n");
    }

    // Fermeture du bloc
    body.append("}");

    // Injection du nouveau code pour le corps 
    newMethod.setBody(body.toString());

    // Ajout de la nouvelle méthode à notre classe
    ctClass.addMethod(newMethod);
}
Profiler.java

Voilà qui est fait. On relance notre test et cette fois, s'affiche le nom de la méthode et son temps d'exécution. Une bonne partie de faite.

Automatisation de l'injection

Maintenant que nous disposons d'un moyen efficace pour injecter à l’éxécution un nouveau comportement à une fonction sans pour autant en modifier son code source, il nous reste à faire cela automatiquement pour toutes les méthodes d'une même application.

Pour réaliser cela, nous allons utiliser le Loader de Javassist. Ce dernier permet d'exécuter une classe principale (disposant d'une méthode main()) tout en nous offrant l'opportunité d'injecter du code à la volée sur chacune des classes utilisées.

Le fonctionnement de ce loader est aussi simple que puissant. Une fois instancié, vous pouvez lui ajouter des objets héritant de l'interface Translator. Agissant comme des plugins, les Translators ont une méthode onLoad dont un des paramètres est le nom de la classe qui est sur le point d'être utilisée par le programme appelant. Nous pouvons donc très facilement écrire un Translator en réutilisant la classe Profiler précédente. Il suffit alors d'ajouté un implements Translator à la déclaration de la casse et d'insérer les deux méthodes manquantes :

public void onLoad(ClassPool pool, String classname) throws NotFoundException, CannotCompileException {
    CtClass ctClass = pool.get(classname);
    // On itère sur toutes les méthodes déclarées de la classe
    for (CtMethod ctMethod:ctClass.getDeclaredMethods()) {
       // On évite de chercher à modifier des méthodes natives ;-)
       if (!Modifier.isNative(ctMethod.getModifiers())) {
           addInstrumentation(ctClass, ctMethod);
       }
    }
}

// Cette méthode est laissée vide, elle ne nous sert à rien
public void start(ClassPool arg0) throws NotFoundException, CannotCompileException { }
Profiler.java

Ultime étape, maintenant que nous avons transformé notre Profile en Translator : lancer notre classe Test à travers le Loader de Javassist, et avec notre Translator qui ajoute dynamiquement l’instrumentation. Pour cela nous allons remplacer la fonction Profile.main() par celle-ci :

public static void main(String[] args) throws Exception{
   // Traitement des paramètres. Le premier est la classe principale à instrumentaliser, on l'enlève donc de la liste
   String mainClass=args[0];
   String[] tmp = new String[args.length - 1];
   System.arraycopy(args, 1, tmp, 0, tmp.length);
   args=tmp

   // Instanciation d'un loader Javassist
   Loader loader = new Loader();

   // Ajout de notre Translator
   loader.addTranslator(ClassPool.getDefault(), new Profiler());

   // Exécution de la méthode main de la classe principale grâce au loader
   loader.run(mainClass, args);
}
Profiler.java

Pour utiliser votre profiler maison, il ne vous reste plus qu'à exécuter la classe Profiler avec en 1ier paramètre la classe à instrumenter, et en paramètres suivants les éventuels arguments de celle-ci. Et si tout va bien, vous devriez voir, sans avoir modifier une ligne de la classe à étudier, les temps d'exécution défiler.

Conclusion

Lors de ce petit parcours nous n'avons jeté rapidement les bases d'un profiler ultra-précis et adapté à vos besoins. Il est cependant possible d'aller beaucoup plus loin, par exemple en utilisation les annotations pour sélectionner les méthodes, les classes ou les packages à instrumenter. Il est aussi possible de faire mieux qu'afficher simplement les temps et par exemples calculer les temps cumulés, les nombres d'appels, les occupations CPU par méthodes, la mémoire consommée, etc.

Mais outre cette application bien pratique de javassist, c’est l’injection de code elle-même qui démontre ici tout son intérêt. Il est possible d’utiliser cette technique dans nombre de cas où l'on était obligé de recopier des portions de code répétitives et gênante pour la compréhension des sources, comme par exemple des fermetures de connexion de base de données ou l'ajout de traces de debuggage. En tout cas j'espère que tout cela vous aura donné des idées.