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=0i < 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 à :
- Renommer la méthode ma_methode en ma_methode$impl
- Dupliquer ma_methode$impl et la renommer ma_methode
- 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.
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.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
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.
Drupal a la réputation d'un outil aussi puissant qu'ardu à apréhender. Destiné aux concepteurs de site Web, cet ouvrage a été conçu pour permettre une prise en main progressive et pragmatique du CMS qui ne cesse de faire parler de lui.
Commentaires
Merci!
Nickel, this tutoriel is very good for the bigginer in instrumentation class for JAVA. I am very entranced.
Publier un nouveau commentaire