1. Overview
In this tutorial, we'll take a look at cache2k — a lightweight, high-performance, in-memory Java caching library.
2. About cache2k
The cache2k library offers fast access times due to non-blocking and wait-free access to cached values. It also supports integration with Spring Framework, Scala Cache, Datanucleus, and Hibernate.
The library comes with many features, including a set of thread-safe atomic operations, a cache loader with blocking read-through, automatic expiry, refresh-ahead, event listeners, and support for the JCache implementation of the JSR107 API. We'll discuss some of these features in this tutorial.
It's important to note that cache2k is not a distributed caching solution like Infispan or Hazelcast.
3. Maven Dependency
To use cache2k, we need to first add the cache2k-base-bom dependency to our pom.xml:
<dependency> <groupId>org.cache2k</groupId> <artifactId>cache2k-base-bom</artifactId> <version>1.2.3.Final</version> <type>pom</type> </dependency>
4. A Simple cache2k Example
Now, let's see how we can use cache2k in a Java application with the help of a simple example.
Let's consider the example of an online shopping website. Let's suppose that the website is offering a twenty percent discount on all sports products and a ten percent discount on other products. Our goal here is to cache the discount so that we do not calculate it every time.
So, first, we'll create a ProductHelper class and create a simple cache implementation:
public class ProductHelper { private Cache<String, Integer> cachedDiscounts; private int cacheMissCount = 0; public ProductHelper() { cachedDiscounts = Cache2kBuilder.of(String.class, Integer.class) .name("discount") .eternal(true) .entryCapacity(100) .build(); } public Integer getDiscount(String productType) { Integer discount = cachedDiscounts.get(productType); if (Objects.isNull(discount)) { cacheMissCount++; discount = "Sports".equalsIgnoreCase(productType) ? 20 : 10; cachedDiscounts.put(productType, discount); } return discount; } // Getters and setters }
As we can see, we've used a cacheMissCount variable to count the number of times discount is not found in the cache. So, if the getDiscount method uses the cache to get the discount, the cacheMissCount will not change.
Next, we'll write a test case and validate our implementation:
@Test public void whenInvokedGetDiscountTwice_thenGetItFromCache() { ProductHelper productHelper = new ProductHelper(); assertTrue(productHelper.getCacheMissCount() == 0); assertTrue(productHelper.getDiscount("Sports") == 20); assertTrue(productHelper.getDiscount("Sports") == 20); assertTrue(productHelper.getCacheMissCount() == 1); }
Finally, let's take a quick look at the configurations we've used.
The first one is the name method, which sets the unique name of our cache. The cache name is optional and is generated if we don't provide it.
Then, we've set eternal to true to indicate that the cached values do not expire with time. So, in this case, we can choose to remove elements from the cache explicitly. Otherwise, the elements will get evicted automatically once the cache reaches its capacity.
Also, we've used the entryCapacity method to specify the maximum number of entries held by the cache. When the cache reaches the maximum size, the cache eviction algorithm will remove one or more entries to maintain the specified capacity.
We can further explore the other available configurations in the Cache2kBuilder class.
5. cache2k Features
Now, let's enhance our example to explore some of the cache2k features.
5.1. Configuring Cache Expiry
So far, we've allowed a fixed discount for all sports products. However, our website now wants the discount to be available only for a fixed period of time.
To take care of this new requirement, we'll configure the cache expiry using the expireAfterWrite method:
cachedDiscounts = Cache2kBuilder.of(String.class, Integer.class) // other configurations .expireAfterWrite(10, TimeUnit.MILLISECONDS) .build();
Let's now write a test case to check the cache expiry:
@Test public void whenInvokedGetDiscountAfterExpiration_thenDiscountCalculatedAgain() throws InterruptedException { ProductHelper productHelper = new ProductHelper(); assertTrue(productHelper.getCacheMissCount() == 0); assertTrue(productHelper.getDiscount("Sports") == 20); assertTrue(productHelper.getCacheMissCount() == 1); Thread.sleep(20); assertTrue(productHelper.getDiscount("Sports") == 20); assertTrue(productHelper.getCacheMissCount() == 2); }
In our test case, we've tried to get the discount again after the configured duration has passed. We can see that unlike our previous example, the cacheMissCount has been incremented. This is because the item in the cache is expired and the discount is calculated again.
For an advanced cache expiry configuration, we can also configure an ExpiryPolicy.
5.2. Cache Loading or Read-Through
In our example, we've used the cache aside pattern to load the cache. This means we've calculated and added the discount in the cache on-demand in the getDiscount method.
Alternatively, we can simply use the cache2k support for the read-through operation. In this operation, the cache will load the missing value by itself with the help of a loader. This is also known as cache loading.
Now, let's enhance our example further to automatically calculate and load the cache:
cachedDiscounts = Cache2kBuilder.of(String.class, Integer.class) // other configurations .loader((key) -> { cacheMissCount++; return "Sports".equalsIgnoreCase(key) ? 20 : 10; }) .build();
Also, we'll remove the logic of calculating and updating the discount from getDiscount:
public Integer getDiscount(String productType) { return cachedDiscounts.get(productType); }
After that, let's write a test case to make sure that the loader is working as expected:
@Test public void whenInvokedGetDiscount_thenPopulateCacheUsingLoader() { ProductHelper productHelper = new ProductHelper(); assertTrue(productHelper.getCacheMissCount() == 0); assertTrue(productHelper.getDiscount("Sports") == 20); assertTrue(productHelper.getCacheMissCount() == 1); assertTrue(productHelper.getDiscount("Electronics") == 10); assertTrue(productHelper.getCacheMissCount() == 2); }
5.3. Event Listeners
We can also configure event listeners for different cache operations like insert, update, removal, and expiry of cache elements.
Let's suppose we want to log all the entries added in the cache. So, let's add an event listener configuration in the cache builder:
.addListener(new CacheEntryCreatedListener<String, Integer>() { @Override public void onEntryCreated(Cache<String, Integer> cache, CacheEntry<String, Integer> entry) { LOGGER.info("Entry created: [{}, {}].", entry.getKey(), entry.getValue()); } })
Now, we can execute any of the test cases we've created and verify the log:
Entry created: [Sports, 20].
It's important to note that the event listeners execute synchronously except for the expiry events. If we want an asynchronous listener, we can use the addAsyncListener method.
5.4. Atomic Operations
The Cache class has many methods that support atomic operations. These methods are for operations on a single entry only.
Among such methods are containsAndRemove, putIfAbsent, removeIfEquals, replaceIfEquals, peekAndReplace, and peekAndPut.
6. Conclusion
In this tutorial, we've looked into the cache2k library and some of its useful features. We can refer to the cache2k user guide to explore the library further.
As always, the complete code for this tutorial is available over on GitHub.