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:
- siamo disposti a consumare memoria RAM al fine di migliorare l’accesso ai dati;
- ci aspettiamo di accedere più volte allo stesso dato;
- 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
private static ItemService service = new ItemService(); private static ConcurrentMap<Long, Item> cache = new ConcurrentHashMap<Long, Item>(); public static Item findItem( Long id ) { Item item = cache.get(id); if ( item == null ) { item = service.find(id); if ( item != null ) { cache.putIfAbsent( id, item); } } return item; } |
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
è 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.
1 2 3 4 5 6 7 8 9 |
public class ItemCacheLoader extends CacheLoader<Long,Item> { private ItemService service = new ItemService(); @Override public Item load(Long key) throws Exception { Item item = service.find( key ); return item != null ? item : new Item(); } } |
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.
1 2 3 4 5 6 7 8 |
ItemService service = new ItemService(); CacheLoader.from( new Function<Long, Item>() { @Override public @Nullable Item apply(@Nullable Long key) { return service.find( key ); } }); |
Utilizzando invece l’interfaccia Supplier
il valore viene ottenuto, indipendentemente dalla chiave, invocando il metodo get()
.
1 2 3 4 5 6 7 8 |
ItemService service = new ItemService(); CacheLoader.from( new Supplier<Item>() { @Override public Item get() { return service.find( 0L ); } }); |
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.
1 2 3 4 |
LoadingCache<Long, Item> cache = CacheBuilder.newBuilder() .expireAfterWrite(10, TimeUnit.MINUTES) .maximumSize(1000) .build(new ItemCacheLoader()); |
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
.
1 2 3 4 5 6 7 8 9 |
for ( int i = 0; i<10; i++ ) { Long id = Long.valueOf( random.nextInt( 10 ) ); System.out.println( "Searching for the item number " + id ); Item item = cache.get( id ); System.out.println( "Item returned " + item ); } |
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.