Implementare una Cache in java

In ambito informatico le cache sono oggetti molto utili in un’ampia varietà di casi d’uso. Ad esempio è auspicabile utilizzare una cache quando il recupero o calcolo di un determinato valore è particolarmente oneroso e si ha la necessità di manipolare tale valore più volte nel corso dell’esecuzione di una applicazione.

Esistono diverse soluzioni avanzate, sia open source che commerciali come EHCache o Memcached, per l’implementazione di cache distribuite, quindi condivise tra vari nodi, e che utilizzano supporti esterni come file system per la conservazione dei dati. In questo articolo però, limitiamo il discorso all’implementazione di cache gestite in memoria su singolo nodo, utilizzabili cioè quando si verificano le seguenti condizioni:

  1. siamo disposti a consumare memoria RAM al fine di migliorare l’accesso ai dati;
  2. ci aspettiamo di accedere più volte allo stesso dato;
  3. non abbiamo la necessità di conservare più dati di quanto la memoria RAM ne riesca a contenere.

Concurrent Hash Map

La via più rapida ed immediata per implementare una cache locale è l’utilizzo della struttura dati ConcurrentHashMap. Tale struttura implementa sostanzialmente una hash table col vantaggio, non trascurabile, di essere thread-safe e di gestire in modo efficiente l’accesso concorrente ai dati. I punti caratteristici di tale struttura dati possono infatti essere così riassunti:

  • La struttura di dati sottostante è un Hashtable.
  • La classe ConcurrentHashMap è thread-safe, cioè thread multipli possono operare su un singolo oggetto senza complicazioni.
  • Le operazioni di lettura possono avvenire in modo concorrente senza necessità di bloccare i thread (cosa non possibile con HashMap).
  • Gli oggetti ConcurrentHashMap sono suddivise in certo numero di segmenti in base al livello di concorrenza. Il livello di concorrenza predefinito è 16.
  • Per le operazioni di aggiornamento viene eseguito il lock del particolare segmento a cui il thread desidera operare. Questo tipo di meccanismo di lock è noto come lock del segmento o del bucket. Conseguentemente 16 sono le operazioni di aggiornamento che possono essere eseguite in modo concorrente.
  • La struttura non ammette il valore null come chiave o valore.

Utilizzare una ConcurrentHashMap per implementare una semplice cache è abbastanza immediato. Supponiamo di disporre di un servizio ItemService che recupera da una base dati gli Item di un ordine con tempi di accesso eccessivi. Un possibile codice che utilizza tale struttura come cache è il seguente:

Di tale, semplice, implementazione vogliamo evidenziare due particolarità. Innanzitutto la necessità di verificare che l’oggetto non sia null prima di inserirlo nella cache, pena una eccezione a runtime. Poi l’utilizzo del metodo putIfAbsent() che, in modo atomico, verifica che la chiave non sia già presente in tabella prima di inserirla. Questo è importante perché, ovviamente, l’intero metodo findItem() non è thread-safe.

Sebbene di semplice realizzazione, una tale implementazione non offre diverse delle funzionalità che generalmente sono richieste ad una cache, come ad esempio il numero massimo di elementi che può contenere o la politica di rimozione degli elementi che non sono più acceduti o che devono fare spazio a nuovi dati.

Guava Cache

Guava Cache offre maggiore flessibilità e potenza rispetto a HashMap o ConcurrentHashMap, ma non è pesante (e neppure robusto) come EHCache o Memcached, poiché funziona esclusivamente in memoria. La particolarità di tale cache è che offre un meccanismo per l’auto-popolamento, grazie al quale i valori che non sono presenti quando richiesti vengono recuperati o calcolati, quindi archiviati nella struttura dati. Ciò implica che una operazione di get() non restituirà mai oggetti null.

Due sono le classi fondamentali di Guava Cache: CacheLoader e CacheBuilder. La prima specifica come caricare i valori mentre la seconda è utilizzata per creare la cache con le impostazioni desiderate.

CacheLoader

CacheLoader è una classe astratta che specifica come calcolare o caricare valori nella cache, se non presenti. Esistono due modi per creare una nuova istanza di CacheLoader. Il primo consiste nell’estendere la classe CacheLoader<K, V>, sovrascrivendo il metodo load(), che specifica come generare il nuovo valore per una determinata chiave. 

Il secondo prevede l’utilizzo del metodo factory statico CacheLoader.from, al quale deve essere fornita una implementazione delle interfaccie Function o Supplier. Quando si fornisce un oggetto Function, la funzione viene applicata alla chiave per calcolare o recuperare il valore associato.

Utilizzando invece l’interfaccia Supplier il valore viene ottenuto, indipendentemente dalla chiave, invocando il metodo get().

CacheBuilder

CacheBuilder è utilizzata per creare nuove istanze di cache supportando lo stile fluent di costruzione del codice e dando la possibilità di impostare le seguenti proprietà della cache:

  • La dimensione massima della cache (cache size limit). L’algoritmo LRU (Least Recently Used) è utilizzato per la rimozione gli elementi quando la cache è piena.
  • Il tempo di scadenza di un valore dall’ultimo accesso.
  • Il tempo di scadenza di un valore dopo essere state scritte o aggiornate.
  • Un oggetto di RemovalListener che può ricevere eventi una volta che una voce viene rimossa dalla cache.
  • Il livello di concorrenza della cache (il valore predefinito è 4).

Quest’ultima opzione è importante in quanto viene utilizzata per partizionare la tabella internamente, in modo che gli aggiornamenti possano avvenire senza conflitto. L’impostazione ideale sarebbe il numero massimo di thread che potrebbero potenzialmente accedere alla cache in modo contemporaneo.

Il modo canonico di interrogare una LoadingCache è attraverso il metodo get(), il quale restituirà un valore già memorizzato nella cache, oppure utilizzerà il CacheLoader definito per caricare atomicamente un nuovo valore nella cache. Poiché il CacheLoader potrebbe generare un’eccezione, il metodo get() può sollevare a sua volta una ExecutionException.

In conclusione la Guava Cache presenta alcune funzionalità molto interessanti. La decisione di utilizzare una Guava Cache si riduce davvero al compromesso tra disponibilità ed utilizzo della memoria rispetto all’aumento delle prestazioni.

Codice Sorgente

Il codice sorgente con l’esempio presentato è scaricabile qui cache.

How useful was this post?

Click on a star to rate it!

Average rating / 5. Vote count:

No votes so far! Be the first to rate this post.

As you found this post useful...

Follow us on social media!