Introducción a la cafeína

1. Introducción

En este artículo, vamos a echar un vistazo a Caffeine, una biblioteca de almacenamiento en caché de alto rendimiento para Java .

Una diferencia fundamental entre un caché y un mapa es que un caché desaloja los elementos almacenados.

Una política de desalojo decide qué objetos deben eliminarse en un momento dado. Esta política afecta directamente la tasa de aciertos de la caché, una característica crucial de las bibliotecas de caché.

La cafeína utiliza la política de desalojo de Window TinyLfu , que proporciona una tasa de aciertos casi óptima .

2. Dependencia

Necesitamos agregar la dependencia de cafeína a nuestro pom.xml :

 com.github.ben-manes.caffeine caffeine 2.5.5 

Puede encontrar la última versión de cafeína en Maven Central.

3. Rellenar caché

Centrémonos en las tres estrategias de Caffeine para la población de caché : carga manual, sincrónica y asincrónica.

Primero, escribamos una clase para los tipos de valores que almacenaremos en nuestra caché:

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. Llenado manual

En esta estrategia, colocamos valores manualmente en la caché y los recuperamos más tarde.

Inicialicemos nuestro caché:

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

Ahora, podemos obtener algo de valor del caché usando el método getIfPresent . Este método devolverá nulo si el valor no está presente en la caché:

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

Podemos poblar el caché manualmente usando el método put :

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

También podemos obtener el valor usando el método get , que toma una función junto con una clave como argumento. Esta función se utilizará para proporcionar el valor de reserva si la clave no está presente en el caché, que se insertaría en el caché después del cálculo:

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

El método get realiza el cálculo de forma atómica. Esto significa que el cálculo se realizará solo una vez, incluso si varios subprocesos solicitan el valor simultáneamente. Por eso es preferible utilizar get a getIfPresent .

A veces necesitamos invalidar algunos valores en caché manualmente:

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

3.2. Carga síncrona

Este método de carga de la caché toma una función, que se utiliza para inicializar valores, similar al método de obtención de la estrategia manual. Veamos cómo podemos usar eso.

En primer lugar, necesitamos inicializar nuestro caché:

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

Ahora podemos recuperar los valores usando el método get :

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

También podemos obtener un conjunto de valores usando el método getAll :

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

Los valores se recuperan de la función de inicialización de back-end subyacente que se pasó al método de compilación . Esto hace posible utilizar la caché como fachada principal para acceder a los valores.

3.3. Carga asincrónica

Esta estrategia funciona igual que la anterior, pero realiza operaciones de forma asincrónica y devuelve un CompletableFuture con el valor real:

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

Podemos usar los métodos get y getAll , de la misma manera, teniendo en cuenta que devuelven 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 tiene una API rica y útil, sobre la que puede leer más en este artículo.

4. Desalojo de valores

La cafeína tiene tres estrategias para el desalojo de valor : basada en el tamaño, basada en el tiempo y basada en la referencia.

4.1. Desalojo basado en tamaño

Este tipo de desalojo asume que el desalojo ocurre cuando se excede el límite de tamaño configurado de la caché . Hay dos formas de obtener el tamaño : contar los objetos en el caché o obtener su peso.

Veamos cómo podemos contar objetos en la caché . Cuando se inicializa la caché, su tamaño es igual a cero:

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

Cuando agregamos un valor, el tamaño obviamente aumenta:

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

Podemos agregar el segundo valor a la caché, lo que conduce a la eliminación del primer valor:

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

Vale la pena mencionar que llamamos al método cleanUp antes de obtener el tamaño de la caché . Esto se debe a que el desalojo de la caché se ejecuta de forma asincrónica y este método ayuda a esperar la finalización del desalojo .

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

En este artículo, nos familiarizamos con la biblioteca de almacenamiento en caché de Caffeine para Java. Vimos cómo configurar y poblar una caché, así como también cómo elegir una política de expiración o actualización adecuada según nuestras necesidades.

El código fuente que se muestra aquí está disponible en Github.