Écrire des programmes performants en java

Partie 1 : Considérations générales

| MOUKOUOP NGUENA IBRAHIM | Ricky81 |

Cet article est le premier d'une série de trois articles consacrés à la performance des applications java.
Il traite de considérations générales applicables au niveau de l'algorithmique et de la programmation.

Commentez

- Titre : Écrire des programmes performants en java

- Auteurs : MOUKOUOP NGUENA IBRAHIM, Ricky81

- Parution : 13 décembre 2009

- Mise à jour : 13 décembre 2009

- Licence :

Sommaire

- I. A propos de l'auteur de cet article

- II. Introduction

- III. Les principales erreurs

- III-A. Vouloir dès le départ faire des programmes très performants.

- III-B. Vouloir simplement faire un programme qui marche.

- IV. Ecrire les programmes performants : les techniques algorithmiques

- IV-A. Rechercher dans la littérature

- IV-B. Choisir les structures de données adaptées

- IV-C. Choisir les algorithmes de tris adaptés

- IV-D. Réduire la création des objets

- IV-D-1. Quelques cas

- IV-D-1-a. Les exceptions

- IV-D-1-b. Les évènements

- IV-D-1-c. Les connexions

- IV-D-1-d. Les StringBuffer et les StringBuilder

- IV-D-1-e. Permettre la fixation des attributs en dehors des constructeurs

- IV-D-1-f. Utiliser les pools d'objets

- IV-D-1-g. Les collections

- IV-D-1-h. Les StringTokenizer

- IV-E. Utiliser les caches

- IV-E-1. Quelques exemples d'utilisation de caches

- IV-E-1-a. Cache des accès à Internet

- IV-E-1-b. La mémoire cache de l'ordinateur

- IV-E-1-c. Les pools de connexions.

- IV-E-1-d. Cache de stratégie de recherches

- IV-E-2. Problèmes posés par les caches

- IV-E-2-a. Problème d'efficacité du cache

- IV-E-2-b. Problème de pertinence du cache

- V. Ecrire les programmes performants : les techniques relatives à la programmation

- V-A. Utiliser la version la plus récente du jre

- V-B. Utiliser les profilers

- V-C. Bien initialiser les collections.

- V-D. Bien gérer les synchronisations

- V-E. Utiliser les threads

- V-F. Copier efficacement les tableaux

- V-G. Utiliser les buffers

- V-H. Faire les déclarations en dehors des boucles

- V-I. Accélérer le processus de libération de la mémoire

- V-J. Choisir les types de données adaptés

- V-J-1. Combiner les techniques

- V-J-2. Utiliser Serializable plutôt que Externalisable pour l'écriture des objets.

- V-J-3. Un exemple de cas de combinaisons de techniques : la bibliothèque megasoft.web de MDAL

- VI. Remerciements

- VII. Liens

I - A propos de l'auteur de cet article

Plus de dix années d'expérience de développement d'applications de tous types (applications Web, bases de données, calculs scientifiques, gestion, etc.). Expert en informatique (algorithmique, génie logiciel, bases de données, décisionnel, sécurité et fiabilité dans les logiciels de gestion, développement Java, PHP, etc), en gestion (gestion de projets, gestion efficace du temps, logistique, solides connaissances en paye, comptabilité, gestion commerciale, ressources humaines, etc.). Enseignant à (l'Ecole Polytechnique de Yaoundé) et principal architecte du framework MDAL qui permet de réduire jusqu'à 90% les coûts et les délais de développement, d'exploitation et de maintenance d'applications java orientées bases de données. Je suis disponible pour des missions sur site ou à distance concernant mes domaines d'expertise. Je peux apporter dans de très brefs délais et de manière très économique des solutions aux problèmes relevant de mon expertise. Me contacter : imoukouo@polytechcm.org

II - Introduction

Cet article est le premier d'une série de trois articles consacrés à la performance des applications java. Le premier traite de considérations générales applicables au niveau de l'algorithmique et de la programmation. Le second traite de la répartition des traitements dans les architectures n-tiers, ainsi que de l'optimisation des traitements au niveau des bases de données. Il y est question pour divers types de problèmes, de dégager tout d'abord les contraintes à prendre en compte au-delà de la performance, ensuite les principes devant orienter une répartition efficace des traitements, et enfin d'analyser l'optimisation de ces traitements dans le cas de la base de données et de son interaction avec le reste de l'application. Le troisième article complète les deux autres dans le cas particulier des applications J2EE.

D'entrée de jeu, il faut reconnaître que la performance d'un programme dépend de plusieurs facteurs cités ici dans un ordre quelconque : 1) qualité du matériel et du réseau informatique, 2) manipulations et requêtes formulées par les utilisateurs, 3) qualité du design de la base de données et des tables, 4) performances du SGBD 5) performances du serveur d'applications 6) performances des drivers d'accès aux bases de données et des outils de mapping OR utilisés, 7) performances des bibliothèques utilisées pour le développement des programmes clients, 8) architecture du logiciel et répartition des traitements, 9) algorithmes utilisés dans les traitements, 10) choix effectués pendant la programmation. Tout au long des trois articles, nous passerons en revue tous ces aspects.

Il est important de noter que dans les programmes, la performance n'est pas une fin en soi. Ce n'est qu'un moyen. Aujourd'hui, les postes de travail sont très puissants. De ce fait, le plus souvent un programme s'exécutant avec un seul utilisateur connecté offrira une vitesse suffisante, même avec de mauvais algorithmes (du point de vue performance).

Malheureusement, ils existent bel et bien, les cas où on a besoin d'algorithmes performants. Cela peut être dû au fait qu'on écrit un programme serveur (servlet, ejb, etc.) qui devrait supporter une charge de plusieurs clients. Cela peut également être dû à la nécessité de manipuler d'énormes bases de données, ou de faire des calculs complexes nécessitant l'examen d'un très grand nombre de cas. De tels cas sont très courants dans les problèmes d'optimisation.

Nous laissons le soin au lecteur de découvrir dans les cours d'algorithmique comment écrire de bons algorithmes et comment calculer leur complexité. Nous allons certes revoir ici quelques principes d'algorithmique, mais nous nous plaçons surtout dans le contexte de " comment faire des programmes performants en java. "

Comme je l'ai dit plus haut, la performance n'est pas une fin en soi. Aussi, il est en général conseillé aux programmeurs de ne pas essayer du premier coup d'écrire des programmes performants, mais plutôt des programmes qui marchent. Je pense qu'en fait il y a deux extrêmes qu'il faut éviter. Le premier est de vouloir dès le départ faire des programmes très performants. Le second est de vouloir simplement faire un programme qui marche.

III - Les principales erreurs

III-A - Vouloir dès le départ faire des programmes très performants.

C'est une erreur qui pose au moins trois problèmes. Le premier problème est qu'on perd beaucoup de temps à essayer de trouver les meilleurs algorithmes, pourtant le temps est réellement ce qui nous manque le plus. De plus, les meilleurs algorithmes sont souvent très complexes et subtils, et ils deviennent d'autant plus difficiles à déboguer. Par exemple, n'importe quel jeune programmeur peut facilement comprendre le tri par sélection et le déboguer s'il ne marche pas. Mais pour le tri rapide, c'est une autre histoire. Le gain apporté par un meilleur algorithme ne justifie pas toujours l'effort déployé pour y arriver.

Le deuxième problème est que les algorithmes les plus rapides pour un problème donné sont ceux qui exploitent au mieux toutes les spécificités de ce problème. De ce fait, ils ont une nature assez figée et ne peuvent être utilisés en l'état pour d'autres problèmes. Il est donc difficile de réutiliser le code des meilleurs algorithmes. Pourtant, dans bon nombre de cas, il sera plus important de pouvoir réutiliser le code que d'atteindre certains niveaux de performance.

Le troisième problème est que pour pouvoir réellement optimiser, il faut savoir où se perd le temps. C'est une information qu'on peut avoir en analysant l'algorithme, mais c'est une information qu'on a surtout en exécutant l'algorithme. Par exemple, en analysant un algorithme, vous pouvez avoir une idée vous permettant d'exécuter 100 fois plus vite une portion de cet algorithme. Vous faites alors de grosses modifications pour y arriver. Si cette portion s'exécutait avant en 1ms, en allant 100 fois plus vite, vous aurez gagné moins d'1ms. Si elle est appelée une seule fois dans votre programme, on peut considérer qu'en fait vous n'avez rien gagné.

Est-ce à dire qu'il ne faut pas se soucier au départ de la performance ? Non.

III-B - Vouloir simplement faire un programme qui marche.

Personnellement, j'ai plusieurs fois dû réécrire des programmes parce que dès le départ je voulais juste faire ce qui marche. J'ai réellement obtenu des programmes qui marchaient, mais qui étaient si lents qu'ils étaient au final inutilisables. En réécrivant, j'ai mis en place des optimisations simples auxquelles j'avais pensé dès le départ, et qui n'ont pas vraiment compliqué le programme final.

Vous pourrez ainsi avoir souvent à réécrire, si dès le départ vous n'essayez pas de faire bien. Le temps et le coût liés à ce choix de départ sont suffisamment importants pour qu'on puisse réellement le déconseiller.

C'est là toute la nuance. Il ne s'agit pas de faire au mieux dès le départ, mais de faire bien. Ecrire des programmes performants sans qu'ils soient forcément les meilleurs programmes. Nous ne cherchons pas la perfection, mais l'efficacité.

IV - Ecrire les programmes performants : les techniques algorithmiques

IV-A - Rechercher dans la littérature

La première technique consiste naturellement à rechercher s'il n'y a pas dans la littérature ou dans les programmes existants une solution simple et performante pour votre problème. Vous pourrez ainsi trouver des algorithmes que vous vous contenterez de programmer ou qui seront même déjà programmés.

IV-B - Choisir les structures de données adaptées

Si vous devez absolument écrire un algorithme, choisissez les structures de données adaptées pour manipuler les collections. L'API de java (collections) fournit pas mal de collections que vous pouvez utiliser dans vos programmes. Vous gagneriez donc à apprendre à vous servir de ces collections. Nous donnons ici juste quelques considérations générales pour leur choix :

Votre problème

Structure adaptée

Recherches d'après une clé (nom, adresse, matricule, etc). Pas besoin de gérer l'ordre des éléments dans votre structure

Hashmap,Hashtable

Recherches d'après une clé. Nécessité de connaître l'ordre d'entrée des éléments, mais pas de réorganisation de la structure.

Hashmap+ArrayList (ou Vector)

Recherches d'après une clé. Nécessité de pouvoir accéder à l'élément le moins récemment obtenu par la clé (ou inséré)

DoubleLinkedHashMap ou (hashtable+DoubleLinkedlist) ou cachelru

Recherches et insertions multiples avec nécessité de conserver un ordre

TreeSet

Recherches par position, insertion en fin de structure

ArrayList, Vector

Insertions n'importe où, pas de recherches

Listes

Représentation de grosses matrices ayant la plupart des termes nuls

Utiliser les sparsematrix dans les bibliothèques adaptées (colt, mtj,or,?) ou représenter vous-même vos matrices selon l'un des formats standards des matrices creuses : (voir netlib) ou créer un format personnalisé (vous pouvez utiliser les tables de hachages, combiner les listes avec les tableaux, utiliser les tableaux de tableaux).

Recherche par position, pas d'insertions nécessaires, taille de la structure définie à sa création

tableaux

Tableau devant représenter des options de choix booléens (vrai ou faux)

BitSet

Utiliser les BitSet pour réduire l'occupation mémoire, lorsque cela est possible. Comme exemple, nous allons rapidement voir comment utiliser les BitSet dans la simulation d'un problème appelé "problème de la famille". L'énoncé de ce problème est le suivant. Une famille comprend n personnes qui s'assoient une fois par jour autour d'une table de n chaises, en choisissant chaque fois les chaises parfaitement au hasard. En moyenne après combien de jours chaque membre se sera assis sur toutes les chaises ? Dans une tentative de résolution du problème par simulation, on choisit d'associer à chaque membre un vecteur de n entiers. La position i du vecteur aura la valeur 1 si le membre s'est déjà assis sur la chaise i et 0 sinon. La simulation s'achève lorsque le vecteur de chaque membre a la valeur 1 à toutes ses positions.

Il est évident qu'utiliser un BitSet à la place d'un vecteur ferait économiser énormément d'espace, puisqu'au final l'espace utilisé correspondrait à n^2 bits plutôt qu'à n^2 entiers.

P.S. Une résolution naïve de ce problème par simulation conduit à une solution qui s'exécute au moins en O(n^3), pourtant, il est bien possible de faire beaucoup mieux. Notons ici qu'il est possible de combiner plusieurs structures de données selon les besoins. Cela implique que chaque objet manipulé soit inséré dans chacune des structures de données. A priori, on pourrait penser à une duplication des données. Il n'en est rien. Java manipule les références des objets. Ainsi, lorsque vous placez un objet dans un vecteur et dans une table de hachage, seules les références à cet objet y sont placées. L'objet n'est pas dupliqué. De ce fait, toute manipulation faite sur l'objet à partir d'une structure apparaît immédiatement à celui qui accède à l'objet à partir d'une autre structure, car toutes les structures font référence au même objet.

IV-C - Choisir les algorithmes de tris adaptés

Dans plusieurs problèmes, on est amené à faire des tris. L'Api standard java fournit un tri par fusion stable et performant, s'exécutant en O(n ln(n)). Ce tri peut être utilisé pour la plupart des cas. Néanmoins, dans certains problèmes où les tris sont très récurrents, il est parfois possible d'utiliser des tris plus efficaces. On pourra donc penser au tri par dénombrement, au tri par paquets ou au tri par base qui sont des tris linéaires.

Par exemple, supposons que l'on ait à trier les individus d'une population d'étudiants d'après leur âge. On peut raisonnablement supposer que l'âge d'un étudiant est compris entre 15 et 100 ans. Pour ce cas, le tri par dénombrement se révélera particulièrement efficace.

On peut également avoir à faire à un programme sur un graphe ayant un très grand nombre de noeuds, chacun ayant un nombre de voisins n'excédant pas un entier relativement faible (40 par exemple), et on s'intéresse pour certains traitements aux noeuds ayant le plus petit nombre de voisins. Dans certains cas, il peut être utile de trier les noeuds selon le nombre de leurs voisins. L'utilisation d'un tri linéaire ici sera très bénéfique si l'opération de tri se fait souvent.

IV-D - Réduire la création des objets

En java, la création d'un objet est une opération coûteuse en temps. Aussi, dans la mesure du possible, il est recommandé de créer le minimum d'objets possibles. On fera le maximum d'efforts pour réutiliser les objets. Notons qu'il ne s'agit pas de créer le minimum de classes possibles. Un objet est créé par l'appel à un constructeur ou à une méthode qui en appelle un. Déclarer un objet ou écrire une nouvelle classe n'est pas la création d'objets. Il s'agit donc essentiellement de minimiser le nombre d'appels à l'opérateur new dans le programme.

IV-D-1 - Quelques cas

IV-D-1-a - Les exceptions

Plutôt que de propager les Exceptions dans votre code en faisant chaque fois throw new Exception(msg), vous pouvez simplement écrire un code semblable à celui-ci :

/*déclaration d'une variable quelque part dans la classe du type de l'exception à propager s'il y a erreur*/
Exception badValue = new BadValueExeption() ;

/**
* cette méthode est censée propager une exception du type badvalueException
*/
public void testValue throws BadValueExeption {

//ici on a constaté une erreur et on va propager l'exception ayant le message msg
badValue.setMessage(msg) ;/*si cette méthode n'existe pas, écrivez la dans la classe BadValueException*/
throw badValue ;
/*magique ! on ne crée pas un nouvel objet, même si cette erreur survient 1000 fois.
Naturellement si on doit gérer les accès concurrents, il peut être nécessaire d'utiliser les caches
pour éviter de mélanger les messages destinés à des utilisateur différents (voir caches plus bas)*/
}

Note : l'approche présentée ici à l'inconvénient de faire que la trace de l'exception ainsi propagée ne vous est que de très peu d'utilité, car elle ne contient pas la pile des appels ayant conduite à l'exception.

IV-D-1-b - Les évènements

La création des évènements dans le modèle de java peut être très coûteuse en temps. Aussi, lorsqu'on veut faire des traitements de masse sur des objets auxquels on a affecté des auditeurs d'évènements, si on n'a pas besoin pour de tels traitements de faire fonctionner les auditeurs, il convient tout d'abord de les désactiver, pour éviter une création d'un grand nombre d'objets évènements, ce qui aura pour fin de ralentir le programme et saturer la mémoire. La désactivation peut se faire par une méthode prévue à cet effet ou bien en enlevant l'auditeur avant le traitement pour le remettre après.

Par exemple, si vous utilisez jbuilder et que vous voulez parcourir un dataset qui contient plusieurs enregistrements (en vous contentant de lire ces enregistrements ou d'effectuer des traitements qui ne devraient pas impliquer un travail de la part des auditeurs liés aux datasets), il convient de procéder de la manière suivante :

//notre dataset s'appelle ds
ds.enableDataSetEvents(false) ;// on annule ainsi la création et la propagation des évènements
try{
makeAllProcesses() ;//on écrit tous les traitements à faire (boucle while,etc)
} catch(Exception exp) {
//traiter l'exception. Si necessaire, la propager par throw exp ;
} finally {
enableDataSetEvents(true) ;//remettre le dataset dans son état normal
}

IV-D-1-c - Les connexions

Voir l'utilisation des caches plus loin dans ce cours, ou la mise en pool des connexions dans le cours sur jdbc.

IV-D-1-d - Les StringBuffer et les StringBuilder

Imaginons un objet avec une fonction toString.

public String toString(){
StringBuffer bf = new StringBuffer();
//traitement de remplissage du buffer
//?.
//renvoi du resultat
return bf.toString();
}

Si cette fonction va être appelée plusieurs fois sur le même objet, elle peut être améliorée de la manière suivante :

StringBuffer bf = new StringBuffer();
public String toString(){
bf.setLength(0);
//traitement de remplissage du buffer
//?
//renvoi du resultat
return bf.toString();
}

La différence ici est que l'objet StringBuffer a été créé une seule fois, et par la suite on se contente de l'utiliser.

IV-D-1-e - Permettre la fixation des attributs en dehors des constructeurs

Idéalement, chacune de vos classes devrait avoir un constructeur sans arguments (elle peut en avoir d'autres acceptant des arguments bien sûr). Ainsi, les utilisateurs de ces classes devraient pouvoir en fixer les propriétés par les méthodes getXXX ou setXXX. Grâce à cela, un objet peut être créé, puis réutilisé par le changement de ses propriétés. Dans certains cas, il pourrait y avoir la méthode init pour réinitialiser l'objet. Ne pas permettre une telle fixation oblige les utilisateurs de vos classes à faire plusieurs créations d'objets.

La liste n'est pas exhaustive et ces exemples devraient juste vous inspirer.

IV-D-1-f - Utiliser les pools d'objets

Il s'agit ici d'une collection dans laquelle vous stockez les objets avec lesquels vous travaillez. Quand vous avez besoin d'un objet, vous le demandez au pool. S'il y un objet inutilisé, il vous le passe. Sinon, il crée un nouvel objet et vous le passe. Lorsque vous avez fini, vous remettez l'objet au pool pour qu'il puisse en disposer pour d'autres ou tout simplement le détruire.

En effet, vous devez limiter le nombre d'objets que le pool peut contenir, car autrement il saturerait la mémoire avec des objets stockés et inutiles. Il suffirait d'une forte sollicitation pendant un moment, ce qui l'amènerait à créer un grand nombre d'objets. Si ensuite il n'est plus sollicité au même niveau, plusieurs objets vont rester dans le pool et occuper inutilement la mémoire.

Vous disposez dans la bibliothèque MDAL d'une classe Pool qui vous permet de gérer les pools d'objets.

IV-D-1-g - Les collections

Plusieurs collections disposent de méthodes permettant de les vider (clear, empty,etc). Lorsqu'il est nécessaire de travailler sur plusieurs objets collections, il vaut mieux lorsque cela est possible utiliser un seul objet et le vider pour le réutiliser (comme cela a été fait ci-dessus avec le StringBuffer), plutôt que d'en créer plusieurs.

IV-D-1-h - Les StringTokenizer

Utiliser indexOf et Substring est plus rapide que d'utiliser un StringTokenizer lorsque cela est applicable.

IV-E - Utiliser les caches

On pourrait définir un cache comme un objet dans lequel on stocke le résultat d'une opération coûteuse en temps, afin de ne pas avoir à reprendre cette opération si on avait à nouveau besoin du dit résultat. En général, un objet contenu dans un pool est censé être utilisé de manière exclusive par celui qui le prend du pool, tandis que les objets mis dans un cache doivent pouvoir simultanément être accédés par autant de clients que nécessaire.

Vu que dans plusieurs problèmes, il y a toujours des opérations qui reviennent, les caches sont l'un des moyens les plus performants pour améliorer la performance des systèmes informatiques.

IV-E-1 - Quelques exemples d'utilisation de caches

IV-E-1-a - Cache des accès à Internet

Utilisé par un navigateur, c'est un objet (ou un espace) dans lequel sont stockés des fichiers récemment sollicités sur Internet (pages web, images,etc). Lorsque l'un de ces fichiers est à nouveau sollicité, il est directement récupéré dans le cache, ce qui évite tout le temps nécessaire pour le télécharger depuis internet. Un tel cache possède souvent deux niveaux : un espace en mémoire et un espace sur le disque. L'accès en mémoire étant plus rapide, on peut ainsi gagner du temps.

IV-E-1-b - La mémoire cache de l'ordinateur

Elle permet un accès plus rapide que la mémoire centrale aux données récentes et par conséquent permet de gagner du temps.

IV-E-1-c - Les pools de connexions.

La création des objets est une opération coûteuse en temps, elle l'est encore plus lorsqu'il s'agit de connexions (connexion à une base de données par exemple). Aussi, pour gagner du temps, une stratégie consiste à conserver des connexions qui ont déjà été créées, de manière à pouvoir s'en servir pour d'autres requêtes.

IV-E-1-d - Cache de stratégie de recherches

Imaginez un programme qui fait des recherches combinatoires de solutions à un problème. On pourrait citer des jeux, mais pour faire plus sérieux nous citerons le problème de découpe du verre. On cherche comment découper des plaques de verre dans des plateaux en stock pour fabriquer un ouvrage. Dans un tel problème, on peut imaginer qu'on coupera 3 plaques dans le premier plateau, puis calculer l'ensemble des coupes possibles du second plateau. Si on ne trouve pas de solution satisfaisante au problème, on peut considérer plutôt la possibilité de couper 4 plaques dans le premier plateau. On ne souhaiterait pas refaire le calcul de l'ensemble des coupes possibles du premier plateau. Aussi, il serait bien de stocker ce résultat dès qu'on l'a obtenu pour la première fois.

De manière générale, dès qu'on peut être appelé à réutiliser le résultat d'une opération longue, il faut sérieusement envisager la possibilité de stocker ces résultats dans un cache.

IV-E-2 - Problèmes posés par les caches

IV-E-2-a - Problème d'efficacité du cache

Le cache se sert de la mémoire pour gagner du temps. Or, la mémoire n'est pas infinie. Il devient à un moment nécessaire de décider de ce qui peut être mis dans le cache ou de ce qui doit en sortir lorsqu'il est plein et qu'il y a un nouvel objet à y insérer.

Plusieurs stratégies existent pour la gestion du cache : LRU : least recently used (moins récemment utilisé) LFU : least frequently used (moins fréquemment utilisé). FIFO : first in fist out (premier entré, premier sorti) LIFO : last in, first out (dernier entré, premier sorti) RANDOM : gestion aléatoire du cache Custom : toute stratégie particulière obéissant à d'autres critères que celles ci-dessus.

Le problème de gestion du cache consiste à trouver la stratégie permettant au cache d'améliorer au mieux l'efficacité d'un programme. Le problème est d'éviter de sortir du cache des résultats coûteux en temps qui seront immédiatement sollicités après (on doit alors reprendre l'opération qui a permis de les obtenir) ou de mettre dans le cache un objet qui ne sera pas souvent sollicité. Naturellement, on peut mathématiquement représenter ce problème. Je laisse ce soin aux matheux.

Notons juste que si vous avez à gérer les caches, il a été observé que des stratégies ci-dessus citées, LRU et LFU donnent en général de meilleurs résultats. Il faut noter que la stratégie LFU étant basée sur la fréquence d'utilisation pose le problème de l'instant à partir duquel cette fréquence est comptée, ce qui complique un peu sa mise en oeuvre.

LRU étant très simple à mettre en oeuvre de manière performante (eh oui, la gestion du cache peut devenir elle-même une source de baisse de performances), vous pourrez l'utiliser. A partir du jdk1.4, l'objet DoubleLinkedHashMap permet de le faire.

IV-E-2-b - Problème de pertinence du cache

Pour certains problèmes, il peut arriver que les données du cache ne reflètent plus la réalité. Par exemple, une page web mise en cache peut différer de la version actuelle de la page. Des résultats d'une requête, mis en cache peuvent ne plus être exacts si la base de données a changé. Lorsque cela peut arriver, il sera nécessaire de mettre en place une stratégie de détection automatique des modifications de l'original afin de mettre à jour le cache (demander par exemple la date de dernière modification d'un fichier et comparer à ce qui est dans le cache), ou toute autre stratégie permettant de mettre dans le cache des données récentes.

Une bonne gestion de caches sera certainement l'une des meilleures sources de performances d'un programme.

V - Ecrire les programmes performants : les techniques relatives à la programmation

V-A - Utiliser la version la plus récente du jre

V-B - Utiliser les profilers

Le profiler est un outil qui permet de suivre l'exécution d'un programme et par conséquent de déterminer quel temps le programme a passé dans chaque méthode. Grâce au profiler, on détermine rapidement les 20% du code dans lesquels le programme passe 80% de son temps, et on peut mettre en place une stratégie efficace d'optimisation basée uniquement sur cette partie du code.

Java offre un profiler intégré. Pour savoir comment vous en servir, utiliser la commande :

java -Xrunhprof:help

ou la commande

java -prof:help

Un exemple d'utilisation de cette commande est

java -Xrunhprof:cpu=samples,depth=6 com.develop.demos.TestHprof

où depth=6 indique une pile de profondeur 6. Par défaut, les sorties du profiler vont dans le fichier java.hprof.txt. Vous y trouverez un tableau donnant le pourcentage du temps passé par le programme dans chaque partie. Vous pouvez plus aisément exploiter ce fichier en l'important sous Excel. Il existe également de nombreux outils sur le marché spécialisé dans le profiling (les profilers). Ils vous aideront à la fois à identifier les parties qui prennent le plus de temps, mais également celles qui occupent le plus la mémoire. Pour en savoir plus, visiter java performance tuning. A défaut d'utiliser un véritable profiler, la méthode System.currentTimeMillis() est utile pour savoir quel est le temps présent. La différence de valeur entre deux appels de cette méthode définit le temps écoulé entre ces deux appels.

V-C - Bien initialiser les collections.

Les collections dynamiques de java (Vector, ArrayList, StringBuffer,etc) utilisent très souvent en interne des tableaux, qui sont redimensionnés chaque fois que la collection est pleine. Lorsque vous utilisez un constructeur vide pour créer ces collections, ce tableau initial est créé avec une taille dépendant de la collection (consulter l'api java pour savoir). Chaque fois que la collection est pleine, un nouveau tableau est créé avec une taille deux fois supérieure à celle de l'ancien, puis les données sont recopiées de l'ancien vers le nouveau. Ce processus peut être couteûx en temps, et si vous avez une idée de la taille maximale que votre collection aura, il convient de l'éviter en créant directement une collection de cette taille (surtout si vous êtes sûr que dans la quasi-totalité des cas votre collection finit par atteindre cette taille). Sinon, utilisez la taille maximale moyenne que votre collection atteint.

// Ainsi, si vous voulez par exemple créer un vecteur qui contiendra 10000 éléments, évitez de le créer avec
Vector v = new Vector() ; /*ceci risque vous entraîner des dizaines de recopies et ralentir d'autant votre programme*/
// Créez le plutôt avec
Vector v = new Vector(10000) ;// aucune recopie ne sera faite.

/* Si en fait le vecteur n'atteint que très rarement les 10 000 éléments,
mais que sa taille moyenne se situe autour de 4000 élements, on utilisera */

Vector v = new Vector(4000) ;
/* il arrivera de faire des recopies, mais on évitera de trop souvent perdre inutilement de l'espace.
Si le facteur le plus critique c'est le temps, il n'est pas sûr que Vector(10000) soit plus rapide,
car il faut prendre en compte le temps de création d'un vecteur. */

V-D - Bien gérer les synchronisations

Le mot clé synchronized peut ralentir les threads en obligeant certains à attendre alors que d'autres ne font pas des traitements qui les gênent. Voir le cours sur les threads pour savoir comment optimiser l'utilisation de ce mot pour les performances.

V-E - Utiliser les threads

Même sur un seul processeur, l'utilisation des threads peut améliorer les performances globales d'un programme s'il y a un grand nombre d'accès sur le disque dur, sur le réseau ou à l'écran. Ainsi, lorsqu'un thread fait l'une de ces opérations, il libère le processeur qui peut alors être utilisé par un autre thread.

V-F - Copier efficacement les tableaux

Supposons que l'on ait les tableaux indices et temp, et que l'on veuille copier nb éléments du tableau indices dans le tableau temp, en les prenant à partir de la position a. On pourrait procéder par la boucle suivante :

for(int j=a ; j<a+nb ; j++)    indices[j] = temp[j];

Il est plus rapide d'écrire :

System.arrayCopy(temp, a, indices, 0, nb);

De manière générale, Pour effectuer la copie d'un tableau dans un autre, utiliser System.arrayCopy permet d'aller plus vite.
De même vous pourrez transformer vos collections en tableau du type qu'ils contiennent par la méthode toArray (collection) (A partir du jdk 1.5)

V-G - Utiliser les buffers

Pour concaténer les chaînes de caractères, utiliser la classe StringBuffer.

// Plutôt que de faire par exemple
String s = " a " ;
int n = 100 ;
for(int i=0 ; i<n ; i++) s+="a";

// faire :

StringBuffer bf = new StringBuffer(101) ;
/*  bien initialiser les collections à la taille convenable pour éviter les recopies*/
bf.append("a");
for(int i=0 ; i<n ; i++) bf.append("a");

L'utilisation de StringBuffer est recommandé lorsque l'objet peut être manipulé par plusieurs Threads. Lorsque l'objet est manipulé par un seul Thread (ce qui est très souvent le cas), il est plus convenable d'utiliser la classe StringBuilder (depuis le jdk 1.5). Cette classe, à la différence du StringBuffer n'est pas synchronisée et est par conséquent plus rapide. Mais si l'objet StringBuilder doit être accèdé par plusieurs Threads, il vaut mieux utiliser StringBuffer.

Pour le flux d'entrées/sorties, utiliser les classes BufferedReader, BufferedOutputStream et toutes les autres classes utilisant des buffers. Rappelez-vous que la taille initiale de votre Buffer est capitale (de préférence une puissance de 2 pour la lecture des flots d'entrée/sortie). Pour en savoir plus : JDCTechTips.

Pour des manipulations sur les fichiers, l'API java.nio permet d'obtenir des performances supérieures à celles de java.io dans pas mal de cas. Pour en savoir plus : nio le magazine de developpez.com de juin 2007 donne également des informations intéressantes sur l'utilisation des classes de java.nio.

V-H - Faire les déclarations en dehors des boucles

for(int i=0 ; i<n ; i++){
double y=2*i+4
//??
}
//Faire
double y;
for(int i=0 ; i<n ; i++) {
y=2*i+4
//??
}

V-I - Accélérer le processus de libération de la mémoire

Lorsque vous ne devez plus vous servir d'un objet, mettez immédiatement la valeur cet objet à null. Le garbage collector libérera ainsi rapidement la mémoire occupée par cet objet. Vous pouvez également déclencher l'action du garbage collector afin de libérer la mémoire des objets inutilisés par le code suivant :

r = Runtime.getRuntime();
r.gc();

Écrit dans un thread spécial que vous aurez créé pour la circonstance (et non dans votre programme principal).

Cela empêche la mémoire d'être saturée par des objets inutiles. Mais à utiliser avec modération, car le Garbage Collector (GC) est un processus et son exécution demande également les ressources de la machine. Normalement, vous n'avez pas besoin d'appeler explicitement le GC dans votre programme, car il sait bien faire son boulot. Néanmoins, dans certains cas de création très rapide d'objets, nous avons observé que le GC n'éliminait pas toujours à temps les objets passés à null, ce qui conduisait à une saturation de la mémoire. On peut supposer que cela est dû à la très faible priorité du GC par rapport aux autres processus. En présence de tels cas, il peut être nécessaire non seulement de forcer l'appel au GC, mais également de marquer une petite pause dans le processus de création des objets, le temps pour le GC d'en éliminer suffisamment pour que la mémoire ne soit pas saturée par une création rapide d'objets. Dans le cas de tels programmes, il peut être nécessaire de contrôler la quantité de mémoire encore disponible par un appel à la méthode freeMemory() de la classe Runtime (Runtime.getRuntime().freeMemory();) qui renvoie la quantité de mémoire disponible pour la JVM, avant d'appeler le GC si elle est en dessous d'un seuil critique.

Pour les optimisations que vous pouvez faire au niveau du garbage collector, vous référer à JdcTechTip GC

Pour agrandir la quantité de mémoire disponible pour votre programme java, utiliser pour le lancement la commande

java -Xms1024M

Par exemple, pour 1Gb de mémoire (1024Mb), dans le cas d'un lancement par jlnp, utiliser les variables initial-heap-size et max-heap-size pour définir les tailles respectivement initiale et maximale de la mémoire à utiliser par le programme. Cela devrait donner une ligne semblable à celle ci-dessous dans le jnlp.

<j2se version = "1.5+" href = "http://java.sun.com/products/autodl/j2se" initial-heap-size = "256m" max-heap-size = "512m"/>

V-J - Choisir les types de données adaptés

Ne pas prendre des entiers longs là où les entiers simples seraient suffisants ou les doubles là où les réels suffiraient.

V-J-1 - Combiner les techniques

Un exemple de cas intéressant où sont combinés les StringBuffers et les caches est donné : ici Voir OPTIMIZING STRING PERFORMANCE

V-J-2 - Utiliser Serializable plutôt que Externalisable pour l'écriture des objets.

Pour en savoir plus

V-J-3 - Un exemple de cas de combinaisons de techniques : la bibliothèque megasoft.web de MDAL

Génération du code html

Cette bibliothèque est destinée à servir les pages web via les servlets. Elle est constituée d'objets de type HtmlElement. Chacun de ces objets en contient d'autres, qui peuvent eux-mêmes contenir des objets. Le code html d'un objet qui est renvoyé par le navigateur au client est obtenu simplement en concaténant les codes html de tous les objets qu'il contient. La méthode utilisée pour l'obtenir est la suivante :

/**
* place dans le buffer le code html correspondant à cet element
* le StringBuffer utilisé a été crée une seule fois dans l'élément au sommet de la hiérarchie,
* il est passé aux fils afin dene pas recréer des objets stringbuffer
*/
public void getCode(StringBuffer bf) {
int j = 0;
bf.append(getBeginTag());
int k = getNbItems();
//l'element courant construit le code de tous ses fils en leur passant le buffer qu'il a reçu en //paramètre
for(int i=0 ; i< k ; i++) get(i).getCode(bf);
bf.append(getEndTag());
}

Comment sont créés les objets de la bibliothèque ? Plusieurs classes existent : Text, HtmlGrid, HtmlCell, HtmlRow, HtmlChoice,etc. En général, les constructeurs de ces classes ne sont pas publics. Il existe plutôt des méthodes statiques getXXX. Ainsi, pour avoir une grille, on fera HtmlGrid.getGrid().

Lorsqu'on a fini de se servir d'un objet, on peut appeler sa méthode finalize() ;

De ce fait, le système peut mettre en place en interne une stratégie de cache des objets (ou de pool), qui fait que chaque demande d'objets ne sera pas forcément une création d'objets. getXXX renvoie un objet du cache, finalize le remet dans le cache, et si get est appelé alors qu'il n'y a rien dans le cache, un objet est créé.

VI - Remerciements

Mes remerciements à Baptiste Wicht pour ses commentaires et suggestions sur cet article, ainsi qu'à jacques_jean pour sa relecture orthographique.

VII - Liens

- Audit des performances d'une application en Java EE

- Threads et performance avec Swing

- Le forum francophone Tests et Performances