jeudi 26 juin 2014

Jouons à cache-cache avec Java (seconde partie)

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 !).



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

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