La machine virtuelle Java est-elle vraiment lente ?

Logo JavaDans une discussion récente sur le forum Java concernant la génération d’un exécutable, j’ai indiqué qu’un code natif n’étais pas forcément plus rapide que du bytecode, et que cela pouvait même être l’inverse puisque les JVM actuelles utilisent un compilateur JIT.

Comment cela pourrait-il être possible ?

Lorsqu’on compile un code natif, les compilateurs n’activent pas toutes les optimisations possibles afin que le programme puisse s’exécuter sur des machines ne disposant pas forcément du même type de matériel (et particulièrement le CPU).

A l’inverse, le bytecode Java est compilé dynamiquement à l’exécution par le compilateur JIT. Ce dernier peut donc prendre en compte les spécificités de la machine. Le gain de performance peut être très important…

Je vais donc essayer de faire quelques tests afin de comparer les temps d’exécution…

Environnement de test

Les tests ont été effectué sur un Pentium 4 HT 3.00 Ghz avec 1 Go de RAM.

Chaque test consiste en une boucle de 100 millions d’itérations sur des opérations simples, afin d’obtenir un temps d’exécution significatif. Le temps d’exécution est récupéré de deux manières différentes, avec deux appels à System.currentTimeMillis() autour de la boucle, mais aussi via un programme appelant afin de prendre en compte le temps de démarrage de la JVM et du chargement du programme natif. Chaque test sera exécuté cinq fois afin de limiter les erreurs potentiels.

  • La JVM 5.0 de Sun en mode interprété (option -Xint).
  • La JVM 5.0 de Sun en mode client.
  • La JVM 5.0 de Sun en mode Server (option -server).
  • La JVM 6.0 de Sun en mode client.
  • La JVM 6.0 de Sun en mode Server (option -server).
  • Un code natif généré par GCJ.
  • Un code natif généré par GCJ avec les optimisations activées (option -O3).
  • Un code natif généré par le compilateur Excelsior JET.

Le mode interprété désactive le compilateur JIT et se rapproche donc des premières JVM qui n’en comportait pas (version antérieur à Java 1.2). Dans ce cas le bytecode est simplement interprété sans être compilé en code natif, ce qui induit forcément un temps d’exécution plus lent, sans pourvoir bénéficier des optimisations spécifiques de la machine (puisque le code n’est pas compilé à l’exécution).

Le mode Client correspond à la JVM par défaut pour les postes clients. Elle correspond à un compromis entre optimisation et temps de compilation en privilégiant une compilation rapide à une optimisation à outrance (c’est à dire sans activer toutes les optimisations). La JVM peut éventuellement recompiler certaine partie du code en activant plus d’optimisation lorsque ce dernier est souvent exécuté.

Le mode Server active beaucoup plus d’optimisation dès le départ, et peut donc se relever plus longue à démarrer pour de grosse application. Comme son nom l’indique elle est à privilégié pour les applications serveurs qui nécessitent de bonnes performances…

Pour cela, j’ai donc utilisé les outils suivant :

Compilation & Exécution

Le bytecode Java est compilé avec le JDK 5.0, sans option particulière :

  javac TestX.java

La différence entre les différentes JVM Client et Server se fait à l’exécution avec les paramètres -Xint (pour désactiver JIT) et -server (pour forcer la JVM Server). Le même bytecode est utilisé pour Java 6.0 (il n’y a pas de recompilation).

Note : Sous Windows la JVM Server n’est disponible que dans le JDK.

Le code natif de GCJ est généré avec la commande suivante :

  gcj --main=TestX TestX.java -o TestX

Et avec l’option -O3 pour activer les optimisations standard :

  gcj -O3 --main=TestX TestX.java -o TestX

Note : N’étant pas habitué aux paramètres de GCC/GCJ, je reste à votre écoute pour toutes remarques sur le sujet…

Enfin le code natif de JET est généré par la commande suivante (le compilateur JIT nécessite le bytecode généré par le compilateur javac) :

  jc -main=TestX TestX.class

Pour chaque test, j’ai reporté les résultats suivant :

  • Min : Le temps d’exécution minimum.
  • Moyen : Le temps d’exécution moyen.
  • Max : Le temps d’exécution maximum.
  • Total : Le temps d’exécution total de la première exécution (afin de prendre en compte le temps de chargement de la JVM et de l’application natif).
  • Chargement : La différence entre le temps Total et le temps Moyen.

Toutefois, le temps d’exécution total et de chargement de la JVM ne semble pas vraiment significatif. Certaine optimisation du système d’exploitation pouvant fortement fausser ce résultat, il est préférable de ne pas les prendre en compte…

Premier test : Garbage Collector (Allocation mémoire)

Ce premier test consiste simplement à créer un grand nombre d’objet temporaire dans une boucle, afin de faire travailler le Garbage Collector qui devra s’occuper d’allouer/désallouer la mémoire.


  for (int i=0; i<max; i++) {
    integer = new Integer(i);
  }

Fichier source complet : Test1.java

Résultat :
Application Min Moyen Max Total Chargement
Java Interpreter 36,750 37,147 37,422 37,719 0,572
Java 5.0 Client 1,484 1,519 1,563 2,281 0.762
Java 5.0 Server 1,265 1,296 1,344 1,890 0.594
Java 6.0 Client 1,375 1,418 1,438 2.968 1,550
Java 6.0 Server 1,218 1,247 1,281 1,938 0,691
GCJ 3.4.4 20,500 20,800 21,047 20.953 0,153
GCJ 3.4.4 -O3 18,937 19,612 20,110 20,406 0.794
JET 4.0 11,938 12,356 12,563 13,203 0,847

Sans surprise, le mode interprété est de loin le plus long, mais les modes Client et Server sont bien plus performant que le code natif.

La raison est toute simple : La JVM utilise un espace mémoire propre qu’elle gère elle-même. Ainsi elle ne fait pas réellement d’allocation/désallocation mémoire à chaque itération, ce qui permet d’éviter un grand nombre d’appel système très coûteux…

Second test : Opération arithmétique

On se contentera ici d’un simple calcul :


  for (int i=0; i<max; i++) {
    value += (i/2L);
  }

Fichier source complet : Test2.java

Résultat :
Application Min Moyen Max Total Chargement
Java Interpreter 11,250 11,543 11,828 12,141 0,598
Java 5.0 Client 8,328 8,594 8,782 8,984 0,390
Java 5.0 Server 0,828 0,843 0,849 1,343 0,500
Java 6.0 Client 7,625 7,818 8,016 8,937 1,119
Java 6.0 Server 0,828 0,950 1,000 1,547 0,597
GCJ 3.4.4 5,875 6,165 6,437 6,750 0,585
GCJ 3.4.4 -O3 5,531 5,668 5,860 6,187 0,519
JET 4.0 1,093 1,121 1,156 1,547 0,426

Ici les résultats sont plus mitigés. On remarque déjà un gros écart entre le mode Server et le mode Client de la JVM, tout simplement parce que la JVM Server effectue toutes les optimisations possibles en fonction des caractéristiques de la machine.

Les résultats du code natif viennent s’intercaler entre les résultats de la JVM Server et de la JVM Client, et le code généré par JET se distingue en s’approchant du résultat obtenu par la JVM Server…

Troisième test : Les accésseurs/mutateurs

Il s’agit ici de tester l’utilisation d’un concept de base de la POO : les méthodes accésseurs et mutateurs.


  for (int i=0; i<max; i++) {
    object.setValue( i + object.getValue() );
  }

Fichier source complet : Test3.java

Résultat :
Application Min Moyen Max Total Chargement
Java Interpreter 20,750 21,078 21,313 21516 0,478
Java 5.0 Client 0,453 0,465 0,484 0,829 0,364
Java 5.0 Server 0.094 0,109 0,125 0.390 0,281
Java 6.0 Client 0,250 0,259 0,266 0,437 0,178
Java 6.0 Server 0,140 0,150 0,157 0,891 0,741
GCJ 3.4.4 2,265 2,359 2,422 3,188 0,766
GCJ 3.4.4 -O3 1,875 1,928 1,969 3,000 1,072
JET 4.0 0,109 0,115 0,141 0,766 0,651

Si l’interpréteur est loin derrière avec plus de 20 secondes, les JVM offrent encore les meilleurs performances que seul le code natif du compilateur JET arrive à atteindre.

Pour expliquer cela je vais faire un petit écart en parlant du C++ qui comporte un mot clef qui permet d’optimiser certain appel de méthode : inline. En effet lors de l’utilisation de méthode comportant peu d’instruction (typiquement un mutateur/accésseur), il est possible que le coût de l’appel à la méthode (qui implique aussi une vérification des paramètres et du type de retour) soit plus coûteux en temps que l’exécution du jeu d’instruction qu’elle contient. Le mot-clef inline du C++ permet d’indiquer au compilateur que le code complet de la méthode doit être inséré à la place de l’appel de la méthode, ce qui permet de se passer des appels de méthodes (Pour plus d’information sur le sujet, je vous invite à consulter la section concernant les fonctions inline dans la FAQ C++).

En Java il n’y a pas vraiment d’équivalent car cette tâche n’est pas du ressort du développeur mais du compilateur JIT, qui prend en compte un certain nombre d’élément en considération afin de déterminer s’il doit gérer la méthode en tant que méthode inline ou pas.

Dans ce cas la méthode ne devrait pas être ‘inline‘ puisque cela risquerait de « casser » la possibilité de redéfinir les méthodes dans une classe fille. Mais c’est pourtant ce que fait la JVM. Etant donné qu’elle s’occupe également du chargement des différentes classes, elle peut déterminer qu’il n’y a aucune classe fille chargé en mémoire, et donc que le code de ces méthodes peut être ‘inline‘. Si jamais une classe fille était chargé par la suite, il suffirait à la JVM de recompiler le code en question.

Le mauvais résultat en mode interprété peut aussi s’expliquer par le fait que la gestion des méthodes ‘inline‘ a été déplacé du compilateur javac vers le compilateur JIT (donc l’interpréteur n’effectue pas cette optimisation).

Etant donné les résultats obtenus, il semblerait que le code généré par JET dispose d’un système similaire. Par contre cela ne semble pas être le cas de GCJ.

Pour vérifier cela, le même test a été rejoué en utilisant le modificateur final sur ces méthodes afin d’empêcher toutes classes filles potentielles de les redéfinir. Cela permettra ainsi de forcer l’optimisation dans tous les cas.

Fichier source complet : Test3b.java

Résultat :
Application Min Moyen Max Total Chargement
Java Interpreter 18,969 19,253 19485 19,781 0,528
Java 5.0 Client 0,469 0,491 0,516 0,703 0,212
Java 5.0 Server 0,093 0,103 0,110 0,563 0,460
Java 6.0 Client 0,250 0,256 0,266 1,422 1,166
Java 6.0 Server 0,125 0,150 0,172 0,640 0,490
GCJ 3.4.4 1,765 1,774 1,782 2,094 0,320
GCJ 3.4.4 -O3 0,328 0,331 0,344 0,703 0,372
JET 4.0 0,093 109,2 0,125 1,359 1,250

L’utilisation de méthodes final permet au code compilé avec GCJ de gagner une seconde et demi avec l’optimisation, alors qu’il n’y a pas vraiment de changement perceptible dans les autres cas puisque l’optimisation était déjà effectuée.

Les différences de temps obtenu sur le code GCJ sans optimisation et le bytecode interprété est du au fait que les appels de méthodes final sont plus performante même si elle ne sont pas ‘inline‘ puisqu’il n’y a pas de mécanisme de recherche d’une version surchargée de la méthode.

Justement je finirais par tester cela en ajoutant une classe fille qui surcharge ces deux méthodes, le tout sera utilisé avec le code suivant afin d’utiliser les deux classes conjointement et donc d’empêcher les méthodes ‘inline‘. Cela permet de déterminer le coût de la recherche des redéfinitions de méthode :


  SimpleObject[] object = new SimpleObject[2];
  object[0] = new SimpleObject();
  object[1] = new ExtendedSimpleObject();
  for (int i=0; i<max; i++) {
    SimpleObject tmp = object[i%2];
    tmp.setValue( i + tmp.getValue() );
  }

Fichier source complet : Test3c.java

Résultat :
Application Min Moyen Max Total Chargement
Java Interpreter 36,907 37,271 37,734 37,703 0,432
Java 5.0 Client 2,812 2,840 2,906 3,094 0,254
Java 5.0 Server 2,484 2,550 2,609 3,109 0,559
Java 6.0 Client 5,469 5,546 5,640 6,906 1,360
Java 6.0 Server 0,735 0,753 0,766 1,312 0,559
GCJ 3.4.4 7,719 8,084 8,390 8,922 0,838
GCJ 3.4.4 -O3 6,485 6,653 6,813 6,812 0,159
JET 4.0 1,547 1,581 1,610 2,187 0,606

Chose troublante : Si la JVM 5.0 s’en sort assez bien que ce soit en mode Client ou Server, la JVM 6.0 nous donne des résultats plus étrange. Si la version Server est trois fois plus rapide que son homologue 5.0, la version Client est presque deux fois plus lente…

Les autres résultats sont assez cohérents avec ceux déjà obtenu, avec de bon résultat de JET et la lenteur catastrophique du mode interprété !!!

Conclusion

Le code natif ne se révèle pas forcément plus rapide que le bytecode Java puisqu’il ne peut pas bénéficier de toutes les optimisations possibles du matériel à moins de procéder à une compilation spécifique. Il peut toutefois être plus performant que la JVM Client dans certain cas, reste à savoir si le gain de performance est vraiment aussi important sur une application cliente…

Dans bien des cas la JVM Server se montre la plus performante puisqu’elle profite au maximum des spécifités de la machine. Toutefois pour des applications normales (c’est à dire qui ne se contentent pas d’effectuer la même opération 100 millions de fois), le gain ne sera pas forcément aussi évident…

Concernant le mode interprété, il semble plus qu’évident qu’il est à l’origine du mythe sur la lenteur de Java, mais cela n’a plus rien à voir avec les JVM actuelles…

18 réflexions au sujet de « La machine virtuelle Java est-elle vraiment lente ? »

  1. adiguba Auteur de l’article

    > MACOS : Le portable a été acheté il y a 3 mois, il s’agit donc d’une version récente.

    C’est la version de Java que je voulais connaitre ;)
    Maintenant je t’avouerais que je ne connais pas du tout le fonctionnement de la JVM d’Apple…

  2. hedes

    MACOS : Le portable a été acheté il y a 3 mois, il s’agit donc d’une version récente. C’est vrai aussi que la machine me semble moins performante que l’équivalent PC.

    Par ailleurs, lorsque j’ai utilisé Java2D, j’ai relevé quelques bugs d’affichage en activant l’anti-aliasing, la priorité à la qualité d’affichage etc. : l’application a planté violemment…

  3. adiguba Auteur de l’article

    @trimok

    >> 1) J’aurais aimé voir la comparaison avec du code C++ standard compilé

    Le problème avec ces « comparaison » c’est que si Java et C++ sont très proche syntaxiquement, leurs approches de la POO est assez différente, et le même code n’implique pas forcément les mêmes méchanismes… mais je pourrais essayer… (est-ce pour toi que GCC est un compilateur correct ?)

    >> 2) J’aurais aimé avoir aussi les tests avec les « anciennes » versions des JVM (1.3, 1.4)

    Pourquoi pas…

    @hedes
    >> Sous MacOs, une application java/Swing est assez lente et semble utiliser plus de mémoire.

    Je ne connais pas du tout le fonctionnement de Java sous MacOs…
    Toutefois il y a eu pas mal d’amélioration sur ce point dans les dernières JVM (tu utilisais quelle version ?)…

    Au passage si vous avez des idées de tests supplémentaires n’hésiter pas…

    a++

  4. hedes

    Pour utiliser java sur MacOs et windows avec JET pour ce dernier, JET est extrèement plus rapide lors de l’utilisation de Swing : initialisation, affichage de fenêtres, raffrachissement etc..
    Effectivement, lorsqu’on fait du calcul algorithmique pur ( calcul cartographique par exemple ou traitement d’images ), la différence n’est pas flagrante.
    Sous MacOs, une application java/Swing est assez lente et semble utiliser plus de mémoire.

    Utilisation de la mémoire : il faut faire très attention lors du codage et anticiper les problèmes à venir. Dans notre cas, nous avons été obligé de recoder certaines classes ( BufferedImage par exemple ) pour éviter que l’appli ne s’arrête au bout de quelques secondes et d’implémenter un pool de buffers.
    Autre exemple : on a supprimé les GridBagLayout car on s’est rendu compte qu’ils consommaient de la mémoire.
    Effectivement, nous sommes allés contre la parole officielle qui dit de ne pas s’occuper de la mémoire mais cela marche beaucoup mieux ;-)
    A+

  5. trimok

    Excellent travail d’adiGuba, merci.

    Mais:

    Je fais peut-être partie des « balourds » que cite Pill_S…
    Dans une « autre vie », après avoir travaillé en Pascal Objet/Delphi/C/C++, j’ai fait (2004) des projets WEB/Java Serveur (serveur d’application Websphere 5 sur UNIX, JVM 1.3), et j’ai rencontré des gros problèmes de rapidité, dans des applications « gourmandes » (beaucoup de traitements, beaucoup de mémoire utilisée).
    Plus loin dans le temps (2000), j’avais également rencontré des ingénieurs d’Ilog me disant que leur(s) produit(s)de type système expert – en mode Java, était 2 à 3 fois plus lent qu’en mode C++

    Donc :

    1) J’aurais aimé voir la comparaison avec du code C++ standard compilé
    (pas de librairie MFC ou de framework . NET), avec un compilateur correct.
    2) J’aurais aimé avoir aussi les tests avec les « anciennes » versions des JVM (1.3, 1.4)

  6. flying_nic

    A propos de cette option de gcc, en fait elle ne brise pas la compatibilité avec les autres proc. Elle permet seulement d’ »embarquer » dans l’exécutable du code optimisé pour les P4. Maintenant, il existe aussi l’option march (à la place de mcpu) qui elle brise la compatibilité avec les autres procs, mais optimise à fond l’exécutable.
    Dans le mesure ou la JVM fonctionne sur tous les cpu, je crois que la comparer avec l’option mpcu de gcc est équitable.
    Il existe aussi d’autres possibilités comme la possibilité de profiler son code, c’est à dire de créer un permier exécutable qui va collecter des infos concernant son exécution puis de recompiler son appli en vue d’en créer un deuxième qui va tenir compte des infos collectées par le premier. (Un peu comme la jvm le fait dans les tests 2 et 3). Le problème c’est que cela est un peu plus fastidieux et plus compliqué à faire. (Ps: Ne me demande pas quelles options utiliser, je ne les connais pas par coeur mais je sais que cela peut donner des résultats intéressants).
    Enfin, concernant le deuxième test, je crois que l’on pourrait optimiser le compilateur en le forçant à allouer plus de mémoire que nécessaire. De même, en c++ il est possible d’utiliser des allocateurs pour éviter ce jeu d’allocation/desallocation permanente de mémoire. Je ne pense pas qu’il existe un équivalent en java (mais en même tps, ce langage n’est pas prévu pour être éxécuter directement, donc c’est normal).
    Mais si déjà tu pouvais faire le test en utlisant l’option mcpu, ce serait super. ;)

  7. adiguba Auteur de l’article

    >> Au niveau de la méthodologie par exemple, je crois qu’il aurai été intéressant d’ajouter des options liées à l’architecture du processeur pour les exécutables créés avec gcj, ce en ajoutant une option comme mpcu=pentium4 par exemple.

    Je suis d’accord avec toi… mais comme je l’ai indiqué je ne connais pas suffisament les options d’optimisations de GCC…

    Je pense que je vais en faire un article en détaillant un peu plus les optimisations de la JVM, et j’en profiterais pour rajouter des tests avec cette option de GCC… et je reste à votre écoute pour toutes suggestions…

    Mais ce qui est intérressant c’est que la JVM peut utiliser ce type d’optimisation sans casser la portabilité du programme… alors qu’avec du code natif il faudrait générer un exécutable par OS et par architecture…

    a++

  8. flying_nic

    Pas mal, mais je ne suis pas d’accord sur tout.
    Au niveau de la méthodologie par exemple, je crois qu’il aurai été intéressant d’ajouter des options liées à l’architecture du processeur pour les exécutables créés avec gcj, ce en ajoutant une option comme mpcu=pentium4 par exemple.

  9. Avatar de lunatixlunatix

    pour GCJ, je ne crois pas que l’on puisse utiliser rt.jar (a cause de la licence de celui-ci). Ce que je voulais dire, c’est qu’on ne peut pas trop le comparer, puisque le code qui est derriere n’est pas le même que celui de sun.

  10. adiguba Auteur de l’article

    >> tu aurais pu en faire un article non ? ;))
    Au début je voulais juste faire un tout petit tests…

    >> Qui nous fera un jours un article sur le code managé, en éssayant d’éviter de retomber dans les travers JAVA VS .NET :)
    Justement j’avais déjà lu un article similaire concernant dotNET qui disait un peu la même chose, et que le code natif était souvent moins performant que le code managé…
    Malheureusement je ne le retrouve plus…

    >> Pour la compilation avec GCJ, tu as utilisé Gnu-classpath ou les JFC de sun ?
    J’ai fais les tests avec GCC 3.4.4, GCJ 3.4.4 et la libraire libiconv (apparemment neccessaire pour l’UTF8). Je pense donc qu’il doit s’agir de Gnu-classpath…

    Mais si tu as plus d’info sur les JFC et comment les utiliser je pourrais tester la différence…

    a++

Laisser un commentaire