Introduzione alla caffeina

1. Introduzione

In questo articolo, daremo uno sguardo a Caffeine, una libreria di cache ad alte prestazioni per Java .

Una differenza fondamentale tra una cache e una mappa è che una cache elimina gli elementi archiviati.

Una politica di rimozione decide quali oggetti devono essere eliminati in un dato momento. Questa politica influisce direttamente sul tasso di successo della cache, una caratteristica cruciale della memorizzazione nella cache delle librerie.

Caffeine utilizza la politica di sfratto di Window TinyLfu , che fornisce un tasso di successo quasi ottimale .

2. Dipendenza

Dobbiamo aggiungere la dipendenza dalla caffeina al nostro pom.xml :

 com.github.ben-manes.caffeine caffeine 2.5.5 

Puoi trovare l'ultima versione della caffeina su Maven Central.

3. Popolamento della cache

Concentriamoci sulle tre strategie di Caffeine per il popolamento della cache : caricamento manuale, sincrono e caricamento asincrono.

Per prima cosa, scriviamo una classe per i tipi di valori che memorizzeremo nella nostra cache:

class DataObject { private final String data; private static int objectCounter = 0; // standard constructors/getters public static DataObject get(String data) { objectCounter++; return new DataObject(data); } }

3.1. Popolamento manuale

In questa strategia, inseriamo manualmente i valori nella cache e li recuperiamo in seguito.

Inizializziamo la nostra cache:

Cache cache = Caffeine.newBuilder() .expireAfterWrite(1, TimeUnit.MINUTES) .maximumSize(100) .build();

Ora possiamo ottenere un valore dalla cache utilizzando il metodo getIfPresent . Questo metodo restituirà null se il valore non è presente nella cache:

String key = "A"; DataObject dataObject = cache.getIfPresent(key); assertNull(dataObject);

Possiamo popolare la cache manualmente utilizzando il metodo put :

cache.put(key, dataObject); dataObject = cache.getIfPresent(key); assertNotNull(dataObject);

Possiamo anche ottenere il valore usando il metodo get , che accetta una funzione insieme a una chiave come argomento. Questa funzione verrà utilizzata per fornire il valore di fallback se la chiave non è presente nella cache, che verrebbe inserita nella cache dopo il calcolo:

dataObject = cache .get(key, k -> DataObject.get("Data for A")); assertNotNull(dataObject); assertEquals("Data for A", dataObject.getData());

Il metodo get esegue il calcolo in modo atomico. Ciò significa che il calcolo verrà eseguito una sola volta, anche se più thread richiedono il valore contemporaneamente. Ecco perché l' uso di get è preferibile a getIfPresent .

A volte è necessario invalidare manualmente alcuni valori memorizzati nella cache :

cache.invalidate(key); dataObject = cache.getIfPresent(key); assertNull(dataObject);

3.2. Caricamento sincrono

Questo metodo di caricamento della cache accetta una funzione, che viene utilizzata per inizializzare i valori, simile al metodo get della strategia manuale. Vediamo come possiamo usarlo.

Prima di tutto, dobbiamo inizializzare la nostra cache:

LoadingCache cache = Caffeine.newBuilder() .maximumSize(100) .expireAfterWrite(1, TimeUnit.MINUTES) .build(k -> DataObject.get("Data for " + k));

Ora possiamo recuperare i valori utilizzando il metodo get :

DataObject dataObject = cache.get(key); assertNotNull(dataObject); assertEquals("Data for " + key, dataObject.getData());

Possiamo anche ottenere una serie di valori utilizzando il metodo getAll :

Map dataObjectMap = cache.getAll(Arrays.asList("A", "B", "C")); assertEquals(3, dataObjectMap.size());

I valori vengono recuperati dalla funzione di inizializzazione back-end sottostante che è stata passata al metodo di compilazione . Ciò rende possibile utilizzare la cache come facciata principale per l'accesso ai valori.

3.3. Caricamento asincrono

Questa strategia funziona come la precedente ma esegue le operazioni in modo asincrono e restituisce un CompletableFuture che contiene il valore effettivo:

AsyncLoadingCache cache = Caffeine.newBuilder() .maximumSize(100) .expireAfterWrite(1, TimeUnit.MINUTES) .buildAsync(k -> DataObject.get("Data for " + k));

Possiamo usare i metodi get e getAll , allo stesso modo, tenendo conto del fatto che restituiscono CompletableFuture :

String key = "A"; cache.get(key).thenAccept(dataObject -> { assertNotNull(dataObject); assertEquals("Data for " + key, dataObject.getData()); }); cache.getAll(Arrays.asList("A", "B", "C")) .thenAccept(dataObjectMap -> assertEquals(3, dataObjectMap.size()));

CompletableFuture ha un'API ricca e utile, di cui puoi leggere di più in questo articolo.

4. Sfratto di valori

La caffeina ha tre strategie per l'eliminazione del valore : basata sulle dimensioni, basata sul tempo e basata sui riferimenti.

4.1. Sfratto in base alle dimensioni

Questo tipo di eliminazione presuppone che si verifichi l'eliminazione quando viene superato il limite di dimensione configurato della cache . Esistono due modi per ottenere la dimensione : contare gli oggetti nella cache o ottenere i loro pesi.

Vediamo come possiamo contare gli oggetti nella cache . Quando la cache viene inizializzata, la sua dimensione è uguale a zero:

LoadingCache cache = Caffeine.newBuilder() .maximumSize(1) .build(k -> DataObject.get("Data for " + k)); assertEquals(0, cache.estimatedSize());

Quando aggiungiamo un valore, ovviamente la dimensione aumenta:

cache.get("A"); assertEquals(1, cache.estimatedSize());

Possiamo aggiungere il secondo valore alla cache, che porta alla rimozione del primo valore:

cache.get("B"); cache.cleanUp(); assertEquals(1, cache.estimatedSize());

Vale la pena ricordare che chiamiamo il metodo cleanUp prima di ottenere la dimensione della cache . Questo perché l'eliminazione della cache viene eseguita in modo asincrono e questo metodo aiuta ad attendere il completamento dell'eliminazione .

We can also pass a weigherFunctionto get the size of the cache:

LoadingCache cache = Caffeine.newBuilder() .maximumWeight(10) .weigher((k,v) -> 5) .build(k -> DataObject.get("Data for " + k)); assertEquals(0, cache.estimatedSize()); cache.get("A"); assertEquals(1, cache.estimatedSize()); cache.get("B"); assertEquals(2, cache.estimatedSize());

The values are removed from the cache when the weight is over 10:

cache.get("C"); cache.cleanUp(); assertEquals(2, cache.estimatedSize());

4.2. Time-Based Eviction

This eviction strategy is based on the expiration time of the entry and has three types:

  • Expire after access — entry is expired after period is passed since the last read or write occurs
  • Expire after write — entry is expired after period is passed since the last write occurs
  • Custom policy — an expiration time is calculated for each entry individually by the Expiry implementation

Let's configure the expire-after-access strategy using the expireAfterAccess method:

LoadingCache cache = Caffeine.newBuilder() .expireAfterAccess(5, TimeUnit.MINUTES) .build(k -> DataObject.get("Data for " + k));

To configure expire-after-write strategy, we use the expireAfterWrite method:

cache = Caffeine.newBuilder() .expireAfterWrite(10, TimeUnit.SECONDS) .weakKeys() .weakValues() .build(k -> DataObject.get("Data for " + k));

To initialize a custom policy, we need to implement the Expiry interface:

cache = Caffeine.newBuilder().expireAfter(new Expiry() { @Override public long expireAfterCreate( String key, DataObject value, long currentTime) { return value.getData().length() * 1000; } @Override public long expireAfterUpdate( String key, DataObject value, long currentTime, long currentDuration) { return currentDuration; } @Override public long expireAfterRead( String key, DataObject value, long currentTime, long currentDuration) { return currentDuration; } }).build(k -> DataObject.get("Data for " + k));

4.3. Reference-Based Eviction

We can configure our cache to allow garbage-collection of cache keys and/or values. To do this, we'd configure usage of the WeakRefence for both keys and values, and we can configure the SoftReference for garbage-collection of values only.

The WeakRefence usage allows garbage-collection of objects when there are not any strong references to the object. SoftReference allows objects to be garbage-collected based on the global Least-Recently-Used strategy of the JVM. More details about references in Java can be found here.

We should use Caffeine.weakKeys(), Caffeine.weakValues(), and Caffeine.softValues() to enable each option:

LoadingCache cache = Caffeine.newBuilder() .expireAfterWrite(10, TimeUnit.SECONDS) .weakKeys() .weakValues() .build(k -> DataObject.get("Data for " + k)); cache = Caffeine.newBuilder() .expireAfterWrite(10, TimeUnit.SECONDS) .softValues() .build(k -> DataObject.get("Data for " + k));

5. Refreshing

It's possible to configure the cache to refresh entries after a defined period automatically. Let's see how to do this using the refreshAfterWrite method:

Caffeine.newBuilder() .refreshAfterWrite(1, TimeUnit.MINUTES) .build(k -> DataObject.get("Data for " + k));

Here we should understand a difference between expireAfter and refreshAfter. When the expired entry is requested, an execution blocks until the new value would have been calculated by the build Function.

But if the entry is eligible for the refreshing, then the cache would return an old value and asynchronously reload the value.

6. Statistics

Caffeine has a means of recording statistics about cache usage:

LoadingCache cache = Caffeine.newBuilder() .maximumSize(100) .recordStats() .build(k -> DataObject.get("Data for " + k)); cache.get("A"); cache.get("A"); assertEquals(1, cache.stats().hitCount()); assertEquals(1, cache.stats().missCount());

We may also pass into recordStats supplier, which creates an implementation of the StatsCounter. This object will be pushed with every statistics-related change.

7. Conclusion

In questo articolo, abbiamo familiarizzato con la libreria di caching Caffeine per Java. Abbiamo visto come configurare e popolare una cache, nonché come scegliere una scadenza appropriata o una politica di aggiornamento in base alle nostre esigenze.

Il codice sorgente mostrato qui è disponibile su Github.