Tutoriel aux caches et à la concurrence de Guava

Thierry

Guava est une bibliothèque, de chez Google, proposant de nombreux outils pour améliorer les codes des programmes Java. Elle permet, entre autres, de manipuler les collections, de jouer efficacement avec les immutables, d'éviter la gestion des beans nuls, de s'essayer à la programmation fonctionnelle, de cacher les objets, de les simplifier, et bien d'autres choses…

Dans ce cinquième article sur Guava, nous allons découvrir comment utiliser des caches en mémoire et jouer sur la concurrence. 2 commentaires Donner une note à l'article (5)

Article lu   fois.

L'auteur

Site personnel

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Introduction

Cet article est le cinquième d'une série consacrée à la bibliothèque Guava :

I-A. Versions des logiciels et bibliothèques utilisés

Pour écrire ce document, j'ai utilisé les versions suivantes :

  • Java JDK 1.6.0_24-b07 ;
  • Eclipse Indigo 3.7 JEE 64b ;
  • Maven 3.0.3 ;
  • JUnit 4.10 ;
  • Guava 14.0.

J'utilise Java 6, car Java 7 n'est pas encore très répandu en entreprise. C'est ce que je vérifie durant mes conférences lorsque je demande qui utilise Java 7 sur ses serveurs de production, mais que très peu de mains se lèvent…

I-B. Mises à jour

11 décembre 2013 : création

18 janvier 2014 : ajout du type composite en clé de cache

II. Caches

Les caches sont quasi indispensables dans les applications modernes, qui sont souvent interconnectées. Il existe plusieurs bibliothèques de cache comme EhCache, et même des serveurs de cache distribués comme Terracotta. Guava propose également sa solution, dont la spécificité est d'être un cache en mémoire, qui ne fait donc pas de sérialisation. Le contenu du cache n'est pas persisté et il est perdu à l'arrêt du programme. La conservation des données dans le cache consomme donc de la mémoire. En revanche, le cache de Guava est très rapide.

On choisit donc le cache de Guava lorsqu'on veut une solution très rapide, non persisté et qu'on est prêt à payer un coût en mémoire.

Dans le cadre d'un cache, on peut stocker une valeur unique (on parlera de « mémoization ») ou un ensemble de valeurs identifiées par des clés. En première approche un cache pourrait être assimilé à une map, mais c'est une vision un peu trop simple. En effet, un cache doit :

  • supporter la concurrence (comme une « ConcurrentMap ») ;
  • gérer une date de validité sur ses données, contrairement aux maps qui conservent les données tant qu'elles ne sont pas explicitement supprimées ;
  • être capable de charger/recharger les données qu'elle ne possède pas/plus ;
  • pouvoir limiter le nombre d'éléments à l'aide d'une stratégie telle que FIFO.

II-A. Création

Pour créer un cache Guava pour un élément unique, il faut employer un « Supplier » :

Création d'un cache simple
Sélectionnez

private Supplier<Integer> nbOfDogsSoldSupplier = Suppliers.memoize( 
        new Supplier<Integer>() {
            public Integer get() {
                return monWebService.checkNombreAnimalVendusHier("dog");
            }
        });

Pour utiliser ce cache, il suffit d'appeler la méthode « get() ». Si Guava connaît déjà la valeur alors il la renvoie directement. Sinon il réalise la recherche, ici un appel de Web Service :

Utilisation d'un cache simple
Sélectionnez

@Test
public void testSupplier() {
    // Arrange
    final int expected = 31; // ici 31 chiens ont ete vendus hier.

    // Act
    final int nb= nbOfDogsSoldSupplier.get();

    // Assert
    Assert.assertEquals(expected, nb);
}

Pour créer un cache avec plusieurs valeurs, c'est à peine plus complexe. Il faut déclarer un « CacheLoader » dont le travail sera de faire le chargement si besoin, et un « LoadingCache » qui sera le cache à proprement parler :

CacheLoader
Sélectionnez

private static CacheLoader<String, Chien> chienLoader = new CacheLoader<String, Chien>() {

    @Override
    public Chien load(String prenom) throws Exception {
        return monWebService.searchChien(prenom);
    }

};
LoadingCache
Sélectionnez

private static LoadingCache<String, Chien> chienCache = CacheBuilder
        .newBuilder()
        .build(chienLoader);

Il faut gérer le cas où le CacheLoader n'est pas capable de charger la donnée. C'est typiquement le cas lors d'une recherche en base sur un élément qui n'existe pas (ie. null).

Là encore, pour demander une valeur, il suffit d'appeler la méthode « get() » :

Demander une valeur
Sélectionnez

@Test 
public void testMilou() {
    // Arrange
    final String prenom = "Milou";
    final SexeEnum expected = SexeEnum.MALE;

    // Act
     Chien chien = null;
    try {
        chien = chienCache.get(prenom);
    } catch (ExecutionException e) {
        ...
    }

    // Assert
    Assert.assertEquals(expected, chien.getSex());
}

En fait, ces deux types de caches sont quasi identiques (vu de l'extérieur) au delta des noms de classes. Dans la suite de ce document, c'est le second cache qui sera présenté. Toutefois, on retiendra que les méthodes fonctionnent pour les deux, lorsque ça a du sens.

Bien entendu, on peut aussi insérer des méthodes manuellement (comme dans une map) :

Ajout manuel
Sélectionnez

final Chien lassie = new Chien("Lassie", FEMALE);
...
chienCache.put("Lassie", lassie);

Et on peut demander un « export » du cache sous forme de map concurrente :

Export
Sélectionnez

final ConcurrentMap<String, Chien> map = chienCache.asMap();

Imaginons maintenant qu'on utilise un web service (ou une méthode) ayant besoin de plusieurs paramètres. Comme le cache ne prend qu'un seul objet en clé, on doit utiliser un type composite (c'est-à-dire composé de plusieurs champs). Voici un exemple d'un tel objet :

Type composite
Sélectionnez

public class ChienCriteria {

	private String couleur;
	private SexeEnum sexe;
	private boolean lof;

	// + constructeur(s)
	
	// + hashCode() + equals()
	
	// + getters/setters

Pour que ça fonctionne, il ne faut surtout pas oublier les méthodes « hashCode » et « equals ». Pour le reste, ça marche comme un cache classique.

Cache avec clé composite
Sélectionnez

private static CacheLoader<ChienCriteria, Chien> chienCriteriaLoader = new CacheLoader<ChienCriteria, Chien>() {
	@Override
	public Chien load(ChienCriteria criteria) throws Exception {
		return monWebService.get(criteria.getCouleur(), criteria.getSexe(), criteria.isLof());	
	}
};

private static LoadingCache<ChienCriteria, Chien> chienCriteriaCache = CacheBuilder//
		.newBuilder()//
		.build(chienCriteriaLoader);

II-B. Éviction

Dans le cache, on va parfois devoir supprimer des données, car :

  • il n'y a plus de place (ie. on veut économiser de la mémoire) ;
  • les données sont périmées.

La limite du nombre d'éléments dans le cache doit être indiquée lors de la déclaration du cache :

Limite à 1000 chiens
Sélectionnez

final static private int LIMIT = 1000;

private static LoadingCache<String, Chien> chienCache = CacheBuilder
        .newBuilder()
        .maximumSize(LIMIT) 
        .build(chienLoader);

Il est aussi possible de préciser le poids (en octets) maximum du cache :

Poids
Sélectionnez

final static private int POIDS_MAX = 2000000;

private static LoadingCache<String, Chien> chienCache = CacheBuilder
        .newBuilder()
        .maximumWeight(POIDS_MAX) 
        .build(chienLoader);

L'empreinte mémoire d'un élément (ie. son poids) est calculée lors de l'insertion dans le cache. Si le chien grossit, cela ne sera pas pris en compte.

Guava supprime des éléments avant d'atteindre la limite. Il commence par les éléments qui sont peu (ou pas) utilisés. Enfin il essaie…

On peut préciser sa propre fonction de calcul du poids à l'aide d'un « weighter » :

Poids avec weighter
Sélectionnez

final static private int POIDS_MAX = 2000000;

private static LoadingCache<String, Chien> chienCache = CacheBuilder
        .newBuilder()
        .maximumWeight(POIDS_MAX) 
        .weigher(new Weigher<String, Chien>() {
                @Override
                public int weigh(String name, Chien chien) {
                    return something;
                }
            }) 
        ...
        .build(chienLoader);

Voici d'ailleurs ce qu'en dit la documentation : « If your cache should not grow beyond a certain size, just use CacheBuilder.maximumSize(long). The cache will try to evict entries that haven't been used recently or very often. Warning: the cache may evict entries before this limit is exceeded -- typically when the cache size is approaching the limit. Alternately, if different cache entries have different weights -- for example, if your cache values have radically different memory footprints -- you may specify a weight function with CacheBuilder.weigher(Weigher) and a maximum cache weight with CacheBuilder.maximumWeight(long). In addition to the same caveats as maximumSize requires, be aware that weights are computed at entry creation time, and are static thereafter. »

Notez tout de même une petite subtilité. Bien qu'on parle de poids, rien n'oblige à l'exprimer en kilogramme ou en octet. Prenons en exemple le code suivant :

Weighter en nombre de raviolis
Sélectionnez

public final static int poidsDunRavioliPekinois = 26; // en gramme  
public final static int radioViandeMale = 38; // en pourcent
public final static int radioViandeFemale = 32; // en pourcent

private static LoadingCache<String, Chien> chienCache = CacheBuilder//
    .newBuilder()
    .maximumWeight(12345) // en nb de raviolis
    .weigher(new Weigher<String, Chien>() {
        @Override
        public int weigh(String name, Chien chien) {
            final int ratio = (chien.getSex() == SexeEnum.MALE) ? radioViandeMale : radioViandeFemale;
            final double poids = chien.getWeight(); // en kg
            final double viande = poids * ratio; // en gramme
            final int nbRaviolis = (int) viande / poidsDunRavioliPekinois;
            return nbRaviolis;
        }
    }) 
    ...

Vous l'aurez compris, ici, je convertis mes chiens en unités de raviolis à l'aide d'une formule. J'ai conservé un lien avec le poids physique des chiens, mais on aurait tout aussi pu dire que le poids à utiliser dans le cache dépendait de la couleur…

On peut aussi préciser la durée de validité des données dans le cache. Cette durée est exprimée soit à partir de la date d'insertion, soit à partir de la date de dernière consultation, soit les deux :

Double durée de validité
Sélectionnez

private static LoadingCache<String, Chien> chienCache = CacheBuilder
        .newBuilder()
        .expireAfterAccess(10, TimeUnit.MINUTES) 
        .expireAfterWrite(60, TimeUnit.MINUTES) 
        .build(chienLoader);

Dans l'exemple, les données seront périmées (et supprimées du cache), dès qu'elles n'auront pas été consultées depuis plus de dix minutes. Et dans tous les cas, même si elles sont fréquemment consultées, elles seront périmées une heure après l'insertion (ou la dernière modification).

De même qu'on peut ajouter manuellement une entrée dans le cache, on peut aussi supprimer des éléments manuellement :

Suppression
Sélectionnez

chienCache.invalidate("Lassie");

Guava permet également d'écouter les événements de suppression des éléments du cache pour lancer une opération (log, rechargement, notification, etc.) :

Listener
Sélectionnez

private static RemovalListener<String, Chien> chienRemovalListener = new RemovalListener<String, Chien>() {

    @Override
    public void onRemoval(RemovalNotification<String, Chien> removal) {
        System.out.println("Le chien " + removal.getValue().getName() + " n'est plus dans le cache...");
    }
};

private static LoadingCache<String, Chien> chienCache = CacheBuilder
        .newBuilder()
        .removalListener(chienRemovalListener) 
        .build(chienLoader);

II-C. Rechargement

Il ne faut pas confondre « éviction » et rechargement. L'éviction supprime les données (et leurs clés) du cache. Un rechargement recharge les données, si bien que les clés sont toujours présentes :

Rechargement
Sélectionnez

chienCache.refresh("Lassie");

Le rechargement est asynchrone.

Il peut être intéressant de recharger automatiquement les données après leur éviction (dans le listener) ou tout simplement lorsqu'elles sont périmées :

Rechargement automatique
Sélectionnez

private static LoadingCache<String, Chien> chienCache = CacheBuilder
        .newBuilder()
        .refreshAfterWrite(45, TimeUnit.MINUTES) 
        .build(chienLoader);

II-D. Statistiques

On sous-estime trop souvent l'importance de vérifier le fonctionnement des caches, pour bien le régler. Avec Guava, c'est relativement simple :

Statistiques
Sélectionnez

CacheStats stats= chienCache.stats();

final long hits = stats.hitCount();
final long loads = stats.loadCount();
final long loadSuccess = stats.loadSuccessCount();
final double miss = stats.missRate();

III. Concurrence

La concurrence est une question qui se pose de plus en plus en programmation. Les dernières versions du JDK ont chacune apporté leur pierre à l'édifice, mais ça reste relativement complexe. Guava va simplifier/clarifier cela, tout en allant bien plus loin.

Le fait que Guava travaille avec des immutables le rend particulièrement adapté à la programmation concurrente.

III-A. Les Futures du JDK

Le problème de la concurrence se pose généralement, quand on a besoin de faire plusieurs traitements, indépendants, qui prennent du temps. Or on aimerait lancer ces traitements de manière parallèle pour gagner un peu de temps. Prenons un exemple simple ; on veut employer des Web services lents pour rechercher les chiens Milou et Lassie :

Milou et Lassie
Sélectionnez

public List<Chien> chercheMilouEtLassie() {
    final Chien milou = service1("Milou");    // 3 secondes
    final Chien lassie = service2("Lassie", FEMALE); // 3 secondes
    return ImmutableList.of(milou,lassie);
}

Si chaque appel de service prend trois secondes, l'ensemble de la recherche va donc prendre six secondes. Pour que les appels soient parallélisés, on peut utiliser les interfaces « Callable » et « Future » du JDK :

Appels
Sélectionnez

public List<Chien> chercheMilouEtLassieParallele() {
    final Future<Chien> milouFuture = service1b("Milou");
    final Future<Chien> lassieFuture = service2b("Lassie", FEMALE);
    try {
        return ImmutableList.of(milouFuture.get(), lassieFuture.get());
    } catch (Exception e) {
        ...
    }
}

Lorsqu'on demande un « Future » en Java, on dit juste qu'on veut un « service » qui renverra un résultat. Et c'est lors de l'appel à la méthode « get() » qu'on va réellement demander le résultat.

Pour créer un « Future », le plus simple est de demander un « ExecutorService » sous forme de singleton :

Future et Callable
Sélectionnez

private static ExecutorService executor = Executors.newSingleThreadExecutor();

private static Future<Chien> service1b(final String prenom) {

    return executor.submit(new Callable<Chien>() {
        @Override
        public Chien call() throws Exception {
            Thread.sleep(3000); // pour que ca dure 3 secondes.

            return new Chien(prenom);
        }
    });
}

III-B. ListenableFuture

Les « Futures » fonctionnent très bien et devraient vous suffire dans la plupart des cas. Ils ont néanmoins un défaut, car on ne sait pas dans quel état est le « future », en particulier s'il a déjà obtenu son résultat. C'est pour cela que Guava a introduit les « ListenableFuture » dont le surcoût est négligeable. Pour le créer, il suffit de décorer un « executor » :

Création
Sélectionnez

private static ExecutorService executor = Executors.newSingleThreadExecutor();
private static ListeningExecutorService listeningExecutor = MoreExecutors.listeningDecorator(executor);

Encore une fois, il n'y a pas beaucoup de changements sur les méthodes :

Très peu de changements
Sélectionnez

private static ListenableFuture<Chien> service1c(final String prenom) {

    return listeningExecutor.submit(new Callable<Chien>() {
        @Override
        public Chien call() throws Exception {
            Thread.sleep(3000); // pour que ca dure longtemps.

            return new Chien(prenom);
        }
    });
}

Le grand avantage des « ListenableFutures » est qu'ils permettent l'utilisation combinée de « callbacks » qui seront appelés, dès que le traitement sera fini (ou immédiatement s'il est terminé) :

Ajout de callback
Sélectionnez

final ListenableFuture<Chien> milouFuture = service1c("Milou");

Futures.addCallback(milouFuture, new FutureCallback<Chien>() {

    @Override
    public void onFailure(Throwable arg0) {
        ...
    }

    @Override
    public void onSuccess(Chien arg0) {
        ...
    }
});

On peut, par exemple, mettre le résultat en cache :

En cache
Sélectionnez

private static Map<String, Chien> chienCache = Maps.newHashMap();
...

final String prenom = "Milou";
final ListenableFuture<Chien> chienFuture = service1c(prenom);

Futures.addCallback(chienFuture, new FutureCallback<Chien>() {

    @Override
    public void onFailure(Throwable t) {
        ...
    }

    @Override
    public void onSuccess(Chien chien) {
        chienCache.put(prenom, chien);
    }
});

Puisqu'on a abordé la classe utilitaire « Futures », il convient de préciser qu'elle travaille principalement avec des « ListenableFuture » et non avec des « Future » simples. Là où cela devient intéressant, c'est que l'appel à la méthode « get() » des « Future » standards, dans le cadre d'un renvoi de liste, bloque l'ensemble du retour. Avec un « ListenableFuture », on évite donc ce blocage :

Non bloquant
Sélectionnez

public ListenableFuture<List<Chien>> chercheMilouEtLassieListenable() {
    final String prenom = "Milou";
    final ListenableFuture<Chien> chienFuture = service1c(prenom);
    ...
    final ListenableFuture<Chien> fooFuture = service2c("Lassie", FEMALE);
    ...
    return Futures.allAsList(chienFuture, fooFuture);
}

Elle permet également :

  • transform, qui renvoie un ListenableFuture qui est le résultat de l'application d'une AsyncFunction sur les éléments initialement remontés ;
  • allAsList, qui permet de renvoyer une liste de ListenableFuture ;
  • successfulAsList, qui renvoie une liste des ListenableFuture qui ont réussi (cf. plus haut).

Une AsyncFunction nécessite d'implémenter la méthode « apply(..) » qui est appelée de manière asynchrone et qui permet de transformer une valeur.

Il est également possible d'utiliser une « CheckedFuture », qui étend les fonctionnalités de « ListenableFuture » avec une méthode « get » pouvant lancer une exception si tout ne se passe pas comme prévu : interuption, timeout, etc. Les méthodes additionnelles sont :

  • checkedGet(), qui renvoie une exception spécifique correspondant aux InterruptedException, CancellationException et ExecutionException ;
  • checkedGet(long timeout, TimeUnit unit), qui renvoie des exceptions comme checkedGet(), mais qui renvoie aussi une TimeoutException quand le temps est dépassé.

Et si les exceptions proposées ne conviennent pas, il est simple de partir d'un « ListenableFuture » déjà existant pour en faire un « CheckedFuture » qui lève une exception maison :

Avec mon exception maison
Sélectionnez

final String prenom = "Milou";
final ListenableFuture<Chien> chienFuture = service1c(prenom);

final Function<Exception , MaChienException> func = new Function<Exception, MaChienException>() {

    @Override
    public MaChienException apply(Exception e) {

        return new MaChienException(e.getMessage());
    }
};

final CheckedFuture<Chien,MaChienException> cf = Futures.makeChecked(chienFuture, func);

IV. Conclusion

Maintenant que vous savez mettre en cache et jouer sur la concurrence, vous êtes en mesure de développer des applications super rapides, grâce à Guava. N'hésitez pas à consulter les autres épisodes de cette série pour découvrir les fonctionnalités fantastiques de la bibliothèque.

Vos retours nous aident à améliorer nos publications. N'hésitez donc pas à commenter cet article sur le forum : 2 commentaires Donner une note à l'article (5)

V. Remerciements

D'abord, j'adresse mes remerciements à l'équipe Guava, chez Google, pour avoir développé une bibliothèque aussi utile et pour la maintenir. Je n'oublie pas tous les contributeurs qui participent notamment sur le forum Guava.

Plus spécifiquement en ce qui concerne cet article, je tiens à remercier l'équipe de Developpez.com et plus particulièrement Bernard Le Roux, Ricky81, Mickael Baron, Yann Caron, Logan, et Vincent Viale.

VI. Annexes

VI-A. Liens

Guava : https://code.google.com/p/guava-libraries/

Article « Simplifier le code de vos beans Java à l'aide de Commons Lang, Guava et Lombok » :
http://thierry-leriche-dessirier.developpez.com/tutoriels/java/simplifier-code-guava-lombok/

Blog sur Guava : http://blog.developpez.com/guava/

VI-B. Liens personnels

Retrouvez ma page et mes autres articles sur Developpez.com à l'adresse
http://thierry-leriche-dessirier.developpez.com/#page_articlesTutoriels

Suivez-moi sur Twitter : @thierryleriche(https://twitter.com/thierryleriche)@thierryleriche

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2013 Thierry Leriche-Dessirier. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.