IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

Améliorer les performances de vos programmes Java.

04/09/2004

Par Lionel Roux (site)
 

Développer un cache réutilisable d'appels méthodes avec le Dynamic Proxy Handler.
V1.0


Introduction
Qu'est ce qu'un cache ou une mise en cache ?
Qu'est ce que la mise en cache des appels méthodes ?
Pourquoi utiliser la mise en cache ?
Quels sont les inconvénients de la mise en cache ?
Comment mettre en oeuvre la mise en cache des appels méthodes ?
Utiliser les Dynamic Proxy Handlers pour implémenter le framework de caching.
Un exemple type.
Pour aller plus loin, un cache plus évolué.
Est-ce la solution ultime ?
Existe-il des cas rédhibitoires ?
Ressources.
Contactez moi.
Telecharger ce document au format PDF


Introduction


Tout le monde ou presque connaît et reconnaît les avantages des caches objets.
Aujourd'hui, plus personne ne serait tenté d'utiliser JDBC sans mettre en place un pool de connexions. Les caches objets sont donc largement adoptés par la plupart des développeurs.

En revanche peu de développeurs utilisent la mise en cache du résultat d'exécution d'un appel méthode. Pourtant, cette technique très utilisée par les langages fonctionnels comporte un certain nombre d'avantages non négligeables, et notamment une amélioration globale tant au niveau mémoire que temps processeurs des performances.

Java offre pourtant des outils permettant de mettre facilement en place un tel cache d'appels méthodes grâce à l'API standard de J2SE.

Nous allons voir dans cet article comment mettre en œuvre cette technique en Java afin d'améliorer les performances de vos programmes.


Qu'est ce qu'un cache ou une mise en cache ?


La mise en cache est un procédé visant à stocker temporairement un objet afin de le réutiliser ultérieurement. En informatique c'est une technique très utilisée pour améliorer les performances d'un système ou d'un logiciel. Plus généralement, il s'agit de définir une stratégie d'accès à certains objets, en ne recourant pas systématiquement à une création pure et simple de ceux-ci.

Concrètement, elle permet de ne pas avoir à reconstruire les objets en question à chaque fois que l'on en à besoin. Lorsque l'utilisateur doit les utiliser, il va tout simplement les chercher dans une sorte d'entrepôt de stockage d'objets (que l'on appelle le cache), et les récupère tels quels, déjà construits et prêts à l'utilisation.


Qu'est ce que la mise en cache des appels méthodes ?


De nombreux langages fonctionnels comme Perl, Python ou encore Lisp utilisent cette technique qui consiste à mettre en cache un résultat d'appel méthode après son exécution. Ainsi lors du prochain appel de cette méthode, le programme ne ré-exécutera pas le code de celle-ci mais retournera directement le résultat de sa précédente exécution, depuis le cache.


Pourquoi utiliser la mise en cache ?


La mise en cache améliore grandement les performances d'un programme qui utilise beaucoup d'objets, qui engendre beaucoup d'appels méthodes ou encore dont les méthodes engendrent de gros calculs.

En effet, nous savons très bien que non seulement la création de l'objet est très coûteuse, mais que sa collecte par le garbage collector l'est tout autant. Sans entrer dans des considérations de niveau machine, plus il y a d'objets, plus les besoins de réorganisation de la mémoire sont nombreux et plus les recherches dans les registres sont longues.

Par ailleurs, il en est de même pour les cycles des processeurs qui sont partagés entre les différents processus de la machine. Plus il y a de demandes de calculs, plus les processeurs sont chargés, entraînant de fait un effondrement des performances.

L'apport d'une technique comme la mise en cache des appels méthodes peut s'avérer significatif dans certains types de programmes.
Par exemple un logiciel accédant beaucoup à un SGBD peut en tirer partie puisqu'il permet de réduire les requêtes vers ce dernier, et réduit d'autant l'utilisation de bande passante pour rapatrier les données et du temps processeurs du serveur de SGBD.

La mise en cache permet aussi de meilleures montées en charge (la fameuse " scalabilité "), puisque les systèmes sont moins sollicités (tant au niveau utilisation mémoire que temps processeurs) et donc peuvent traiter un plus grand nombre d'opérations. Les ressources sont ainsi mieux utilisées et mieux réparties.


Quels sont les inconvénients de la mise en cache ?


Certains types de programmes s'accommodent très mal de la mise en cache. Il s'agit plus particulièrement des programmes dits "temps réels durs" qui se servent de données sans cesse mises à jour.

On ne peut pas imaginer l'ordinateur central d'un avion utilisant des paramètres issus d'un cache. Il lui faut des données mises à jour régulièrement afin par exemple de corriger sa trajectoire ou son assiette.

Dans une moindre mesure, un système de gestions du stock d'une entreprise ne pourra pas tirer partie de la mise en cache des requêtes vers la table de disponibilité des produits. En revanche il pourra toujours mettre en cache les requêtes vers la table des clients ou la table des pays.


Comment mettre en oeuvre la mise en cache des appels méthodes ?


En général, cette technique peut être mise en œuvre via un framework de mémorisation transparente via des wrappeurs (enrobeurs) de méthodes.

Ce framework propose en général des méthodes pour enregistrer les classes dont les méthodes doivent être mise en cache, pour les libérer ou pour les rafraîchir.

Supposons que nous souhaitons cacher certaines méthodes de la classe MyClass.
Il suffit de définir une interface IMyClass qui déclare les méthodes concernées.
La seule modification à apporter à la classe MyClass est de la faire implémenter de l'interface IMyClass.

Ensuite il faut enregistrer auprès du framework la classe en question :

IMyClass cachedMyClass = (IMyClass)CallbackFramework.register(new MyClass());
Ce changement est mineur et trivial puisque c'est la classe qui contraint l'interface quant aux méthodes à déclarer. Il est donc possible dès lors de cacher n'importe quelle méthode d'une classe existante.


Utiliser les Dynamic Proxy Handlers pour implémenter le framework de caching.


La version 1.3 du SDK Java a introduit la classe Proxy dans le package java.lang.reflect.

Cette classe utilise largement les possibilités d'introspection de java pour créer, via des méthodes statiques, des instances de classes dont l'implémentation est choisie à l'exécution, à la différence des classes classiques dont l'implémentation est fixée à la compilation. Ce type de classes est appelé un "gestionnaire de représentant dynamique" (Dynamic Proxy Handler).

En utilisant cette classe, nous pouvons ainsi créer des classes dont le comportement peut changer pendant l'exécution d'un programme.

C'est cette fonctionnalité que nous allons utiliser pour implémenter le framework de caching d'appel méthode. En effet, nous pouvons imaginer de faire en sorte qu'une fois l'exécution d'une méthode réalisée, son résultat soit stocké. Dès lors le comportement de la méthode change dynamiquement pour ne faire que retourner le résultat stocké dans le cache.

Le framework étend la classe InvocationHandler :

public class CallBackFramework implements InvocationHandler { ... // le reste du code ici; }
Il est donc en mesure d'intercepter chaque invocation d'une méthode enregistrée, c'est-à-dire présente dans l'interface donnée lors de la création du Proxy. Ce proxy va utiliser les méthodes invoke() pour invoquer par callback sur l'instance source les dites méthodes.

La valeur ajouté du framework vient ici de la redéfinition de cette méthode invoke().
En effet, c'est à cet endroit que nous allons mettre en place le mécanisme de caching.

La méthode register() peut se présenter comme ceci :

public static Object register(Object object) { Class clazz = object.getClass(); return Proxy.newProxyInstance ( clazz.getClassLoader(), clazz.getInterfaces(), new CallbackFramework(object) ); }
Nous remarquons que les méthodes déclarées comme utilisables par le proxy créé le sont grâce à la récupération de l'interface par clazz.getInterfaces(). C'est pour cela que nous sommes précédemment passé par la définition d'une interface que la classe doit implémenter. Sans quoi toutes les méthodes seraient cachées.
L'enregistrement d'une classe déclenche un certain nombre d'opérations comme la création d'une instance du framework (proxy) dédié à classe, … :

Object object = null; private CallBackFramework(Object object) { this.object = object; ... }
Une fois la classe enregistrée comme candidate au caching de ses méthodes déclarées, il nous faut fournir un moyen de les invoquer.

C'est ce que propose de faire la redéfinition de la méthode invoke() utilisée par le mécanisme de réflexion:

public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { if(method.getReturnType().equals(Void.TYPE)) { // les méthodes retournant void ne doivent pas être cachées, // car elles ne retourne pas de résultats exploitables. // Nous appelons donc la méthode invoke() classique de la reflexion. // voir le bloc de code suivant. return invoke(method, args); } else { Map mCache = getMethodCache(method); List argsKey = Arrays.asList(args); Object value = mCache.get(argsKey); if (value == null && !mCache.containsKey(argsKey)) { value = invoke(method, args); mCache.put(argsKey, value); } return value; } }
Pour les méthodes de type void, il faut absolument passer par le mécanisme classique de réflexion. En effet, les méthodes de type void ne retournent pas de résultat exploitable, et ne font donc que des traitements non cachables :

private Object invoke(Method method, Object[] args) throws Throwable { try { return method.invoke(object, args); } catch (InvocationTargetException e) { throw e.getTargetException(); } }
Les méthodes pouvant être invoquées avec des arguments différents, il faut tenir à jour pour chaque méthode un cache de résultat en fonction de ceux-ci.

C'est pourquoi, nous utilisons un cache d'objet dont la clé est une liste d'arguments :

// Nous récupérons dans le cache des méthodes, le cache spécifique // mCache à la méthode method. Map mCache = getMethodCache(method); // Nous construisons la clé avec l'ensemble des arguments args. List argsKey = Arrays.asList(args); // Nous tentons de récupérer dans le cache spécifique le résultat // pour les arguments présentés. Object value = mCache.get(argsKey);
Nous avons donc deux caches; l'un pour les méthodes cachées, et l'autre pour les résultats par ensemble d'arguments d'une méthode spécifique. Le clé est une List car l'entropie d'un tel objet est suffisante pour garantir des clés uniques grâce à l'implémentation de sa méthode equals().


Un exemple type.


Supposons que l'on a une application qui fait une utilisation intensive d'un SGBD.
Supposons aussi, comme c'est le cas dans la plupart des systèmes actuels, qu'une partie des données ne changent pas.

Il est alors opportun de construire une interface d'accès au SGBD qui va utiliser les capacités de caching du framework.

Un exemple très général serait :

public class DataBaseHelper extends ICachedDb { Connection connection; //… public ResultSet executeQuery(String query) { connection.executeQuery(query); } }
Avec ICachedDB défini comme ceci:

public interface ICachedDb { public ResultSet executeQuery(String query); }
Dans votre programme, lorsque vous souhaitez utiliser le mécanisme de cache pour certaines requêtes, vous enregistrer simplement l'instance de la classe DataBaseHelper dans le framework :

ICachedDb cachedDbHelper = (ICachedDb)CallbackFramework.register(new DataBaseHelper(…)); ICachedDb notCachedDbHelper = new DataBaseHelper(…); ... // cette requête est mise en cache. Elle ne sera exécutée qu'une fois ResultSet countries = cachedDbHelper.executeQuery("Select * from Country"); // cette requête n'est pas dans le cache, elle sera exécutée à // chaque invocation ResultSet stocks = notCachedDbHelper.executeQuery("Select * from Stock");
Voila, en quelques lignes vous avez soulagé votre SGBD d'autant de requêtes que d'invocation via cachedDbHelper.


Pour aller plus loin, un cache plus évolué.


Comme nous venons de le voir ce framework peut rendre d'énormes services.
Mais nous pouvons encore l'améliorer. Nous pouvons par exemple envisager d'utiliser différentes stratégies pour la mise en cache.

Dans l'exemple précédent, nous avons choisi de définir deux utilitaires d'accès à la base : l'un est caché, l'autre pas. Cette approche quelque peu manichéenne peut ne pas satisfaire tout le monde.

En effet, ne serait-il pas plus intéressant d'avoir un cache pour lequel nous pouvons décider d'un temps maximum de mise en cache. Cette approche permettrait d'avoir des résultats de requêtes mis à jour régulièrement sans pour autant ré-exécuter systématiquement les requêtes.
Imaginons par exemple, un site communautaire qui fait apparaître sur sa page d'accueil les derniers inscrits. Nul besoin ici d'aller chercher en base de données ceux-ci à chaque fois que la page d'accueil est affichée. En effet, on peut choisir d'aller prendre le résultat dans un cache. Cependant, l'affichage doit quand même refléter les changements. Nous pouvons dès lors choisir de mettre à jour le cache toutes les 2 minutes, ce qui pour un site à fort trafic, représente un gain énorme.

Pour notre exemple précédent, nous pouvons utiliser un cache expirant, c'est-à-dire un cache dont les résultats sont valides pendant un temps donné ; le code source suivant donne un exemple de cache expirant sommaire :

import java.util.*; public class ExpireCache extends Cache { Map cache; Map time; long timeout; public ExpireCache(long timeout) { this.cache = new HashMap(); this.time = new HashMap(); this.timeout = timeout; } public Object get(Object key) { if(!isExpired(key)) { return cache.get(key); } else { cache.remove(key); time.remove(key); return null; } } public Object put(Object key, Object value) { time.put(key, new Long(System.currentTimeMillis())); return cache.put(key, value); } private boolean isExpired(Object key) { Long keyTime = (Long)time.get(key); if(keyTime != null) { return(keyTime.longValue()+timeout < System.currentTimeMillis()); } return true; } }
Pour utiliser le framework en précisant le type de cache à utiliser, il faut en modifier la méthode register() et le constructeur :

public static Object register(Object object, Cache cache) { Class clazz = object.getClass(); return Proxy.newProxyInstance ( clazz.getClassLoader(), clazz.getInterfaces(), new CallbackFramework(object, cache) ); }
La construction du proxy va définir le type de cache :

Object object = null; Cache cache = null; private CallBackFramework(Object object, Cache cache) { this.object = object; this.cache = cache }
L'utilisation du framework est modifiée en interne. Le cache définit prend la place de l'habituelle Map :

// Nous récupérons dans le cache des méthodes, le cache spécifique // mCache à la méthode method. Cache mCache = getMethodCache(method); // Nous construisons la clé avec l'ensemble des arguments args. List argsKey = Arrays.asList(args); // Nous tentons de récupérer dans le cache spécifique le résultat // pour les arguments présentés. Object value = mCache.get(argsKey);
En revanche, tout est transparent pour l'utilisateur final. Rien ne change si ce n'est la construction du proxy :

// définition d'un proxy dont les requêtes sont valables 2 minutes. ICachedDb shortCachedDbHelper = (ICachedDb)CallbackFramework .register(new DataBaseHelper(…), new ExpireCache(120000L)); // définition d'un proxy dont les requêtes sont valables 1 heure. ICachedDb longCachedDbHelper = (ICachedDb)CallbackFramework .register(new DataBaseHelper(…), new ExpireCache(3600000L));
Voila, vous avez défini votre stratégie, et vous pouvez la personnaliser à loisir. Il vous suffit pour cela de définir le cache qui fera ce dont vous avez besoin.

Vous pouvez imaginer toutes sortes de choses, comme réaliser des statistiques sur le nombre d'appels à une requête ou à un appel méthode, ou encore choisir d'utiliser l'objet le plus récent dans une collection (cache LRU).

Vous pouvez aussi ne mettre en cache que les N appels les plus utilisés.


Est-ce la solution ultime ?


Bien qu'alléchante de prime abord, cette technique n'est pas sans inconvénients.

En effet, le gestionnaire d'invocation utilise fortement la réflexion, ce qui, comme tout développeur expérimenté le sait, a un coût certain.

Attention tout de même de ne pas tomber dans la paranoïa, depuis le JDK 1.4, la réflexion n'est plus aussi lente que par le passé.
Joshua Bloch, un des évangélistes de Sun, estime, étude à l'appui, qu'un appel par réflexion est deux fois plus lent qu'un appel classique.
A comparer avec le temps d'une requête sur plusieurs centaines de lignes vers un SGBD!
Ou encore avec l'exécution d'une méthode faisant un calcul mathématique complexe ou une méthode mal programmée construisant et utilisant des dizaines d'objets en mémoire (le temps de collecte n'est pas négligeable).

En tout état de cause, seul un passage au profiler pourra vous dire si vous devez mettre en place un tel framework. Si vos performances sont acceptables, ce n'est pas sûr. En revanche si vous chercher à les améliorer par tous les moyens, plutôt que de passer des heures à essayer d'optimiser une partie infime du code, penchez-vous vers sa mise en place.

En conclusion, le Dynamic Proxy Handler est intéressant dans 90 % des cas et dans 100% des cas, tout est affaire de compromis.


Existe-il des cas rédhibitoires ?


Oui, il en existe trois :
Si votre méthode n'est pas déterministe. Le résultat est susceptible de changer entre deux appels avec les mêmes arguments. Si votre méthode modifie un état interne du programme. Si l'un des arguments de votre méthode n'est pas immuable.

Au-delà de cela, toute méthode peut être candidate au caching.


Ressources.


Lisp - Pau Graham on Lisp :
 http://www.paulgraham.com/onlisp.html Sun.com - Joshua Bloch effective java on reflection :
 http://java.sun.com/docs/books/effective/ OnJava.com - Tom White :
 http://www.onjava.com/pub/a/onjava/2003/07/20/memoization.html Sun.com - La documentation du Proxy :
 http://java.sun.com/j2se/1.4.1/docs/api/java/lang/reflect/Proxy.html Sun.com - The reflection API:
 http://java.sun.com/docs/books/tutorial/reflect/


Contactez moi.


Pour toute question ou précision, ou encore pour toute demande ou prestation, n'hésitez pas à me contacter par email : lionel.roux at redaction-developpez dot com.
Visitez  mon site (http://lroux.developpez.com) régulièrement pour vérifier les mises à jour et lire d'autres articles.


Telecharger ce document au format PDF





© 2004 Lionel Roux - Tous droits réservés : Lionel Roux. Toute reproduction, utilisation ou diffusion de tout ou partie de ce document par quelque moyen que ce soit autre que pour un usage personnel doit faire l'objet d'une autorisation écrite préalable de la part de Lionel Roux, le propriétaire des droits intellectuels. Contact : lionel.roux@redaction-developpez.com. Site : http://lroux.developpez.com
Toutes transgressions des présentes recommandations seront systématiquement suivies d'une plainte comme le permet la loi sur l'économie numérique (LEN). Ce document est enregistré à la SACD.