26 août 2016

Optimiser, oui mais comment ?

Design & Code

Grow

Together

26 août 2016

Optimiser, oui mais comment ?

Design & Code

Grow

Together

26 août 2016

Optimiser, oui mais comment ?

Design & Code

Grow

Together

Cet article fait suite à un premier billet qui présente mes préconisations pour réaliser des benchmarks permettant d'améliorer les performances ou limiter la consommation des ressources critiques dans son code ; je vous propose maintenant de nous concentrer sur les phases d'optimisation, de fuite mémoire et de profiling.

Optimisation de l’exécution

Une fois que l’on a fait des mesures et que l’on a identifié les fonctions qui nécessitent une attention particulière, il est nécessaire de faire une relecture de code poussée des parties impliquées. En effet, les données nous donnent une bonne indication de ce qui ne va pas, mais il faudra être imaginatif pour voir à implémenter autrement la zone critique en tenant compte du critère de performance.

En fonction des cas, plusieurs solutions sont possibles, à appliquer dans les fonctions traversées:

  • Changer l’algorithmique de la fonction pour en trouver une plus efficace. Pour pouvoir faire cela, il faudra déjà comprendre ce que la fonction réalise fonctionnellement pour voir si cela peut être effectué différemment.

  • Si une fonction est énormément appelée, voir si le nombre d’appel peut être diminué (voir si l’appel peut être « factorisé »). En effet l'appel à une méthode peut être très couteux. C'est d'autant plus vrai si cet appel est lié à un service distant.

  • Si une fonction est énormément appelée, voir si le contenu ne peut pas être optimisé, même un petit gain de quelques ms sera visible au final vu que c’est une fonction appelée très souvent.

  • Le rajout d’un cache peut être utile dans le cas d’opérations répétitives qui prennent du temps. Par contre, le premier appel d’une donnée qui n’est pas dans le cache sera tout aussi coûteux.

  • Il pourra aussi être intéressant d’analyser le bytecode effectivement généré par le compilateur java pour voir ce qui va réellement être exécuté. Pour cela il suffit de prendre le .class généré et de le déplacer dans la fenêtre des éditeurs d’Eclipse.

Optimisation de la consommation mémoire (permanente ou temporaire)

De même que pour l’optimisation de l’exécution, les mesures de performances effectuées seront nécessaires ainsi qu’une relecture approfondie du code impacté.

Pour limiter la consommation mémoire, il sera nécessaire de retravailler les structures de données voir l’algorithmique. En choisissant un algorithme différent, des structures de données différentes pourront être utilisées.

Voici quelques remarques générales qui pourront vous aider lorsque vous essaierez de limiter la consommation mémoire :

  • Utiliser les types de base (int, long, short, …) de préférence plutôt que les types Objet (Integer, Short, …). Ces types ne sont pas des objets et consomment juste les quelques octets (1 à 4 en général, voir 8 pour les long et double) servant à les stocker. Attention à l’auto-boxing/unboxing qui est automatiquement effectué par le compilateur (conversion automatique des types de base vers les types Objet : int<->Integer, short <-> Short, …). Il faut savoir que la plupart des structures de la JDK prennent des Objects en paramètre et même si un type de base est fourni, le compilateur pourrait automatiquement le transformer en Object (style Integer, …).

  • Utiliser la bonne structure de données. Il pourrait être souhaitable de créer la sienne si les classes « standards » sont moins efficaces : Par exemple, une LinkedList consomme beaucoup plus de mémoire qu’un ArrayList en général, car pour chaque objet stocké, un autre objet est créé pour faire les liens entre les éléments. Mais attention à l’ArrayList qui a un tableau en interne qui peut excéder les besoins et du coup faire une surconsommation. Initialiser l’ArrayList à la bonne « taille » peut s’avérer judicieux, surtout que cela évitera les redimensionnements internes.

  • Utiliser des « pools » d’objets. Cela permettra d’éviter une trop grande consommation d’objets temporaires (ayant une durée de vie limitée dans l’algorithme mais utilisés énormément). Par contre cela nécessite la création et l’utilisation explicite de ce pool d’objet dans tous les codes dépendants.

Recherche de fuite mémoire

La recherche de fuite mémoire est un peu différente des pratique que je viens de vous présenter. Car dans les cas précédents, le code marche bien et il est juste nécessaire d’améliorer les performances, alors qu’ici le code, bien qu’ayant l’air de s'exécuter correctement, comporte un problème à corriger qui est la fuite mémoire.

Une fuite mémoire est due au fait que certains objets restent référencés dans le système par erreur. Par exemple, un code, le temps de son traitement a besoin d’être à l’écoute de certains événements. Pour cela un addXXXListener est nécessaire et dès que le traitement est terminé, le composant est sensé faire un removeXXXListener. Si l'appel au removeXXXListener a été oublié, cela entrainera alors une fuite mémoire de cet objet mais aussi de tous les autres objets référencés. Cela peut se produire soit dans le cas nominal, soit dans certains cas aux limites ou cas d’erreurs. Un autre cas classique est l’ouverture d’une ressource en début de traitement mais sans sa fermeture (close) à la fin du traitement.

De même que pour les activités précédentes, un profilage (des objets en mémoire) sera nécessaire pour identifier les objets en cause. Sachant qu’à chaque exécution d’un code, il va générer toujours la même fuite, il suffit de faire des images des objets mémoires sur plusieurs exécutions successives (pour éviter d’être parasité par les objets temporaires qui seront garbage collectés, il sera souhaitable d’exécuter un GC avant de faire les mesures). Une fois ces images disponibles, il suffit d’identifier les objets dont le nombre augmente de manière linéaire en fonction des exécutions.

Une fois la liste identifiée, il reste à identifier le code qui crée cette fuite. Cette liste contiendra beaucoup d’objets « basiques » utilisés dans la plupart des codes (List, Map, Integer, tableaux, …) qui ne permettront pas de localiser le code en cause. Par contre, un de ces éléments sera en général moins basique et donnera une indication (par le nom de la classe) du package ou des classes en cause. Connaissant les objets qui ne sont pas normalement libérés, une relecture de code permettra d’identifier et de remédier au problème.

Attention : Il faut faire attention aux « faux positifs ». En effet, lors des analyses, il peut s’avérer que certaines parties du code consomment continuellement et de plus en en plus d’objets, mais cela peut être un fonctionnement normal. Par exemple, un code qui effectue du caching aura ce genre de comportement mais ce n’est pas un réel problème, c’est même probablement un comportement voulu dans le but d’améliorer les performances. En général et dans la mesure du possible, lors de la recherche de fuite mémoire, il est souhaitable de désactiver les caches.

Outils de « profiling »:

Pour pouvoir optimiser du code, il est nécessaire de connaitre les performances du code. Pour cela, il sera nécessaire d’utiliser des outils de profiling pour faire ces mesures.

Outils de « profiling » du JDK

De nombreux outils sont directement disponible dans les JDK récents (JDK 1.5 et supérieurs). La documentation d’Oracle concernant ces outils est disponible ici :

http://www.oracle.com/technetwork/java/javase/tooldescr-136044.html

Tous ces outils utilisent la JVMTI qui est l’interface offerte par la JDK pour monitorer l’exécution.

Voici une partie des outils disponibles et leur utilité :

  • HPROF - Heap Profiler -> profiler agent)

  • Java VisualVM -> GUI

  • JConsole Utility -> GUI: affiche et surveille l’évolution des informations globales (mémoire, thread, classes, ...) à l’aide de graphique

  • jdb Utility -> débuggeur en ligne de commande

  • jhat Utility -> permet de naviguer dans la topologie des objets à partir des d’une image de la « heap » : Cela permet de savoir pourquoi un objet est référencé et par qui. Cela peut être très utile lors de la recherche de fuite mémoire

  • jinfo Utility -> récupère les informations d'une jvm (propriétés diverses)

  • jmap Utility -> affiche des statistiques mémoires

  • jstack Utility -> affiche la stacktrace de tous les threads actifs. Très utile pour la recherche de deadlock

  • jstat Utility -> outil donnant des informations sur la mémoire et les GC qui s'effectuent

  • visualgcTool -> GUI: c'est la version graphique de jstat

Outils de profiling gratuit :

De nombreux outils gratuits existent pour profiler votre application et tous utilisent la même interface JVMTI fournie par la JDK. Par contre la plupart n’ont pas évolué depuis très longtemps.

  • Profiler4j (2006):

http://profiler4j.sourceforge.net/

  • JIP ou Java Interactive Profiler (2010) :

http://sourceforge.net/projects/jiprof/

  • JMH : cela permet d’utiliser des annotations pour profiler spécifiquement certaines parties du code. Par contre cela ne donnera que des informations relatives à la CPU.

Site : http://openjdk.java.net/projects/code-tools/jmh/

Tutoriel : http://soat.developpez.com/tutoriels/java/mesurer-performances-jmh/