Dame Java, plus que toute autre plate-forme, trimbale avec elle une ribambelle d'idées reçues au regard de ses performances. Cela va du bête et simple "Java c'est lent" qui date des JDK 1.2 et 1.3, à des optimisations de code exotiques en droite ligne des croyances C/C++. L'objectif de cet article est donc de poser des valeurs chiffrées sur certains de ces mythes pour déterminer s'ils en sont, ou pas. Et des fois, cela révèle de belles surprises.
Protocole de test
Lorsque l'on fabrique des micro-benchmarks, l'ennemi c'est HotSpot. Pour ceux qui voudraient s'y frotter, je vous conseille à ce sujet l'exellemnt article (en anglais) : Anatomy of a flawed microbenchmark.
En effet le bougre est tellement doué qu'il a vite fait de fausser les résultats par un tour de passe-passe en détectant que telle ou telle partie de code n'est pas utilisée. Tous les tests qui sont fait ici sont donc clôturés par un test de sorte à s'assurer qu'HotSpot ne nous joue pas de tours.
De plus pour réduire encore les aléas, chaque benchmark est lancé 10 fois de suite et seul le meilleur résultat est gardé (merci shimrod
. A chaque lancement de test, le garbage collector est vidé (autant que faire se peut), et les cache disques sont ré-initialisés.
Enfin, tout se fait de manière automatique dans une infrastructure maison très proche de JUnit de sorte à ne privilégier personne. Les graphiques sont ainsi générés automatiquement à l'issue de chaque test.
Influence du -server
Tant que l'on est dans le domaine de HotSpot, il est intéressant de jeter un œil sur une optimisation des plus directe, à savoir d'utiliser la VM dans son profil "serveur". Pour ce test, un calcul de la valeur de PI à 100 chiffres sert de base de travail et le résultat est pour le peu étonnant.
Le mode -server indique à la VM, et particulièrement à HotSpot, d'optimiser dès le démarrage tout ce qu'il peut. Ce à quoi cela correspond exactement reste assez obscur, certains disent qu'HotSpot pré-compile l'ensemble du code, d'autres qu'il détecte les chemins efficaces en avance de phase (c'est à dire avant d'avoir à les exécuter réellement). Au fond peu importe, ce qui est sûr c'est que les performances sont meilleures avec cependant une contrepartie à garder en tête.
Sur le graphique, les barres indiquent les temps d'exécution du calcul de PI pour les deux modes, la ligne représente elle le temps de démarrage par mode. Ce que l'on constate donc c'est que si le gain de performance est réel, le temps d'exécution lui, a augmenté significativement. C'est donc la première contre-partie. La seconde étant que ce mode est réputé consommer plus de mémoire.
En somme, le mode "server" est bien adapté à des applications aillant une longue durée de vie. Les applications devant démarrer vite et avec peu de mémoire préféreront le mode "client".
Copie de tableau
Cette vieille histoire de copie de tableaux est une bonne manière d’illustrer les évolutions qu'à connues la VM depuis ces débuts. En effet, la bonne réputation de System.arrayCopy s’est faite à l’époque du JDK 1.4 où elle obtenait des scores deux à trois fois supérieurs à une recopie par itérative.
Avec les JDK 6 et 7 (graphique ci-contre) les choses ont bien changé. ArrayCopy reste rapide mais la différence est devenue très faible alors que le test porte tout de même sur un tableau de 20 millions d'entiers de type long... L'autre aspect intéressant de ce résultat est que System.arrayCopy est une méthode native, c'est à dire écrite en C. Cela illustre donc assez bien les différences réelles de performance entre une VM moderne et un code compilé nativement.
Entrées/Sorties
Dés qu’il s’agit d'entrées-sorties, que ce soit pour un fichier ou pour un flux réseau, la règle d’Or est « tampon ». Certains vont trouver cela un peu naïf comme conseil mais la réalité du code que j’ai régulièrement sous les yeux tend à prouver que ce n’est pas une évidence pour tous et les cas de lecture ou écriture dans un input/output stream tout con, et ce tant qu'à faire, octet par octet, sont légions. Le graphique ci-contre donne une bonne idée de l’impact de cette approche sur les performances.
La raison est toute simple. Nombre de périphériques fonctionnent par bloc, et donc par tampon. Forcer une lecture-écriture octet par octet sur un disque où une interface réseau revient à court-circuiter ce mécanisme en obligeant le matériel à vider des tampons quasi vides. Et se traduit par une perte drastique de performance.
La solution la plus simple consiste à systématiquement encapsuler un input/output stream dans un BufferedInputStream ou BufferedOutputStream.
Ensuite il est possible d'améliorer encore les performances en utilisant la librairie NIO disponible depuis le JDK 1.4. Comme vous les voyez les résultats sont très bon mais le code beaucoup plus complexe.
D'un point de vue général, la bonne position est de toujours passer par un buffer, et autant que possible écrire par block dans ce buffer
Méthodes finales
Toute personne à qui j'ai demandé à quoi servait le mot clef final a du me sortir une version différente de l'histoire. Dés fois c'est juste "joli", d'autre cela fait gagner de la mémoire, cela protège les fonctions/classes, d'autre fois encore cela rend tout plus rapide. Après avoir pas mal cherché car moi non plus je n'avais pas la réponse, je me suis rendu compte que c'est le camp de la "mémoire" qui avait raison, avec celui de la protection, un peu. Mais pour la performance, comme vous pouvez le constater, ça change que dalle...
Position de la constante dans un test
Je ne sais pas de quant date cette drôle de mode, sûrement des âges lointains du C, mais j'ai rencontré un nombre de fois incalculable des développeurs qui s'acharnaient à écrire des choses comme
if (null==maValeur) {
...
}
L'argument auquel j'ai souvent eu droit est que cette approche est beaucoup plus performante pour une raison obscurément liée au nombre de la bête. Mais comme en témoigne le graphique, c'est un mythe cyberurbain de plus. La position de la constante n'a aucune sorte d'importance et ce serait autrement lisible par un humain de base si s'était écrit dans le "bon" sens.
Il en va de même pour les fanatiques du ++compteur qui n'apporte strictement rien sur le naïf compteur++ lisible par n'importe quel quidam, moi compris. pour info, le test a été répété 5 millions de fois pour obtenir ce pouillème de différence sûrement due à un ou deux quarks en vadrouille sur mon CPU ce jour là.
Les exceptions
Là ce n'est plus un mythe, ou plutôt ce n'en était pas un. En effet, les blocks try/catch ont souvent été à juste titre pointés comme une source de fuite de performance. Et force est d'avouer que la VM a énormément évolué depuis pour obtenir aujourd'hui d'aussi une si petite différences sur des tests itérés 1 million de fois...
En somme, si votre programme broute, ce n'est pas la peine de virer la gestion des exceptions...
Condition d'une boucle
Autant la position d'une constante dans un test n'a, comme nous l'avons vu, strictement aucune incidence sur les performances, autant la condition dans une boucle de type for ou while peut avoir des conséquences désastreuses. Et c'est au fond une question de bon sens car cette condition est exécutée autant de fois que la boucle fait de tous. Ainsi, si vous écrivez une boucle comme dans l'exemple suivant, à chaque itération, la fonction calculeValeurMax est exécutée.
for (int i=0; i < calculeValeurMax(); i++) {
...
}
Et si cette fonction prend disons 50ms à renvoyer son résultat, une boucle de 1000 itérations, coûtera 50 secondes juste pour le test... La solution est dans ce cas d'inverser la tendance pour placer la fonction coûteuse dans une zone fixe.
for (int i=calculeValeurMax()-1; i>=0; i--) {
...
}
Taille des identifiants
Repassons un peu sur de la bonne légende urbaine maintenue vivace par la guilde des compresseurs de code. Cette catégorie de gens ont un métier qui s'approche plus de la cryptographie que du développement tant ils aiment à produire un code bien compact en persuadant leur entourage que plus c'est court, plus ça va vite. Ces gens là, on les comprend, détestent les formateurs automatique de code et se vengent sur la taille des identifiants en les réduisant au maximum à un ou deux caractères arguant que de longues variables influent sur les performances.
Dans l'exemple du graphique, j'ai une variable qui fait à peu prés 255 caractères contre une de 1 caractère. Comme vous le voyez, sur 500 millions d'itérations la différence est flagrante... Et pour cause, le compilateur Java stocke les identifiants, toutes tailles confondues, dans un dictionnaire en tête du bytecode de la fonction et ne les utilise plus que par référence en interne. Il n'y a donc aucune raison autre que religieuse à produire un code illisible ou utiliser des abréviations compréhensible des seuls initiés.
Objets mutables et immutables
Un objet est dit "immutable" si l'on ne peut pas en changer la valeur interne. Par exemple String est immutable. Si l'on veut changer la chaîne de caractère qu'il contient nous sommes obligés de créer un nouvel objet. A contrario, StringBuffer est un objet mutable. Je peux le vider de son contenu ou l'étendre.
Une des raisons des gros problèmes de performances de Swing à ces débuts tenait justement à ce que des objets élémentaires comme Point ou Dimension étaient immutable, obligeant ainsi la VM à créer des objets en pagaille à chaque modification. Aujourd'hui la coordonnées X d'un point est modifiable par p.x avec du coup un réel gain de performance.
Le graphique ci-contre illustre la différence d'approche mutable/immutable pour donner une idée des différences de performance. L'immutabilité est donc à utiliser avec parcimonie lorsqu'elle est réellement justifiée (intégrité du contenu par exemple).
Synchronisation
La synchronisation est un mécanisme permettant à une même ressource d'être utilisée sans conflits par plusieurs processus (thread). Ainsi lorsqu'un traitement est synchronisé sur un objet, cet objet n'est disponible que pour le premier arrivant, les suivants sont mis en attente jusqu'à ce que la ressource soit libérée, et ainsi de suite.
Les blocs synchronisés, comme les Exceptions vues plus haut, ont longtemps été une source de perte de performance en Java. Il y a donc dans le JDK des versions synchronisées (dits thread-safe) et non-synchronisées pour une même fonction. C'est le cas de Hashtable (synchronisé) et Hashmap (non synchronisé), Vector (synchronisé) et ArrayList (non synchronisé).
Il serait donc logique de se dire qu'il est important d'utiliser les versions non-synchronisées aussi souvent que possible. Mais le problème est qu'avec une VM moderne (JDK6/7), comme pour les Exceptions, les blocs synchronisés représente un coût négligeable. Le graphique ci-contre en témoigne et le nombre d'itération utilisé pour le construire est de 1 million...
Or aujourd'hui, avec le développement massif des processeurs à 2 ou 4 cœurs, les applications gagnent réellement à être massivement parallélisées. Utiliser une structure qui ne soit pas "thread safe" c'est prendre le risque de passer des heures à chercher les verrous mortels lorsque vous serez amenés à "threader" votre code.
Le second graphique d'exemple compare justement un Hashmap de 800 milles éléments à ses équivalents Hashtable et ConcurrentHashmap. Comme vous le voyez, le jeu n'en vaut clairement plus la chandelle avec même une petite avance pour ConcurrentHashmap. Et le résultat est strictement le même entre Vector et ArrayList.
Alors vu l'enfer que représente d'un debuggage multi-thread, pensez-y à deux fois avant de jouer la carte de ce genre d'optimisation.
Les entiers
Une idée communément répandue est de dire que plus petite est la structure, meilleur sont les performances. Ainsi il est courant de voir des compteurs de boucles "taillées au plus juste", genre en utilisant un byte. Le problème est que dans la vraie vie les choses se passent autrement car c'est la matériel qui compte. Et il y a longtemps que les CPU ne manipulent plus des entiers de 8bits, mais plutôt de 32 ou 64 bits. La conséquence est que pour les opérations de comptage (Inc/Dec et Add/Sub), le byte est systématiquement le mauvais choix de performance et int le meilleur alors qu'il est 4 fois plus gros. Et pour les mêmes raisons sur les processeur modernes, un long (64 bits) est toujours et encore meilleur qu'un byte.
A noter aussi que les opérateurs ++ et -- sont plus rapides que leurs équivalent +1 et -1. La aussi la raison en est que les processeurs disposent d'opérateurs d'incrémentation-décrémentation et que le bytecode, lui aussi, distingue ces deux cas. Du coup, sur un processeur moderne décrémenter ou incrémenter un compteur est à peu prés aussi performant sur un long que sur un int.
Maintenant il y a une autre raison beaucoup plus intéressante qui se trouve dans le bytecode lui-même. Prenons l'incrémentation d'une variable de type entier (i++), en byte code cela nous donne :
IINC 1: i 1
Maintenant regardons le bytecode de l'incrémentation d'un byte (b++)
// Chargement de la valeur de b dans le registre entier
ILOAD 1: b
// Création d'une constante 1
ICONST_1
// Ajout des deux
IADD
// Conversion de l'entier en bytes
I2B
// Stockage du byte
ISTORE 1: b
Étonnant non ? En fait lorsqu'une seule opération est nécessaire pour incrémenter notre entier, il en faut 5 pour incrémenter un byte.
Variables locales contre champs
Là aussi c'est un concept qui revient souvent. La variable locale serait plus rapide que la variable d'instance (le champ). Et pourtant, comme vous pouvez le voir, la différence est loin d'être flagrante et ce même en poussant à 1 milliard d'itérations. Même constat lorsque les variables sont finales ou statiques, cela ne change rien du tout.
Getter ou pas getter
Les accesseurs (getters et setters) emmerdent les développeurs, c'est bien connu
Et pourtant ils ont une importance primordiale car ils offrent sécurité et extensibilité. En effet, il est autrement plus simple d'ajouter des tests sur un setValeur() que sur un champ public que l'on a laissé "filé" dans la nature.
Mais comme souvent cette bonne pratique est contrée par des impératifs de performance. Le Getter ou le Setter, c'est un appel de fonction et donc un temps en plus qui va plomber l'application...
En vérité c'est bien mal connaître M. HotSpot, le compilateur temps réel de Java, qui va arranger tout cela aux petits oignons pour obtenir les résultats du graphique qui se passent de commentaires. L'argument performance ne tient ici pas la route, le résultat est strictement le même.
Les dimensions des tableaux
Encore une légende urbaine qui a la peau dure : plus un tableau a de dimensions, plus ça rame. Là aussi ce doit être un héritage que nous a laissé l'ancêtre C mais la réalité Java est tout autre. Le nombre de dimension n'a juste aucun impact sur les temps d'exécution. Donc autant faire rapidement un code propre plutôt que de se perdre dans des buffer[y*largeur+x] qui eux, coûtent du temps (celui du calcul de l'indice).
Chaînes de caractère
Comme nous l'avons vu plus haut, les String sont immutables, ce qui implique que lorsque l'on cherche à construire dans une boucle, une liste de 100 items séparés par des virgules, nous fabriquons en réalité au minimum 100 objets String. 200 même si les deux ajouts sont séparés. Autant dire que lorsqu'il s'agit de modifier une chaîne, comme l'illustre sans appel le graphique, il faut systématiquement passer par un StringBuffer qui est fait pour cela.
L'exemple suivant est plus étonnant. Il s'agit de formater une chaîne composée d'un entier, une espace et une autre chaîne. Je me suis longtemps fait avoir en imaginant que le très pratique String.format("%d %s", i, PATTERN) était plus performant que i+" "+PATTERN. Belle erreur comme en témoigne le graphique ou les deux derniers cas font appel à ce fameux formateur. La concaténation à l'ancienne est donc préconisée lorsque l'on a aucun formatage évolué à mettre en œuvre.
Cast et Generics
Les génériques étaient une évolution très attendue du JDK 1.5 permettant de typer un objet lors de son utilisation (Vector<Integer>). Il était donc intéressant de savoir si cette fonctionnalité avait un coût en terme de performance. Et le résultat semble être que non.
Et ce n'est pas si étonnant au fond car sans les génériques, nous étions obligés d'opérer un cast sur les valeurs d'un Vector par exemple pour pouvoir utiliser l'objet sous-jacent.
Avec les génériques ce cast est, un peu comme le C++, effectué à la compilation du code. Le gain entre les deux approches est donc à mettre au crédit de cet aspect comme en témoigne le second graphique qui mesure l'impact d'un cast sur le temps d'exécution.
Notez au passage que le cast classique (MonObjet)maValeur et par méthode MonObjet.class.cast(maValeur) ont le même coût.
Avec les génériques ce cast est, un peu comme le C++, effectué à la compilation du code. Le gain entre les deux approches est donc à mettre au crédit de cet aspect comme en témoigne le second graphique qui mesure l'impact d'un cast sur le temps d'exécution.
Notez au passage que le cast classique (MonObjet)maValeur et par méthode MonObjet.class.cast(maValeur) ont le même coût.
AutoBoxing
Lorsque l'on manipule les versions Objet de types de base, comme Integer, une optimisation qui est souvent évoquée est de replacer Integer i=new Integer(2) par Integer i=Integer.valueOf(2). Sur le graph, on se rend bien compte que la différence est, comment dire, assez négligeable... Mais ce qui est ici intéressant c'est que l'utilisation de l'AutoBoxing, c'est à dire quelque chose du genre Integer i=2, qui est une fonctionnalité apparue avec la version 1.5 de Java, est aussi rapide que les autres.
Conclusion
Ce qu'il faut conclure de tout cela est tout d'abord que la VM a énormément évolué, gommant beaucoup de problèmes de performances (synchronisation, Exceptions, etc.). Ensuite que le code offusqué n'est en rien plus performant qu'un code clair. Enfin, qu'outre deux trois fondamentales comme l'utilisation des buffers (String ou IO) ou l'utilisation des entiers, aucune optimisation n'est réellement efficace. La meilleure manière d'optimiser reste d'écrire un code le plus clair possible et de lasser HotSpot faire au mieux son travail. Doublé d'une bonne dose de bon sens, cela devrait permettre d'obtenir le maximum d'efficacité.
Juste une petite remarque, non pas sur les résultats mais sur le protocole : dans un benchmark, ça n'est pas la moyenne des temps d'exécution qu'il faut retenir, mais le résultat le plus faible.
En effet, l'exécution peut être ralentie par tel ou tel autre processus, mais ne sera jamais accélérée par un processus externe. Le résultat le plus proche du temps d'exécution optimal est donc celui qui s'est déroulé le plus rapidement possible, et non pas celui prenant en compte la moyenne de toute les perturbations.
Concernant les constantes j'ai pris l'habitude de les placer avant les variables quand je faisais du C++
if (NULL==maValeur) {
...
}
pour une raison simple: l'oubli du double égal est détecté des la compilation,
j'ai trop perdu de temps sur du débogage parce que j'avais claqué quelque chose comme ca:
if (maValeur=NULL){...qui passe tres bien a la compilation C++
Salut,
Tout d'abord, je tiens à te féliciter pour cet article très enrichissant. Par contre, pour les accès concurrents, j'aurais été intéressé par les performances obtenues avec les nouvelles structures de données comme ConcurrentHashMap par exemple, introduites en 1.5.
Aurais-tu fait quelque test là-dessus?
J.
@Chimrod, Tu marques un point là, et en même temps, une fois que c'est dis c'est tellement logique. Je vais modifier le framework de bench dans ce sens. Merci
@Fabien Ah oui, effectivement j'avais pas pensé à cette origine là. Ceci dit, l'habitude peut être perdue car java ne confondant pas les types de données, le seul cas où ça poserait problème c'est lorsqu'avec a, un boolean, tu ferais quelque chose du genre "if (a==true)" ce qui est assez rare comme approche
@Jérôme Pour l'instant je n'ai pas testé ces structures qui pourtant ne sont plus toutes jeunes. Je vais l'ajouter dans le test pour voir.
Suite à l'ensemble de vos remarques :
Bah difficile de perdre l'habitude.. l'air de rien du C++ j'en fais encore un peu :/
@FabienK je sais bien
Sur le point de vue des performances du langage lui même, je suis d'accord que Java est loin d'être lent. Par contre, dès qu'on utilise la librairie standard ça devient autre chose (en particulier avec Swing)... Par exemple, il suffit de comparer le temps d'ouverture d'une image avec ImageIO.read() et avec une autre librairie pour dire que oui Java ça peut être très lent.
Je suis d'accord pour dire que l'API Java Image I/O n'est pas encore au point...Après, il ne faut pas généraliser, c'est souvent le développeur qui est en faute, et/ou le codage des images respectant plus ou moins la norme.
Bonjour,
Félicitation pour cet excellent benchmark. J'ai justement une collègue qui n'arrivait pas à relire des parties de mon code à cause de certaines de ces pratiques héritée du C.
Serait-il possible d'obtenir le code source de ces tests ? Tu pourrais même en faire un site dédié, du genre http://shootout.alioth.debian.org/ mais sur les pratiques de programmation Java. Je pense que le web en aurait bien besoin.
Pour un projet perso, je dois effectuer des opération de permutation dans un tableau d'entiers à deux dimensions (aka une matrice d'entier), et je ne me suis pas encore décidé entre List<List<Integer>>, int[][] et int[x + y*width], mais pour l'instant int[x + y*width] est le candidat le plus probable car je me demandais comment dupliquer tout le tableau: il semble que pour List<List<Integer>> et int[][] on doive faire une instantiation à chaque tour de boucle du premier indice, alors qu'avec int[x + y*width] on peut tout faire en une seule fois.
^Nioub^
P.S.: je n'ai pas trouvé comment afficher correctement les < et > .
@3po c'est un peu charrier tout de même
Ca tombe bien, je prépare un 3ième benchmark entre C++, C# et Java. Pour charger un jpeg de 3840x1024 :
Java 6 : 550ms
gcc : 420ms
Alors ok, c'est un peu plus lent que du code natif (libjpeg6) mais quant je compare les 2 lignes pour charger une image avec ImageIO et les 20 lignes en C++, je fais vite le choix
Au passage, si je lance le même test avec JDK 4 (la première version avec ImageIO), le temps est de 882ms. Donc peut-être ton sentiment de lenteur date t-il de cette époque ?
Pour ce qui est de Swing, là aussi il y a eu du chemin de parcourus depuis JFC et le JDK 1.3. Aujourd'hui, j'ai des applications en Swing qu'il est délicat de distinguer en terme de réactivité de leur version GTK. Ceci dit, le problème de swing demeure avec une intégration moyenne avec le bureau et des effets de fond gris qui continuent d'exister.
@Nioub oui, je vais publier les tests dés que je les aurais affinés. J'attends de finir ceux qui évaluent c#/c++/Java comme je le disais plus haut. Maintenant je doute que cela devienne un shootout, en tout cas je vais essayer que cela ne soit pas le même bazaar en tout cas
Pour ce qui est de ton tableau, vu les différences de perf entre une copie itérative et System.arraycopy, je te conseille plutôt de prendre un tableau[][]. Les listes ne sont adaptées que si tu as des lignes à nombre de colonnes variables d'une ligne à l'autre, donc pas une matrice. Et le tableau à simple dimension va te faire payer tes opérations de ré-indexation. En plus si tu veux faire une copie optimisée d'un tableau à deux dimensions, tu peux mélanger iterratif et System.arraycopy
// Initialisation
int[][] t1 = new int[100][100];
for (int i = 0; i < 100; i++) {
t1[i][i] = i;
}
// Recopie de t1 dans t2
int[][] t2 = new int[100][100];
for (int i = 0; i < 100; i++) {
System.arraycopy(t1[i], 0, t2[i], 0, 100);
}
// Vérification de la copie
for (int i = 0; i < 100; i++) {
if (t2[i][i] != i) {
System.err.println("Erreur");
}
}
Tres bon article mais à propos du ++a et a++ en c, essaye ce code :
#include
#include
int
main(void)
{
int a =3;
printf("%i", a++);
return EXIT_SUCCESS;
}
Cela te retourne 3, remplace a++ par ++a et tu obtiendras 4.
@Poischack Ah mais je n'ai jamais dis que les deux notations étaient équivalentes
++x incrémente et renvoie; x++ envoie et incrémente
La seule chose que je veux dire c'est qu'utilisé tout nu, pour incrémenter un compteur de base sans en utiliser la valeur de retour, autant le mettre dans le bon sens.
Oki doki, je n'avais pas saisi le "tout nu".
Ceci dit, j'ai jamais compris à quoi servait se machin, même en mode "retour", si ce n'est pour rendre le code illisible.
Très bien ton bench, dommage de ne pas avoir eu un comparatif avec C/C++ avec se qui est comparable.
Pour l'utilisation du mot clé Final, c'est pour bloquer l'héritage de la fonction/class ?
@Armetiz Tu parles de ça : http://artisan.karma-lab.net/node/1112 ?
Fonctionellement oui, cela bloque l'héritage, et cela évite de prendre plus de ram avec une vtable innutile.
@Ulhume: C'est exactement de çà que je parlais
Un article d'IBM est récemment sorti: http://www.ibm.com/developerworks/library/j-benchmark1.html?ca=dgr-lnxw1...
Peut-être que tu y trouveras des idées à rajouter à ta plateforme de benchmark.
@Nioub vachement intéressant, merci !!
Le test sur les méthodes finales est peut-être faussé par HotSpot. En effet celui-ci transforme automatiquement presque toutes les méthodes dynamiques en finales. Il regarde l’arbre des classes chargées en mémoire, et en déduit que certaines méthodes dynamiques, peuvent être passées finales dans le contexte d’exécution actuel car elles sont uniques.
Es-tu certain d’en avoir tenu compte dans l’écriture de ton test ?
@sle Je te rejoins parfaitement là dessus et c'est justement ce que je cherche à prouver
En java, si je ne me trompe pas, l'utilisation de "final" dans le contexte d'une méthode, demande au compilateur d'inclure le code de la méthode "final" dans le corps de la méthode appelante (inlined). Ajouter un "final" pour beaucoup implique donc de meilleurs performances car cela permet de sauver quelques cycles d'horloge en évitant un appel supplémentaire. Maintenant depuis hotspot, le passage en mode "inlined" des methodes est déterminé dynamiquement à l'exécution et je ne pense même pas que le mot clef ait encore un impacte à la compilation (à vérifier cependant). Mon test cherchait juste donc à prouver qu'une méthode "inlined" et une autre "dynamique" ne changeait rien pour HotSpot en terme de performance.
En revanche, "final" reste un mot-clef utile pour des raisons de design du code, pour empêche toute surcharge.
Salut,
Intéressant tout cela... mais il serait bien mieux de voir les codes sources pour clarifier certains points...
Par exemple je n'ai pas trop compris le paragraphes sur les objets mutables et immutables !!! Surtout que c'est le caractères mutables des classes Point et Dimension qui oblige la création de copie en pagaille...
Concernant les variables locales, l'intérêt vient du fait qu'elles ne peuvent en aucun cas être utiliser depuis un autre thread, et la JVM est donc libre d'y faire toutes les optimisations qu'elles souhaites, ce qui n'est pas le cas des valeurs membres...
Pour les exceptions, les bloc try/catch n'ont effectivement quasiment aucun coût. C'est la génération de l'exception qui est couteuse (et de son stacktrace si utile). cf Exception & Performance
En ce qui concerne les méthodes finales, il faut reprendre l'objectif principal du terme, c'est à dire interdire la redéfinition de la méthode dans une classe fille.
Et grâce à cela la JVM a deux moyens d'optimisations :
Et c'est là qu'on retrouve la force du compilateur JIT de la JVM : il peut détexter cela tout seul selon le contexte, et considérer qu'une méthode virtuelle ne possède aucune redéfinition dans les classes filles chargées en mémoire, et permettre ainsi les mêmes optimisations.
Pour montrer cela il est intéressant de reproduire le même test 3 fois :
On s'aperçoit alors qu'on obtient les mêmes résultats dans les deux premiers test, mais pas dans le troisième...
Bref : il ne faut pas utiliser final "parce que c'est mieux", mais parce qu'on a besoin d'interdire les redéfinition !
a++
@adiGuba
Déjà pour les sources, tout est disponible ici :
http://svn.arnumeral.fr/subversion/public/technics-comparator/trunk/tech...
Pour ce qui est du Muttable/Immutable, je ne vois pas bien pourquoi un objet mutable implique une multiplication des copies de cet objet, pour en conserver la valeur ? Techniquement, je dis seulement que sur une itération, la création systématique d'objets immutable entraîne un coût non négligeable. Après je ne parle que de performance, pas de qualité logiciel
Pour les variables locales, une fois de plus, je ne cherche pas à en déterminer les avantages mais à répondre au mythe disant qu'elles sont plus "rapides" que les variables membre.
Pour les exceptions et les stacktraces ce n'est plus exactement le cas en réalité, du moins il me semble. Depuis la 1.5, le pile d'appel est maintenue nativement (Thread.currentThread().getStackTrace()
et simplement recopiée (nativement aussi, méthode Thread.dumpThreads).
Enfin pour "final" on est globalement d'accord.
Cool pour les sources ! J'essayerais de regarder cela quand j'aurais un peu de temps
En ce qui concerne les objets mutables, le problème vient du fait que cela casse la couche Objet, puisqu'on peut alors modifié une propriété (ou une partie d'une propriété) sans passer par la méthode set :
JComponent c = ...// Problème 1 : On récupère la taille et on la modifie sans passer par setSize() :
Dimension dim = c.getSize();
dim.height = 50;
// Problème 2 : On défini une taille via setSize(), mais on modifie cette taille par la suite :
Dimension size = new Dimension(50, 50);
c.setSize(size);
size.width = 50000;
Mais en fait il n'en est rien car les méthodes getSize()/setSize() font des copies de protections afin d'éviter ce style de problème, et ainsi éviter de partager les instances...
En gros, lorsqu'on utilise un objet mutable, on doit faire des copies de protections à chaque fois qu'on veut partager la valeur (pour éviter de partager l'instance). A l'inverse on peut très bien partager une instance d'objet immuable, car on est sûr de ne pas avoir d'effet de bord, mais on doit en créer une nouvelle instance à chaque fois qu'on la modifie...
Mais une chose est certaine : les classes Point et Dimension n'ont jamais été immuable !
Pour les variables locales : on est d'accord que l'accès est similaire en temps
Enfin pour le stacktrace cela c'est amélioré, mais c'est toujours "coûteux" (mais c'est relatif puisque cela n'est le cas qu'en cas d'une création d'exception, et dans ce cas le stacktrace est généralement très utile
)
a++
@adiGuba Alors c'est moi qui vais aller réviser, j'étais persuadé qu'à l'époque des JFC, le point était "immuable" (c'est une faute de frappe ou la bonne traduction, elle me plaît bien en tout cas
. Va falloir que je trouve les vieux sources de la chose pour vérifier cela... Si ce n'est pas le cas, désolé, c'est ma mémoire qui me joue des tours.
Maintenant je suis bien d'accord sur le fait qu'il ne soit pas malin d'utiliser la mutabilité dans un contexte partagé. Maintenant cela reste très intéressant comme approche dans une stratégie interne à un algo par exemple, c'était le sens de mon chapitre. Motivé je l'avoue par le nombre de fois où j'ai audité du code qui faisait des "new XY(x,y)" dans des algo pas du tout publique avec les impacts de performances que tu peux imaginer.
Pour le stacktrace, je suis allé me pallucher les sources du JDK histoire de ne pas trop dire d'âneries. Il semble que dans ThreadService.cpp de la JVM, la liste soit crée à partir d'un snapshot pré-existant. Je ne suis pas allé très loin mais en gros l'information existe, elle est juste mis en forme à grand coup de création de structure javaesque pour que le natif puise y être dumpé. Et cela en soit c'est couteux, je suis d'accord. Mais sur ma machine, la génération de 25000 piles de taille 25, prends 854ms, en 1676ms pour des tailles 50. C'est donc grosso-modo linéaire, et consomme 0.07ms pour la génération d'une pile de 50.
Maintenant cela n'est pas utile _que_ pour les Exceptions, perso j'utilise beaucoup les stacktraces pour mes traces. Il est sur que je perds du temps mais tant que je colle pas cela dans une boucle ou plus généralement dans un algo qui travaille à la ms, cela ne pose pas de problème vu le temps réel que cela coûte.
PS: pour les sources, tout avis est le bienvenu mais à l'origine c'est un jeu de test didactique que j'utilise pour expliquer le non interêt de certaines pratiques réputées "optimisantes" et qui nuisent à la qualité logiciel.
@adiGuba, intéressant ton article, j'ai rajouté un commentaire, 2 ans plus tard
))
Cool cet article, même si ça commence à dater, c'est loin d'être dépassé encore...Je me demandais, il y'a pas comme une petite incohérence dans certains graphiques ? Genre des libellés inversés pour les temps d'exécution ?
@Olivier
Dater ? Comment ça ?
Pour les incohérences c'est possible, je referais une passe là dessus lorsque le JDK7 sera officiellement out.
Je voulais dire que l'article date de juin, et que j'arrive après la guerre, désolé de m'être mal exprimé ^^!
J'ai un exposé à présenter prochainement sur les optimisations en Java, j'aimerais beaucoup pouvoir utiliser cet article, en vous citant bien sûr, cela vous poserait-il un souci?
Merci en tout cas,
@Olivier
Ah ok, je comprends mieux
Non aucun problème de mon côté, use et abuse, c'est fait pour cela. Et si tu as besoin de précisions, n'hésites pas.