Devoxx 2017 : Retour sur « Reactor 3 et la programmation réactive sur la JVM »

J’ai eu la chance de participer cette année à la 6ème édition du Devoxx France. Je vous propose dans cet article de revenir sur la conférence "Reactor 3 et la programmation réactive sur la JVM" présentée par Simon Baslé qui travaille sur le projet Reactor. J’ai apprécié cette présentation qui plus est, elle est sur un thème dans l'air du temps : la programme réactive.

Trucs et astuces : Optimiser Eclipse pour améliorer sa productivité

Dans un contexte d'entreprise, il est souvent nécessaire de garder une configuration d'IDE stable tout au long du projet et s'assurer que l'IDE réponde rapidement. Il est également important que les configurations pour tous les développeurs soient identiques et stables pour éviter l'effet "But it works on my machine!" (ie. un développeur a mis à jour un plugin ajoutant une fonctionnalité qui corrige un bug. Le problème est que le bug est résolu localement, mais pas pour les autres développeurs qui n'ont pas mis à jour leur plugin...).

Devoxx France 2012 – Manipulation de bytecode

Durant Devoxx France 2012, j'ai assisté à la conférence intitulée Manipulation de bytecode : démocratisons la magie noire, présentée par Julien Ponge (son compte github) et Frédéric Le Mouel.

Parmi les librairies qui manipulent le bytecode, il y a les conteneurs d'EJBs, terracota pour distribuer les calculs et hibernate. Le plugin eclipse gérant la mise en page et l'affichage des écrans Android manipule aussi le bytecode afin d'instrumenter la vraie librairie utilisée dans Android.

Ensuite, Julien et Frédéric nous présentent rapidement le bytecode java : chaque type primitif est représenté en interne par une lettre, les packages des classes sont représentées avec des slash au lieu des points (exemple : java/lang/String)... La machine virtuelle destinée à utiliser le bytecode est une machine à pile avec une zone contenant les constantes (constant pool) et une autre contenant le code à exécuter (opcode). Pour illustrer cela, ils nous font la démonstration d’exécution du bytecode généré par la compilation d'un simple System.out.println pour afficher le résultat d'une addition. Nous pouvons ainsi voir l'évolution de la pile et des variables (les registres du processeur).

ASM est une des librairies permettant la manipulation du bytecode. Elle dispose de 2 APIs :

  • Une API basée sur un modèle événementiel, structurée autour du patron de conception visiteur. Cela peut servir pour faire le profilage de code java, en implémentant l'interface ClassFileTransformer.
  • Une API basée sur un modèle objet, chargeant tout le bytecode en mémoire sous la forme d'un arbre syntaxique du code (AST).

AspectJ est une autre librairie permettant de faire de la manipulation de bytecode. Celle-ci permet de faire de la programmation orientée aspect (AOP) pour, par exemple, rajouter des traces (appels de méthodes...), de la sécurité (vérification d'accès à certains services) ou de la validation (vérification des champs d'un formulaire) ...
Pour illustrer cela, Julien et Frédéric font 2 démonstrations autour du calcul de la suite de Fibonacci, de manière récursive : la première est très lente et fait certains calculs plusieurs fois, la seconde est beaucoup plus rapide car elle utilisent AspectJ pour implémenter un cache des calculs déjà effectués.
Une autre utilisation possible est la modification d'une classe pour lui rajouter l'implémentation d'une interface qu'elle n'implémentait pas auparavant.

Byteman, une autre librairie permettant la manipulation du bytecode, implémente un agent qui modifie les classes d'une JVM en cours de fonctionnement, pour débugger par exemple. Byteman fournit également un injecteur de bytecode, à utiliser dans le setup de JUnit. Il devient ainsi possible, par exemple, de forcer File.mkdirs() à retourner false, ce qui permet d'améliorer la couverture des tests.
Pour accomplir sa tache, Byteman définit un langage proche du bytecode, qui permet de spécifier quelles sont les modifications à faire et l'endroit où il faut les placer.

Pour terminer la présentation, Julien et Frédéric nous présentent la version béta du projet de recherche JooFlux (celui-ci sera bientôt mis en open source). Ce projet utilise le travail de Rémi Forax sur le nouveau bytecode invokedynamic (introduit dans Java 7). Byteman modifie toute une classe, même si une seule de ses méthodes a changé (ce qui force le compilateur JIT à optimiser de nouveau l'ensemble des méthodes). A l'opposé, JooFlux ne modifie seulement ce qui est nécessaire, ce qui optimise l'utilisation du compilateur JIT. JooFlux dispose également d'une interface Swing/JMX pour gérer l'injection de bytecode. Comme exemple d'utilisation, on peut remplacer tous les appels invoke* par un appel invokedynamic.

Pour plus d’informations sur le bytecode, vous pouvez consulter les slides de la présentation JVM Bytecode for Dummies faite par Charles Nutter pour JavaOne 2011.

Scala et Java

Depuis quelques temps, je n’arrêtais pas d’entendre autour de moi des personnes parler et vanter les mérites du langage ‘’Scala’’.

Par curiosité, j’ai donc fait quelques recherches et à ma grande surprise je l’ai trouvé très intéressant.

Pour l’histoire, Scala a été démarré en 2001 par Martin Odersky, Professeur à l’EPFL qui travaille également pour Sun à la production de l’implémentation de référence de javac. C’est lui qui est à l’origine de l’implémentation des Generics Java.

Soirée JVM Performance – Paris JUG

Lors de la soirée « JVM Performance » organisée le 11/09/2012 par le ParisJUG, William Louth (@williamlouth sur twitter et william (at) inspired.com par email) de la société jinspired nous a sensibilisé à la problématique d'exploitation de la JVM.

Constat

Il part du constat, qu'en général, lorsqu'une alerte est détectée sur une JVM (alerte rouge nagios par exemple), la réaction des exploitants est :

  • Attendre que cela respasse au vert !
  • Faire un kill -9 de la jvm

Les exploitants ont finalement peu d'outil et ne peuvent pas vraiment analyser ce qui se passe à un instant T sur la JVM. La problématique est encore plus grande lorsqu'on essaye de connaître la qualité de service ressentie par les utilisateurs alors qu'on est en mode distribué dans le « cloud ».

Les logiciels que nous développons ne prennent pas en compte l'environnement dans lequel ils tournent (puissance de chaque nœud par exemple). Les traitements ne s'adaptent pas à l'environnement mais essayent de délivrer un résultat le plus rapidement possible.

Par exemple, lorsqu'on doit persister une information dans une base de données, le goulot d'étranglement étant cette base, il est probable qu'à sollicitation importante, certains Threads commencent à se bloquer. Or, un Thread qui se bloque, c'est de la mémoire de la JVM qui reste utilisée et on imagine très bien quelle sera la conséquence pour le Garbage Collector (GC) qui va commencer à passer de plus en plus souvent pour libérer de la mémoire.  Le GC va consommer de plus en plus de ressource processeur et au final, les Threads se bloqueront définitivement (spirale infernale).

L'idée défendue par William peut consister par exemple à ralentir certains traitements lorsqu'on constate que les accès à la base commencent à ralentir. Il s'agit d'implémenter une auto régulation par le logiciel lui même (Software Regulation Control).

La collecte

Pour cela, il est nécessaire de collecter de l'information sur les différents traitements et de les observer dans la durée afin de pouvoir anticiper l'avenir probable si rien est fait.

Le coût de la collecte

Le problème ici est que la collecte d'information et son stockage est très coûteux. William a illustré par un exemple assez simple mais très illustratif.


public class Client {
public static void main(String[] args) {
int NBTEST = 100;

long all = 0;
for (int i=0; i < NBTEST; i++) {
long start = System.nanoTime();
c1();
all += System.nanoTime() - start;
}
System.out.println("Mean: " + all / NBTEST);
}

public static void c1() {
}
}

L'objectif de ce code est de mesurer le temps d'exécution de l'appel à c1().

Sur mon PC portable assez lent, on obtient une moyenne de l'ordre de 1,5 ms.

Mais quel est le coût de cette mesure ?

Pour cela, William ajoute simplement (et seulement), deux appels à nanoTime() dans la méthode c1() qui devient donc :


public static void c1() {
System.nanoTime();
System.nanoTime();
}

La moyenne passe à environ 3 ms sur la même machine. On peut facilement conclure que le temps de la collecte est assez important.

Imaginez maintenant ce qui peut se passer lorsque les traitements s'empilent :


public static void c1() {
System.nanoTime();
c2();
System.nanoTime();
}

public static void c2() {
System.nanoTime();
c3();
System.nanoTime();
}

public static void c3() {
System.nanoTime();
c4();
System.nanoTime();
}

public static void c4() {
System.nanoTime();
System.nanoTime();
}

Notre système de mesure va introduire une gène dans la capacité de nos JVM à réaliser les vrais traitements pour les utilisateurs. Ici en l’occurrence, la méthode c4() ne fait toujours rien et pourtant nous avons consommé du temps ! Imaginez qu'il faille en plus persister les données collectées dans un fichier de log ou une base ou que sais-je encore !

L'idée développée par William est qu'il faut tenter de minimiser cet effort de collecte. Comment ?

Tracer l'utile seulement

La première piste pourrait être de ne tracer que là où on constate des ralentissements. Par exemple, se placer à une couche interface (servlet) que l'on peut imaginer être notre méthode c1() et mesurer tous les temps de chaque niveau c1() de notre application. Puis, lorsqu'on constate un incident, on regarde quel c1() a posé problème et alors, dans ce cas, on ajoute du code sur tous les niveaux c2() sollicités par ce c1(). On relivre l'application en production et on attend à nouveau que cela se dégrade puis ainsi de suite jusqu'à identifier le problème au niveau le plus fin.

Évidemment, cette méthode n'est pas envisageable dans un contexte de production !

Le crédit-temps pour les traces

L'autre piste consiste plutôt à s'attribuer une sorte de « crédit-temps » de perturbation et à dynamiquement adapter les mesures à chaque itération de collecte. Cela signifie que pour la première itération, on va s'attacher à évaluer un c1() en dessous, un c2() et un c3(). Puis, voyant notre crédit-temps épuisé, on va stopper là la collecte pour cet appel. A l'appel suivant, on partira du même c3() puis on ira vers c4() et on prendra un autre c1(). Et ainsi de suite. A bout de quelques itérations, nous devrions avoir les mesures pour l'ensemble de notre système.

L'axe du temps est donc utilisé pour collecter de la donnée et identifier les goulots d'étranglement avec un temps de nuisance maîtrisé !

Une amélioration possible consiste à essayer d'éliminer le bruit inutile de notre collecte. En effet, si nous savons par expérience qu'un traitement (niveau c2() par exemple) est de l'ordre de 1 ms en moyenne, et que lors d'une mesure niveau c2(), nous constatons que nous avons moins de 1,2 ms par exemple, il est alors inutile d'aller collecter les données dans les sous-niveaux puisque nous savons déjà que tout se déroule correctement.

A l'inverse, si notre c2() commence à dériver dans le temps, il va être intéressant de collecter l'information sur ses sous-niveaux pour lesquels nous appliquerons la même stratégie de collecte.

Au bout de quelques itérations, la collecte devrait être ciblée vers les points « anormaux » de notre traitement et donc, être efficace à 99 % !

Si nous arrivons à identifier les méthodes coûteuses, il est ensuite plus aisé de demander aux développeurs d'optimiser ces parties de code. Optimiser ne veut pas dire nécessairement d'améliorer la dite méthode mais peut-être de regarder au dessus si l'appel est véritablement nécessaire.

Par exemple, si nous reprenons notre exemple, l'appel à la méthode c4() est sans doute inutile puisque c4() ne fait rien ! On ne peut pas optimiser d'avantage c4() mais on peut se passer de son appel !

C'est le même principe par exemple pour une requête SQL. La requête peut-être optimisée mais jusqu'à un certain stade seulement. Pour améliorer ensuite, il faudra regarder à un niveau plus haut dans la couche des services.

Adapter le logiciel

Une fois la collecte réalisée, nous pouvons tenter de nous en servir pour auto-adapter notre logiciel à son environnement.

Par exemple, William prend le cas d'un utilisateur qui arrive sur un site assez chargé. Il ouvre la première page et patiente une dizaine de secondes. Il ouvre une seconde et une troisième et attend à chaque fois une dizaine de secondes. De fait, il se dit que ce site « craint ».

L'idée serait alors de fabriquer des lignes prioritaires (comme les FAST LANE dans les aéroports) de façon à ce qu'un utilisateur qui a déjà patienté une fois voit son prochain traitement prioritaire par rapport à d'autres n'ayant jamais patienté.

Pour cela, il va être nécessaire d'être capable d'allouer la puissance de traitement dynamiquement en fonction du contexte. Ce qui veut dire ralentir certains traitements au profit d'autres (on peut imaginer des sleeps par exemple).

A la manière de ce qu'on connaît avec la gestion de projet et le fameux « chemin critique », on peut identifier dans certains traitements le chemin critique qui implique que tout retard dans l'un de ses composants entraînera un retard total de la chaîne. Un Thread n'a pas cette notion. Lorsqu'on lui demande un traitement, il va tenter de l'exécuter le plus vite possible sans tenir compte du contexte environnant. Or, il peut ne pas être sur le chemin critique et peut s'exécuter plus tardivement.

Conclusion

William a donc exposé pendant près de deux heures la problématique de la collecte d'information sur l'état de la JVM et proposé des pistes pour adapter nos logiciels à cet état plutôt que d'observer impuissants la dégradation de nos JVM (se soldant en général par un arrêt-relance un peu brutal).

Les outils d'instrumentation que sa société propose permettent de répondre à ces besoins : Voir le site www.jinspired.com. Attention, le coût n'est pas anodin (à partir de 5k€ par CPU et licence globale pour un site à 115k€) sur lesquels il faut ensuite ajouter un coût de maintenance.

Toutefois, le rapport coût / efficacité peut s'avérer intéressant sur du long terme et peut-être grâce au tuning live vous faire économiser l'acquisition de quelques machines supplémentaires pour tenir la charge ! A étudier donc.

Java et l’open source

Lors d'un récent séminaire au CEA, j'ai eu l'occasion de présenter quelques idées sur la manière de faire du business à base de services autour de logiciels open source. Pour compléter le point de vue de l'orateur précédent, et dire que le business autour de l'open source n'est pas forcément simple et pas forcément gagnant, j'ai cité notamment l'exemple de Sun. Sun a créé Java puis a finalement disparu, racheté par Oracle, alors même que Java est devenu extrêmement utilisé notamment dans le monde des entreprises où on peut penser qu'il y a beaucoup de business à faire.