Mettre en cache un objet signifie qu'on espère le réutiliser à court ou moyen terme en s'épargnant le coût de sa reconstruction. Cet article est le second d'une série (de deux articles) dédié à la mise en œuvre d'une mauvaise stratégie de cache.
Dans le précédent article, nous avons vu comment mettre en oeuvre une telle stratégie apparemment viable.
Dans celui-ci nous verrons d'abord comment notre cache se comporte, puis si les adaptations apportées améliorent les résultats. Pour finir, nous aborderons un cas d'utilisation dans lequel une petite modification des conditions de départ peuvent rendre ce cache enfin utilisable (ce serait dommage de rester sur un échec !).
Dans celui-ci nous verrons d'abord comment notre cache se comporte, puis si les adaptations apportées améliorent les résultats. Pour finir, nous aborderons un cas d'utilisation dans lequel une petite modification des conditions de départ peuvent rendre ce cache enfin utilisable (ce serait dommage de rester sur un échec !).
Rappel du problème et de la solution
Le cache conçu devait répondre aux exigences suivantes :
- les objets que le cache contient doivent être maintenus jusqu'à ce qu'on leur dise qu'ils peuvent être éventuellement évincés du cache.
- on ne veut évincer du cache les entrées les plus anciennes qu'en cas de manque de mémoire
- on veut pouvoir réaliser un post-traitement sur les objets évincés avant qu'ils ne soient réclamés par le ramasse-miette (c'est cette fonctionnalité qui est délicate)
L'implémentation réalisée répond parfaitement à ces contraintes.
Le code peut être consulté dans le précédent article.
La solution est de recourir à une SoftReference.
Mais cette référence faible n'est pas appliquée directement à l'objet mis en cache (sinon une fois évincé il est impossible de réaliser un post-traitement avec), mais à un "objet compagnon" qui fera office de déclencheur quand la mémoire vient à manquer.
Etalonnage des tests
Pour voir comment notre cache réagit quand la mémoire vient à manquer, on va utiliser de gros objets. On pourrait également considérer des objets de taille réduite mais dans ce cas il en faudrait beaucoup plus. On va donc tout simplement allouer un tableau d'octets, le déposer dans le cache, le rendre éligible à l'éviction et recommencer avec un second tableau et ainsi de suite jusqu'à saturation de la mémoire. Si le cache fonctionne bien, il n'y a pas de raison que le traitement s'arrête donc on va ajouter une condition d'arrêt, disons qu'une fois qu'on a remplit 2 ou 3 fois l'équivalent de la mémoire, on devrait avoir une bonne idée du résultat.
Pour vérifier qu'on arrive bien à saturer la mémoire (et pour identifier le seuil de saturation), on ne va pas tout de suite utiliser notre cache, on va utiliser un
HashMap
, qui s'utilise comme notre cache mais ne libère jamais rien. Le code donne ceci :// size of the data int size = 1024*1024; // memory available long mem = Runtime.getRuntime().freeMemory(); // a cache that retains its items forever Map<Long, byte[]> cache = new HashMap<Long, byte[]>(); // how many loops are necessary to fill the memory long loop = mem / size; System.out.println("MAX: " + loop); loop = loop *2; // go to out of memory long i = 0; try { for (; i < loop; i++) { cache.put(new Long(i), new byte[size]); if (i % 10 == 0) { System.out.println("STORING " + i + " to " + (i+10)); } } } finally { System.out.println("LOOPS: " + i); }
La trace d'exécution obtenue est la suivante :
MAX: 195 STORING 0 to 10 STORING 10 to 20 STORING 20 to 30 ... STORING 170 to 180 STORING 180 to 190 STORING 190 to 200 LOOPS: 195 Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
J'ai pris soin d'allouer assez peu de mémoire à la JVM : 200Mo, avec les options suivantes :
-Xms200m -Xmx200m
(qui correspondent à la mémoire minimal et maximale du tas). L'exécution est très rapide et on obtient immédiatement l'effet attendu. Si vous n'utilisez pas les deux mêmes valeurs, vous risquez d'obtenir une valeur MAX calculée au départ qui ne correspond pas au nombre
d'itérations LOOPS affiché à la fin, car la JVM augmentera la mémoire au fur et à mesure des besoins jusqu'à la valeur limite.Ce qui est surprenant, c'est quand on alloue dix fois plus de place :
-Xms2000m -Xmx2000m
;
la trace obtenue est comparable, mais le programme ne termine pas aussi vite : la JVM fait une longue pose vers la fin, comme si elle essayait désespérément de faire tourner le GC sur plusieurs cycles dans l'optique de trouver des octets devenus trop rares.
Mais rien n'y fait, et le résultat se termine comme attendu :MAX: 1980 STORING 0 to 10 STORING 10 to 20 STORING 20 to 30 ... STORING 1970 to 1980 STORING 1980 to 1990 LOOPS: 1982 Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
En jouant un peu, il reste cependant plausible que l'erreur obtenue puisse devenir
java.lang.OutOfMemoryError: GC overhead limit exceeded
; dans ce cas, le ramasse miette passerait tellement de temps à vouloir libérer la mémoire qu'il n'en resterait plus suffisamment pour l'application. Cette erreur survient si 98% du temps est monopolisé par le GC !
Test avec un SoftHashMap
Ajoutons l'une des fonctionnalités exigées à notre Map, celle permettant d'évincer
les items quand la mémoire vient à manquer. Pour cela, on a recours à un
SoftHashMap
.
Le SoftHashMap
ne répond pas
à toutes nos exigences, car il n'est pas possible d'être notifié de l'éviction d'une entrée : quand elle est réclamée
puis supprimée du cache, c'est trop tard il n'existe plus de référence pour la manipuler. Bien que cette classe ne corresponde
pas à notre cahier des charges, voyons comment elle se comporte avec nos tests.
// a cache that removes its items on memory leaks Map<Long, byte[]> cache = new SoftHashMap<Long, byte[]>();
La trace d'exécution obtenue est la suivante :
MAX: 195 STORING 0 to 10 STORING 10 to 20 STORING 20 to 30 ... STORING 360 to 370 STORING 370 to 380 STORING 380 to 390 LOOPS: 390
Vous avez vu ? C'est comme avec la
HashMap
sauf qu'il
n'y a plus d'exception de lancée car de la mémoire peut être libérée quand il en vient à manquer.
Le programme s'arrête une fois que la boucle a alloué un montant de mémoire atteignant le double de
la mémoire disponible (mais en la libérant au fur et à mesure des besoins).Une fois encore, on peut provisionner 10 fois plus de mémoire dans la JVM pour
voir ce qui se passe :
MAX: 1980 STORING 0 to 10 STORING 10 to 20 STORING 20 to 30 ... STORING 3930 to 3940 STORING 3940 to 3950 STORING 3950 to 3960 LOOPS: 3960
Cette fois aussi le programme se termine sans erreur et
la trace obtenue est comparable, mais là encore le programme ne termine pas aussi vite :
la JVM fait une pose au moment où la mémoire est saturée la première fois avant de poursuivre
normalement.
(L'implémentation
du
SoftHashMap
n'est pas vraiment importante, j'en ai pris une parmi les nombreuses disponibles sur le Web, en y ajoutant simplement les génériques Java -ce qui ne change rien à la compilation.)Test avec SoftCache
Nous avons vu comment étalonner nos tests avec un
HashMap
,
et avec le SoftHashMap
nous avons validé le déroulement de ces tests.
Il ne reste plus qu'à appliquer la méthode à notre SoftCache
(voir le code).
Contrairement au SoftHashMap
vu ci-dessus, le SoftCache
répond parfaitement
à notre besoin, c'est à dire qu'on peut réaliser un post-traitement sur chaque entrée évincée du cache
sans perdre la référence vers l'objet concerné.Adaptons le test pour qu'il fonctionne avec :
// number of entries discarded final long[] discard = new long[1]; discard[0] = 0; // a soft cache that sends a notification when // an entry is removed on memory leaks SoftCache<Long, byte[]> cache = new SoftCache<Long, byte[]>() { @Override public void discarded(Long key, byte[] value) { discard[0]++; // do some stuff with the value } };
La boucle exige aussi une petite adaptation :
les items doivent être relâchés pour devenir éligibles à l'éviction. Conservons
les 100 dernières entrées seulement (c'est ce que faisait également le
SoftHashMap
) :for (; i < loop; i++) { cache.put(new Long(i), new byte[size]); if (i % 10 == 0) { System.out.println("STORING " + i + " to " + (i+10)); } if (i-100 >= 0) { cache.release(new Long(i-100)); } }
C'est la catastrophe (prévisible, et annoncée lors de l'article précédent):
le résultat de l'exécution (avec 200M puis 2000M de mémoire allouée) donne
les mêmes résultats qu'avec le
HashMap
:
... STORING 1970 to 1980 STORING 1980 to 1990 LOOPS: 1982 Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
La raison en est simple : lors d'un cycle du GC, aucun objet
volumineux n'est libéré (sauf le petit objet compagnon factice) puisque ceux-ci sont conservés pour le
post-traitement.
Pour en être sûr, ajoutons une petite trace supplémentaire :
System.out.println("STORING " + i + " to " + (i+10) + " - DISCARDED: " + discard[0]);
...et on relance :
... STORING 1970 to 1980 - DISCARDED: 0 STORING 1980 to 1990 - DISCARDED: 0 LOOPS: 1982 Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
Argh ! Ca ne se passe pas du tout comme ça, MÊME le petit objet compagnon n'est pas libéré !
Celui-ci, si insignifiant, reste désespérément embastillé.
Funeste sort réservé aux petits objets compagnons qui peinent à être libérés |
Test avec un SoftCache qui marche
Tâchons d'améliorer le principe : puisque dans la boucle
on alloue une quantité de mémoire, on va provisionner cette quantité pour la rendre disponible
le moment venu. Cela implique de modifier l'implémentation du
SoftCache
;
une adaptation des entrées gérées dans le cache est nécessaire :SoftEntry(K key, V value) { this(key, value, new Object()); }
...devient :
SoftEntry(K key, V value) { this(key, value, new byte[1024*1024]); }
...et on relance :
MAX: 1980 STORING 0 to 10 - DISCARDED: 0 STORING 10 to 20 - DISCARDED: 0 ... STORING 1020 to 1030 - DISCARDED: 0 STORING 1030 to 1040 - DISCARDED: 122 STORING 1040 to 1050 - DISCARDED: 388 STORING 1050 to 1060 - DISCARDED: 651 STORING 1060 to 1070 - DISCARDED: 882 STORING 1070 to 1080 - DISCARDED: 891 STORING 1080 to 1090 - DISCARDED: 891 ... STORING 1880 to 1890 - DISCARDED: 891 STORING 1890 to 1900 - DISCARDED: 1142 STORING 1900 to 1910 - DISCARDED: 1422 STORING 1910 to 1920 - DISCARDED: 1677 STORING 1920 to 1930 - DISCARDED: 1782 STORING 1930 to 1940 - DISCARDED: 1782 ... STORING 2770 to 2780 - DISCARDED: 1782 STORING 2780 to 2790 - DISCARDED: 1928 STORING 2790 to 2800 - DISCARDED: 2208 STORING 2800 to 2810 - DISCARDED: 2429 STORING 2810 to 2820 - DISCARDED: 2673 STORING 2820 to 2830 - DISCARDED: 2673 ... STORING 3660 to 3670 - DISCARDED: 2673 STORING 3670 to 3680 - DISCARDED: 2855 STORING 3680 to 3690 - DISCARDED: 3168 STORING 3690 to 3700 - DISCARDED: 3489 STORING 3700 to 3710 - DISCARDED: 3564 STORING 3710 to 3720 - DISCARDED: 3564 ... STORING 3950 to 3960 - DISCARDED: 3564 LOOPS: 3960
Yeah ! Et ça ne plante même plus ! On pourrais faire un joli graphique
pour voir comment les entrées éligibles sont évincées par pallier.
De cette dernière trace, j'en tire les enseignements suivants :
le GC tente de libérer un montant de mémoire significatif, et ne semble pas vouloir perdre de temps sur les
petits objets (peut-être le fera-t-il plus tard ?). Mais qui me dit que le comportement sera le
même sur une autre JVM ? Comme on a aucun moyen d'agir sur le GC,
toute tentative d'amélioration est vouée à l'échec. A quand une API pour influencer le comportement
du GC ? Est-ce vraiment souhaitable ?
La stratégie adoptée ici est discutable :
provisionner un montant de mémoire "important" juste pour pouvoir éviter une pénurie de mémoire pour
les futurs objets à allouer n'est pas viable : quel montant doit-on allouer ? S'il s'avère à un
moment trop faible (qui a dit que les objets devaient TOUS occuper le même montant de mémoire ?),
tout risque de s'écrouler. De toutes façons, la mémoire ainsi provisionnée
serait "gâchée" pour le reste de l'application. L'idéal serait de pouvoir agir sur le comportement
du GC, et de faire en sorte que la méthode appelée pour la notification soit ultra-prioritaire de
telle sorte qu'une fois la valeur passée au post-traitement on puisse la libérer immédiatement et
dire au GC de la collecter. Beaucoup de choses que la JVM ne sait pas faire à la demande.
Combien même, le post-traitement est lui-même susceptible d'allouer de la mémoire pour son propre
fonctionnement, à un moment de disette, de forte pénurie, de famine !
On a beau tourner la question dans tous les sens, on voit bien
que la solution, à la base, n'est pas la bonne.
Non, il ne faut pas utiliser de SoftCache
Non, il ne faut pas utiliser de SoftCache
A ma décharge, le plan de test utilisé ici qui consiste à saturer
la mémoire en un temps record n'est pas non plus réaliste : une application normale ne colonise pas
tout l'espace disponible le plus vite possible, mais au contraire prend soin de libérer le plus d'objets
possibles. Combien même on s'extirperait de ce scénario catastrophe, le risque de manquer de mémoire
est accru par l'utilisation d'un tel cache, et bien réel. Donc : ne l'utilisez pas !
Une stratégie de cache viable
L'idée de ce cache m'est venue alors que j'entreprenais de gérer
un cache à deux niveaux : un cache de premier niveau qui stocke mes objets en mémoire vive, puis,
en cas de besoin, un cache de second niveau qui les enregistre sur disque. Le
SoftCache
serait ici idéal : si la mémoire vient à manquer, avant de supprimer l'objet on est notifié de
son éviction du cache de premier niveau, et c'est à ce moment qu'on l'écrit sur le disque.On voit bien que le
SoftCache
est totalement inadapté
à ce problème : le temps de latence pour enregistrer l'objet sur un dispositif de stockage trop lent
est inacceptable ; par ailleurs, il est nécessaire d'allouer de la mémoire supplémentaire pour
l'opération d'écriture.Une stratégie préemptive pour ce cas d'usage est une bien
meilleure solution : plutôt que de réaliser l'enregistrement sur un dispositif de stockage lent
lorsqu'il est trop tard, on le fait bien avant, de telle sorte qu'on n'ait plus besoin d'être
notifié de sa suppression du cache de premier niveau.
Je conseille également pour le cache de premier niveau de
recourir à une stratégie non basée sur les références faibles : il existe de nombreuses librairies
qui gèrent très bien les caches, y compris ceux de second niveau et même parfois sur des
systèmes distribués :
Apache Commons JCS,
EHcacHE,
Google Guava,
et bien d'autres, dont voici une liste non exhaustive.
Dans le premier article de cette série, j'introduisais les références
faibles et fortes, mais sans rentrer dans le vif du sujet.
Pour ceux qui sont intéressés,
voici un article détaillé
sur ce thème, et un autre article tout aussi utile.
A titre comlémentaire, un premier article sur le fonctionnement du GC
et un second article sur les erreurs liées à la mémoire.
Semantic Mismatch ERR-07 : IllegalSequenceException CHARRUE can't be before BOEUFS -- See log for details
Aucun commentaire:
Enregistrer un commentaire