Google Guava : cache

Google Guava provides a lot of programming shortcuts. It contains a simplified version of String and primitives management. These features seem a little bit basic. However, Google's library simplifies also the work with more complex stuff as cache.

Data Engineering Design Patterns

Looking for a book that defines and solves most common data engineering problems? I'm currently writing one on that topic and the first chapters are already available in πŸ‘‰ Early Release on the O'Reilly platform

I also help solve your data engineering problems πŸ‘‰ contact@waitingforcode.com πŸ“©

In this article we'll talk about cache feature implemented in Google Guava. At the begin we'll describe theoretical aspects of caching in this library. After that we'll focus on more practical cases by illustrating the cache integration by some JUnit tests.

Cache in Google Guava

There are several cache handling solution in Java, beginning with normal Map objects, passing to Ehcache or Memcache, and terminating by customized cache implementations. Google Guava is another cache solution available on the market. It doesn't contain the same features as Ehcache or Memcache. For example, Google Guava's cache doesn't support persistent storage, as Ehcache. However, they have some common points, as the time to life (TTL) feature and evictions (removing cache depending on chosen strategy).

Before talking technically about cache in Google Guava, let's introduce its main concepts:
- incremental cache : it means if that requested object is in the cache, it is returned immediately. If the object is absent in the cache, it's loaded and put in it. Thanks to this mechanism, cache size grows incrementally when the new objects are demanded.
- computing cache : this concept means that Google Guava's cache promotes the way of calculating absent cache entries through provided CacheLoader rather than manual adding of these entries. Note that manual adding is still possible.
- eviction: eviction is a process which consists on controlling how much data is stored in the cache. Google Guava's cache provides the control based on time (cache expires after specified timeout, for example 10 seconds), size (cache doesn't accept more entries when it reached the maximum weight) or reference (cache allows to store the entries as soft or weak references which are no so strong to remain in memory for a long time). Eviction can also be made manually, through invalidate methods.

Now we can take a look on technical side of Google Guava cache. Cache entries are represented by the implementations of com.google.common.cache.Cache interface. It's a superinterface for LoadingCache. It represents cache managed thanks to cache loaders. A cache loader is an abstract class CacheLoader which implementations are used to compute and retrieve cache entries for provided key through V load(K key) method. Cache loader must be defined inside cache builders. A CacheBuilder's instance is used to construct Cache objects by providing the configuration for eviction strategy, removal listeners and cache loaders. Cache initialization is made thanks to CacheBuilder's build(CacheLoader<? super K1, V1> loader) method.

Implement cache in Google Guava

As a test case we'll use here the image of animals reserve which can accept limited number of species within limited number of time (the first test method). The second reserve will accept all species, for forever, but only when its total weight isn't bigger than specified value (the second test method):

public class CacheTest {

  @Test
  public void normalCache() {
    /**
      * We make a cache that expires 2 seconds after the write operation those maximum size (maximum elements in the cache) is 4.
      * 
      * In additionally, this cache has a removal listener, trigerred every time when one entry is removed. 
      * In this case, we'll put removed entry into a Map of removed entries.
      */
    final Map<String, Animal> removedEntries = new HashMap<String, Animal>();
    final List<String> loadedEntries = new ArrayList<String>();
    LoadingCache reserve = CacheBuilder.newBuilder()
      .expireAfterAccess(2, TimeUnit.SECONDS) 
      .maximumSize(4)
      .removalListener(new RemovalListener<String, Animal>() {
        @Override
        public void onRemoval(RemovalNotification<String, Animal> entry) {
          removedEntries.put(entry.getKey(), entry.getValue());
        }
      })
      .build(new CacheLoader<String, Animal>() {
        @Override
        public Animal load(String animalName) throws Exception {
          loadedEntries.add(animalName);
          // imagine that here we make a long operation to retrieve Animal objects to put into the cache
          return new Animal(animalName);
        }
    });
    /**
      * The first test consists on putting 4 entries and check immediately after if all 4 entries are present in the cache.
      */
    Animal cow = reserve.get("cow1");
    Animal lion = reserve.get("lion1");
    Animal eagle = reserve.get("eagle1");
    Animal monkey = reserve.get("monkey1");
    assertTrue("Cache size should be 4 but is "+reserve.asMap().size(), reserve.asMap().size() == 4);

    /**
      * The second test consists on waiting 4 seconds (bigger than cache TTL ("time to live")) and 
      * check how many entries are still present in the cache.
      */
    Thread.sleep(4000);
    assertTrue("Cow shouldn't be present anymore in the cache", reserve.getIfPresent("cow1") == null);
    assertTrue("Lion shouldn't be present anymore in the cache", reserve.getIfPresent("lion1") == null);
    assertTrue("Eagle shouldn't be present anymore in the cache", reserve.getIfPresent("eagle1") == null);
    assertTrue("Monkey shouldn't be present anymore in the cache", reserve.getIfPresent("monkey1") == null);
    
     /**
      * We can go further and test what happens if we try to put more than 4 entries into cache. Normally the cache should 
      * acts as FIFO queue where first cached entries are first removed entries.
      * 
      * So if in our case we put, in order: cow, lion, eagle, monkey, tiger and gorilla, we should have only eagle, 
      * monkey, tiger and gorilla in the cache.
      */
    Animal cow = reserve.get("cow2");
    Animal lion = reserve.get("lion2");
    Animal eagle = reserve.get("eagle2");
    Animal monkey = reserve.get("monkey2");
    Animal tiger = reserve.get("tiger2");
    Animal gorilla = reserve.get("gorilla2");
    Animal tiger2 = reserve.get("tiger2");

    assertTrue("Cow shouldn't be present anymore in the cache", reserve.getIfPresent("cow2") == null);
    assertTrue("Lion shouldn't be present anymore in the cache", reserve.getIfPresent("lion2") == null);
    assertTrue("Eagle should be present  in the cache", reserve.getIfPresent("eagle2") != null);
    assertTrue("Monkey should be present  in the cache", reserve.getIfPresent("monkey2") != null);
    assertTrue("Tiger should be present in the cache", reserve.getIfPresent("tiger2") != null);
    assertTrue("Gorilla should be present in the cache", reserve.getIfPresent("gorilla2") != null);

     /**
      * Thanks to removedEntries map we can check if removalListener was correctly called.
      */
    String[] expectedRemovedEntries = {"cow1", "lion1", "eagle1", "monkey1", "cow2", "lion2"};
    for (String entryKey : expectedRemovedEntries) {
            assertTrue("removedEntries doesn't contain '"+entryKey+"'", removedEntries.containsKey(entryKey));
            removedEntries.remove(entryKey);
    }
    assertTrue("removedEntries should be 0 but is "+removedEntries.size(), removedEntries.size() == 0);

     /**
      * Test for invalidating all entries. After the call of invalidateAll(), the size of cache should be 0.
      */
    assertTrue("Before invalidateAll, cache's size should be 4 but is "+reserve.asMap().size(), reserve.asMap().size() == 4);
    reserve.invalidateAll();
    assertTrue("Cache size should be 0 but is "+reserve.asMap().size(), reserve.asMap().size() == 0);

     /**
      * In one of previous cases we read the same entry immediately, one call after another:
      * <pre>
      * Animal tiger = reserve.get("tiger2");
      * Animal gorilla = reserve.get("gorilla2");
      * Animal tiger2 = reserve.get("tiger2");
      * </pre>
      * 
      * Now we check if this entry wasn't loaded twice.
      */
    String[] expectedLoadedEntries = {"cow1", "lion1", "eagle1", "monkey1", "cow2", "lion2", "eagle2", "monkey2", "tiger2", "gorilla2"};
    assertTrue("expectedLoadedEntries.length ("+expectedLoadedEntries.length+") should be the same as the lenght of loadedEntries ("+loadedEntries.size()+")", loadedEntries.size() == expectedLoadedEntries.length);
    for (String entryKey : expectedLoadedEntries) {
      assertTrue("Expected entry ("+entryKey+") is absent in loadedEntries list", 
        loadedEntries.indexOf(entryKey) > -1);
    }
  }
        
  @Test
  public void weightCache() {
    /**
      * In this part we'll test an interesting feature of cache "weight". To implement this attribute, 
      * called maximum weight, we must define a Weigher object. A Weigher is an object used to determine
      * the size of every cache entry. If the sum of these sizes are bigger than maximum weight, the first entry 
      * is removed from the cache.
      */
    final Map<String, Animal> removedEntries = new HashMap<String, Animal>();
    final List<String> loadedEntries = new ArrayList();
    LoadingCache<String, Animal> reserve = CacheBuilder.newBuilder()
      .maximumWeight(12)
      .weigher(new Weigher<String, Animal>() {
        @Override
        public int weigh(String key, Animal value) {
          return value.getName().length();
        }
      })
      .removalListener(new RemovalListener<String, Animal>() {
        @Override
        public void onRemoval(RemovalNotification<String, Animal> entry) {
          removedEntries.put(entry.getKey(), entry.getValue());
        }
      })
      .build(new CacheLoader<String, Animal>() {
        @Override
        public Animal load(String animalName) throws Exception {
          loadedEntries.add(animalName);
          // imagine that here we make a long operation to retrieve Animal objects to put into the cache
          return new Animal(animalName);
        }
      });
    reserve.get("cow");
    reserve.get("pig");
    reserve.get("cat");
    reserve.get("ant");
    reserve.get("bee");
    // cow should be removed because at the moment of inserting 'bee', the maximum weight was reached (9+3 = 12)
    assertTrue("Removed entries should contain 'cow'", removedEntries.containsKey("cow"));
  }
        
}


class Animal {
  private String name;
  public Animal(String name) {
    this.name = name;
  }
  public String getName() {
    return this.name;
  }
  
  @Override
  public String toString() {
    return "Animal {"+this.name+"}";
  }
}

Animal reserve example shows the main purposes of Google Guava's cache: computing loading and incremental storage. And thanks to evictions based on the time, cache size or cache weight, we can control the space occupied by cache entries.


If you liked it, you should read:

πŸ“š Newsletter Get new posts, recommended reading and other exclusive information every week. SPAM free - no 3rd party ads, only the information about waitingforcode!