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 [1].
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
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
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
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.
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
Position de la constante dans un test
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
En somme, si votre programme broute, ce n'est pas la peine de virer la gestion des exceptions...
Condition d'une boucle
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
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
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
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...
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
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
Getter ou pas getter
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
Chaînes de caractère
Cast et Generics
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.
Notez au passage que le cast classique (MonObjet)maValeur et par méthode MonObjet.class.cast(maValeur) ont le même coût.
Notez au passage que le cast classique (MonObjet)maValeur et par méthode MonObjet.class.cast(maValeur) ont le même coût.
AutoBoxing
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é.
- [1] http://www-128.ibm.com/developerworks/java/library/j-jtp02225.html?ca=drs-j0805