JVM (Java Virtual Machine) - #1

Parmi les grands axes d’optimisation d’une application Java, il y a la bonne gestion de sa mémoire et du garbage collector. Pour rappel, lorsque celui-ci s’exécute, toute l’application peut être figée pendant quelques millisecondes (ou plus), le temps de nettoyer les références inutiles. Le second axe d’amélioration est d’optimiser les accès au monde extérieur à la JVM (notamment dans le cadre d’accès à la base de données et des requêtes SQL / HSQL ou autres entrées/sorties).

Cet article est le premier d’une série de 3 articles autour d’une idée centrale : la machine virtuelle Java optimise plus facilement un code court et concis.

Pour optimiser une application Java, il ne faut pas négliger le code lui-même. Le bon vieux CheckStyle nous agace parfois mais il nous alerte sur la longueur maximum d’une méthode. A ce propos, le plugin Toxicity Chart pour Sonar, qui se base sur CheckStyle, peut être intéressant comme nous allons le voir. Parmi les bonnes pratiques de développement, il est primordial de ne pas faire des méthodes trop longues (30 lignes maximum) afin qu’elles puissent être visibles sur un seul écran.

Cette longueur aide non seulement à la lecture, à la compréhension, à la maintenance de cette méthode mais également à accélérer l’exécution de celle-ci par une meilleure optimisation dynamique de la JVM. Une méthode courte peut être jusqu’à 4400 fois plus rapide qu’une méthode trop longue non compilable.

C’est là que la qualité de code rejoint la vitesse d’exécution.

Résumé : comment gagner en rapidité en optimisant la JVM

L’optimisation dynamique concerne tous les langages s’exécutant dans la JVM : Java, Scala, Groovy, Jruby, Closure, Javascript mais aussi tout ce qui passe par une création de classe java intermédiaire : JSP, XSD (via xjc), WDSL (via wsimport).

Pour que les méthodes de la JVM soient transformées en code assembleur machine par la JVM, il faut 2 conditions essentielles :

  1. Un nombre d’exécutions de cette méthode qui doit être au moins de 1500 fois pour le mode -client ou de 10000 fois pour le mode -server.
  2. Une taille de méthode inférieure à 8Ko de bytecode (difficile de donner un équivalent en nombre de ligne de code). Une taille supérieure à 8Ko empêche toute compilation en assembleur de cette méthode.

A noter que l’optimisation du code par le compilateur JIT (Just In Time) et la traduction du bytecode en assembleur est d’autant plus grande si l’inlining est activé. L’inlining est la fusion d’une méthode appelante et appelée afin d’avoir une vision globale du traitement et d’en faciliter l’optimisation. Une méthode dépassant les 1000 octets de bytecode ne pourra pas profiter d’inlining d’inclusion. Elle sera donc moins rapide même si une partie de son code sera transformée en assembleur. La JVM utilise différents type d’inlining, nous y reviendrons plus tard.

Par défaut, les JVM 32bits activent le mode -client alors que les JVM 64bit ne propose que le mode -server. Selon les traitements, il y a un rapport de 1 à quasiment 800 entre le mode -client et -server pour la même JVM.

A noter que dans des calculs intensifs, un rapport de 1 à 4 peut aussi exister entre un JDK 32bits et 64bits.

Jusqu’à quel point le code assembleur généré est optimisé par rapport à un compilateur C

La transformation du bytecode en code assembleur machine apporte un réel gain de vitesse. Pour s’en convaincre, et pour l’exemple, le calcul d’une valeur de la suite de Fibonacci compilée en C avec l’optimisation agressive -O3 a un même temps de réponse moyen que la JVM 1.7 d’IBM utilisant un cache de méthodes déjà compilées (via –Xshareclasses). L’écart est seulement de 31% avec le JDK 8 64bit (sans option particulière au lancement de la JVM).

version C

version Java

#include <stdio.h>
#include <time.h>unsigned int fib(unsigned int n){unsigned int fibminus2=0;unsigned int fibminus1=1;unsigned int fib=0;unsigned int i;if (n==0 || n==1) return n;

for(i=2;i<=n;i++){

fib=fibminus1+fibminus2;

fibminus2=fibminus1;

fibminus1=fib;

}

return fib;

}

int main(int argc, char **argv)

{

unsigned int n;

if (argc < 2) {

printf(« usage: fib n\n »

« Compute nth Fibonacci number\n »);

return 1;

}

clock_t t1, t2;

t1 = clock();

n = atoi(argv[1]);

printf(« fib(%d) = %d\n », n, fib(n));

t2 = clock();

int diff = (((float)t2 – (float)t1));

printf(« time : %d ms »,diff);

return 0;

}

class fib_nc {
public static long fib(long n){long fibminus2=0;long fibminus1=1;long fib=0;long i;if (n==0 || n==1) return n;for(i=2;i<=n;i++){

fib=fibminus1+fibminus2;

fibminus2=fibminus1;

fibminus1=fib;

}

return fib;

}

public static void main(String[] args)

{

long n;

if (args.length < 1) {

System.out.println(« usage: fib n\nCompute nth Fibonacci number\n »);

return;

}

long t1, t2;

t1 = System.currentTimeMillis();

n = Long.valueOf(args[0]);

System.out.printf(« fib(%d) iteratif = %d\n », n, fib(n));

t2 = System.currentTimeMillis();

System.out.println(« time in ms :  » + (t2-t1));

}

}

NB : La ligne de commande à exécuter pour compiler sous Mingw32 est la suivante sous Windows : gcc fib.c -o fib.exe -O3.

A) Du code source à l’optimisation dynamique

La JVM est une machine virtuelle multi-langage et le nombre de langages supportés n’a pas cessé de grandir. Certains langages se limitent à de l’interprétation (même si elle est plus rapide sous Java 7 grâce au InvokeDynamic) mais les plus utilisés proposent également un compilateur pour passer du monde Scala, Groovy, Jruby, Jython, Javascript à un fichier .class en bytecode.

De plus, d’autres outils proposent de générer des classes Java qui à leur tour s’exécuteront dans la JVM. Les JSP sont transformées en servlet .java  via jspc (ou jasper sur tomcat) puis compilée en .class.

Pour les Web Service Soap, avec les fichiers XSD et WSDL, ce sont xjc et wsimport qui génèrent des classes java et .class à partir du XML. Il y a aussi des bascules entre JSON et java.

Enfin, il y a tout ce qu’on pourrait générer via de la génération de code maison avec du velocity et du freemarker. Le pire, c’est que ce code généré est très souvent ignoré dans les analyses de code statique de type Sonar alors que la qualité du code généré ne lui permet pas toujours d’être optimisé par le compilateur JIT.

Exécution JVM

A la compilation en bytecode, aucune optimisation particulière n’est effectuée. C’est au moment de l’exécution qu’une optimisation dynamique sera effectuée sur les classes et les méthodes en ayant le plus besoin. Au début du lancement de la JVM, toutes les méthodes sont interprétées jusqu’à ce qu’un seuil en nombre d’exécutions soit atteint et à condition que le code de la méthode à compiler ne soit pas trop long.  C’est là qu’intervient le compilateur JIT (Just In Time) qui compile en assembleur pour le processeur hôte (x86 ou x64 pour Intel) les instructions de bytecode.

Sur Android, le passage du mode interprété à un compilateur JIT a vu aussi une amélioration de performance, qui selon les benchmarks, passait sur le même appareil d’un facteur de 6 à 600 selon les tests dont en voici un sur le Nexus One (2010) lors du passage d’Android 2.1 à 2.2.

Sur les navigateurs internet, ce compilateur JIT est également en place depuis quelques années pour que le code javascript soit lui aussi traduit en code assembleur. Firefox utilise le moteur IonMonkey qui possède un compilateur JIT quant à Chrome avec le moteur V8, il utilise un autre moyen, la compilation dite “Ahead Of Time” (AoT) où toutes les classes Javascript sont compilées en assembleur à leur première exécution.

Ce type de compilation AoT est également possible avec la JVM Oracle avec l’option -Xcomp. Cependant, nous y reviendrons plus tard, cela ajoute un temps de démarrage plus long et des optimisations précoces.

D’autres options sont possibles au lancement de la JVM :

  •  -Xint : pour un mode totalement interprété sans aucune traduction en assembleur par le JIT ;
  • -Xmixed : qui est un compromis entre le tout compilé et le tout interprété. C’est aussi le mode par défaut de lancement de la JVM.

A noter que sur Android 4.4 (Kitkat), un nouveau mode d’exécution Java est disponible (mais non activé par défaut) : ART en remplacement de la JVM Dalvik. Ce que fait ART est une compilation Ahead Of Time au moment de l’installation de l’application.

B) Les types d’optimisations disponibles de la JVM

Afin d’optimiser au mieux le code interprété (puis compilé en assembleur), la JVM a besoin d’obtenir des métriques sur les méthodes et ainsi de faire un profiling sur l’ensemble des méthodes des classes chargées.

Voici un aperçu avec 9 types d’optimisations différentes parmi plus de 64 proposés par la JVM de Java 7 :

  • Inlining : c’est la fusion du code d’une méthode appelante avec le code de la méthode appelée. Il en existe 4 types différents, mais le plus courant est le  “monomorphic inlining” : ex: System.currentTimeMillis() lors d’appel de méthodes statiques
  • Escape analysis : analyse le code en profondeur et, lorsque c’est possible, évite de créer des instances d’objets lorsque celles-ci pointent sur des valeurs qui ne bougent pas et remplace l’expression par l’utilisation d’une constante.
  • CHA Class Hierarchy Analysis (Analyse des héritages et du polymorphisme qui permet une dévirtualisation) : permet si besoin de dévirtualiser une méthode polymorphe héritée. Cela permettra d’utiliser de l’inlining sur cette méthode dévirtualisée.
  • Optimisation CPU x64, MMX, SSE : ajout dans le code assembleur des instructions x64 sur processeur 64bits, et des optimisations MMX et SSE pour les calculs sur les types « float » et « double » et également la recopie de tableaux. ⇒ Après divers essais, SSE et MMX accélère surtout les JVM 32bits car cela leur met à disposition des registres 64bits, en passant à des JVM 64bits, l’accélération est moins visible.
  • Loop unrolling : lorsqu’une boucle s’effectue sur peu d’éléments et de manière constante, l’optimiseur remplace la boucle par n-fois le corps de cette boucle.
  • Lock coarsening : lors d’une boucle où une méthode synchronisée est appelée n-fois (avec le mécanisme de verrou aussi appelé n-fois), l’optimiseur replace les « n » synchronized par un seul avant la boucle principale ce qui optimise les ressources de verrous.
  • Lock eliding : élimine un bloc “synchronized” lorsque celui-ci n’est pas nécessaire
  • Dead code elimination : élimination du code mort  (ex: condition irréalisable)
  • Duplication code elimination : suite à une première optimisation, simplification du code optimisé

La JVM a deux types de stratégies correspondant à 2 compilateurs JIT différents :

  • Démarrage rapide et optimisation moyenne : c’est le -client ou Compilateur C1
  • Profiling plus long des méthodes mais meilleure optimisation : c’est le mode -server ou Compilateur C2

Sur le JDK Oracle, un nouveau mode a fait son apparition depuis Java 6, c’est le mode Tiered (nivelé en français).  Il tente de regrouper les qualités des 2 compilateurs C1 (-client) et C2 (-server). Après des benchmarks, les résultats que j’ai obtenus ne sont pas assez probants. On peut jouer sur les paramètres suivants : -XX:+TieredCompilation ; seul ou en ajoutant -XX:TieredStopAtLevel=1 (jusqu’à 4 qui est la valeur par défaut).

Autre type de compilation : l’OSR (On Stack Remplacement). C’est une compilation à la volée lors d’une boucle sur une méthode encore en bytecode avec un nombre élevé d’itérations. C’est le coeur de la méthode qui est alors transformé en code assembleur juste pour l’exécution en cours. C’est une compilation “éphémère” mais qui peut accélérer des boucles répétées un nombre de fois très élevé.

Sinon, alors que côté Microsoft et sa CLR, il a fallu attendre le framework dotNet 4.5 pour avoir la compilation assembleur en tâche de fond, celle-ci est disponible par défaut depuis Java 5.

Un dernier moyen d’optimiser le temps de lancement de la JVM et de compilation est d’avoir déjà sous la main le code assembleur des classes du noyau (RT) de la JVM, c’est ce que fait le JDK Sun puis Oracle depuis la version 5 via l’utilisation des classes JSA (-Xshare). Par défaut, le système tente de les utiliser et continue à fonctionner s’il ne parvient pas à les utiliser(l’activation se fait par  -Xshare:dump ou –Xshare:on).

La JVM IBM j9 utilise aussi un moyen équivalent via l’option de démarrage -Xshareclasses.

Avec Java 8, est prévu également le multi-tenant dont le but est de centraliser les classes communes entre applications tournant dans la JVM et aussi de n’avoir qu’une seule copie des méthodes transformées en assembleur par le JIT et ainsi économiser du temps de compilation et de la mémoire. Cependant l‘utilisation de debug, de profiling et d’agent (instrumentation) est problématique dans ce mode.

A noter que dans le monde des machines virtuelles, Microsoft avec la CLR a aussi de la compilation “AoT” via l’outil ngen qui propose une version du bytecode dotNet (IL) compilée en assembleur mais sans profiling et donc avec une optimisation moindre.

Une autre solution proposée par Microsoft n’existe pas encore dans la JVM est MPOG : Managed Profiled Guided Optimization dont le but est de réconcilier les 2 mondes JIT et AoT via la création d’un profiling JIT pendant l’exécution de l’application puis une compilation “Ahead Of Time” prenant en compte les informations de profiling lors de la phase précédente. Le gain de performance pourrait être de 25% par rapport à du “simple” AoT via ngen.

Ici s’arrête le 1er article de cette série sur les optimisations de la JVM. Le second article traitera des options permettant de constater toutes les compilations JIT.

Architecte JEE

0 commentaires

votre commentaire

Se joindre à la discussion ?
Vous êtes libre de contribuer !

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Inscription newsletter

Ne manquez plus nos derniers articles !