The work on improving my old Java Caching Benchmarks continues. This post takes a closer look at the aspect of eviction efficiency. For this comparison we take Guava and EHCache2 and compare them to the new kids on the block, Caffeine and cache2k.
How (not) to benchmark
To simulate a real cache scenario an obvious approach would be to construct a benchmark like this:
Cache<Integer, Integer> cache = ....
int[] trace = ...
for (int i = 0; i < trace.length; i++) {
Integer value = cache.get(trace[i]);
if (value == null) {
Thread.sleep(10);
cache.put(i, i);
}
}
The idea is to have an array of cache keys and request the keys from the cache. The array represents either some random pattern or real cache operations recorded from an application. If the value is not available from the cache, it is a cache miss and the value needs to be computed or loaded from a database. To simulate the miss penalty we sleep for 10 milliseconds.
Of course, Thread.sleep is unreliable and will produce jitter, especially in the area of 10 milliseconds.
But that is not the point. Let’s say we had chosen 10 milliseconds because the average latency of
a database access in our infrastructure is 10 milliseconds. But what happens if we don’t query a
local database but an external web service and have 300 milliseconds latency? If we benchmarked
with a higher simulated latency, clearly the throughput of the cache that achieves a better hit rate
would get better, too.
The performance effect of a cache miss inside the JVM and the local machine can vary per scenario, too. The value might be computed inside the VM, the data may be retrieved from a local SSD or the request goes over the network. We cannot simulate or try out all these variations. For our benchmarks the idea is to isolate the different aspects. In this blog post I will focus on the efficiency of the eviction algorithm. In the next blog post I will take a deeper look at the runtime overhead of eviction.
The Caches
In this benchmark run we focus on these cache implementations:
- Google Guava Cache, Version 19, default settings
- EHCache, Version 2.10.1, default settings
- Caffeine, Version 2.3.0, default settings
- cache2k, Version 0.26-BETA, default settings
The JVM settings and hardware are irrelevant for this benchmark, since we focus on the efficiency of the eviction algorithm and not on the runtime performance.
Caffeine needs two special setup parameters for the benchmark:
int maxSize = ...
Caffeine.newBuilder()
.maximumSize(maxSize)
.executor(Runnable::run)
.initialCapacity(_maxElements)
...
With the executor parameter the eviction happens within the same thread. If this weren’t specified, the
eviction would be delayed and the cache would hold more than the configured maximum size, thus leading
to wrong results. With the initialCapacity parameter Caffeine is instructed to collect data for the eviction
algorithm right from the beginning. Setting this parameter is a courtesy to Caffeine, because otherwise
Caffeine cannot perform well for very short traces.
Why are Hazelcast, Infinispan or XY-Cache not included? Because I currently focus on cache implementations that are optimised for use in the local JVM heap. The benchmark concentrates on the effects inside a single VM.
Eviction Algorithms
Guava and EHCache use LRU. Caffeine uses a recently invented algorithm called Window-TinyLFU. Cache2k uses an improved and optimised version of the Clock-Pro algorithm.
Still, LRU (least recently used) is the most used eviction algorithm today. It is simple and easy to implement and achieves quite useful results. Another algorithm is LFU (least frequently used). Instead of deciding based on the recency it decides based on the frequency. Entries that were accessed more often will be kept. Although yielding better performance for some workloads, LFU is not useful as a universal algorithm.
On the other hand LRU, not addressing the frequency aspect, is problematic, too. For example a scan over the whole data set will sweep out entries in the cache that were frequently accessed before and after.
Caffeine and cache2k use modern algorithms that address both aspects, the recency and the frequency. The basic idea is to detect entries accessed more frequently and protect them in a hot set, while evicting entries only seen once faster than LRU would do.
Only cache2k uses an algorithm that allows fully concurrent access. To achieve that, no temporal information about the access is recorded, only an access counter is incremented.
Going into more detail here is beyond this article. For more information the various papers are the best source.
Traces, Traces, Traces
To compare the cache efficiency we use different access patterns and run them against a cache implementation with a specific size limit. For this article I used traces from four different sources.
The traces Cpp, Glimpse, Multi2 and Sprite come from the authors of these papers:
- J. Kim, J. Choi, J. Kim, S. Noh, S. Min, Y. Cho, and C. Kim, “A Low-Overhead, High-Performance Unified Buffer Management Scheme that Exploits Sequential and Looping References”, 4th Symposium on Operating System Design & Implementation, October 2000.
- D. Lee, J. Choi, J. Kim, S. Noh, S. Min, Y. Cho and C. Kim, “On the Existence of a Spectrum of Policies that Subsumes the Least Recently Used (LRU) and Least Frequently Used (LFU) Policies”, Proceeding of 1999 ACM SIGMETRICS Conference, May 1999.
All these traces are short and have limited practical value today. These traces are used in a lot of papers about cache eviction algorithms. That is why I always run them and compare the results to the outcomes of the papers.
The OLTP trace was used within the ARC paper:
- Nimrod Megiddo and Dharmendra S. Modha, “ARC: A Self-Tuning, Low Overhead Replacement Cache,” USENIX Conference on File and Storage Technologies (FAST 03), San Francisco, CA, pp. 115-130, March 31-April 2, 2003.
The traces UmassFinancial and UmassWebsearch are available from the UMass Trace Repository.
All traces from the sources above are disk I/O traces, which means all traces contain a sequence of disk block numbers. The block size used in these traces is usually 512 bytes. But the access pattern of a Java object cache is different from that of a disk buffer in an operating system. We need to be aware of this mismatch when comparing Java caches with these traces.
To fill the gap of missing middleware traces, Web07, Web12, OrmAccessBusy and OrmAccessNight are traces from a Java application provided by headissue GmbH. The traces are made public in the cache2k benchmark project on GitHub. As far as I know, these are the only publicly available access traces of a Java application.
The Graphs
The graphs show the achieved hit rate for a cache implementation with a specified cache size. The cache implementations LRU and CLOCK are reference implementations from the cache2k-benchmark package.
OPT is a hypothetical cache implementation that can see into the future and only evicts entries that will never be used again or whose reuse is farthest away. This is also known as Belady’s algorithm. To calculate OPT the simulator code of Caffeine is used.
RAND is the simplest eviction algorithm possible. It selects the eviction candidate at random. The result shown is actually not from ‘perfect random’, but from a realistic cache implementation. The evicted entry is chosen by a forward moving pointer inside the cache hash table.
Random Pattern
We start with a completely random pattern of 1000 different values.
Trace length: 3 million, Unique keys: 1000, Maximum possible hit rate: 99.97%
OPT = optimal replacement (Belady), RAND = random eviction; LRU and CLOCK are reference implementations from cache2k-benchmark.
The main purpose of this pattern is to find out whether one of the implementations is cheating. Since the pattern is random, no algorithm has a chance to predict what will be accessed in the future. A cache implementation can always cheat the benchmark by caching a few more entries than the requested limit. If some implementation achieves a better hit rate for a random pattern, that means it is caching more entries. As we can see, the results are almost identical for all implementations. All fine.
Oltp Trace
A trace from an OLTP workload, used by the authors of the ARC algorithm.
Trace length: 914145, Unique keys: 186880, Maximum possible hit rate: 79.56%
OPT = optimal replacement (Belady), RAND = random eviction; LRU and CLOCK are reference implementations from cache2k-benchmark.
UmassFinancial2 Trace
A trace from the Umass Trace Repository, described as: “I/O traces from OLTP applications running at two large financial institutions”. From the original trace only the first 1 million requests are used. The trace contains I/O requests for contiguous blocks. From each request only the first block address is used. This should make the trace data more relevant to the access of objects.
Trace length: 1 million, Unique keys: 102742, Maximum possible hit rate: 89.73%
OPT = optimal replacement (Belady), RAND = random eviction; LRU and CLOCK are reference implementations from cache2k-benchmark.
UmassWebSearch1 Trace
A trace from the Umass Trace Repository, described as: “I/O traces from a popular search engine”. From the original trace only the first 1 million requests are used. The trace contains I/O requests for contiguous blocks. From each request only the first block address is used. This should make the trace data more relevant to the access of objects.
The low possible hit rate is quite atypical for this trace.
Trace length: 1 million, Unique keys: 470248, Maximum possible hit rate: 52.98%
OPT = optimal replacement (Belady), RAND = random eviction; LRU and CLOCK are reference implementations from cache2k-benchmark.
OrmAccessBusytime Trace
This trace represents requests from a Java application to an object relational mapper for unique instances of entities. The trace was extracted during a busy period at daytime. The requested objects are a mixture of products and user data. Besides the user activity, some analytical jobs also happen in short periodic bursts.
Trace length: 5 million, Unique keys: 76349, Maximum possible hit rate: 85.61%
OPT = optimal replacement (Belady), RAND = random eviction; LRU and CLOCK are reference implementations from cache2k-benchmark.
Web12 Trace
The trace represents requests to product detail pages of an e-commerce site in December, the busiest month. For the trace we just use integer numbers. Each unique URL is represented by one number.
Trace length: 95607, Unique keys: 13756, Maximum possible hit rate: 85.61%
OPT = optimal replacement (Belady), RAND = random eviction; LRU and CLOCK are reference implementations from cache2k-benchmark.
The Zipfian Pattern
The Zipfian pattern is a random distribution, but the probability of each value follows Zipf’s law. The result is a typical long-tail distribution with a head of ‘hot’ values and a tail of ‘cold’ values. This pattern is used quite often for comparing caches. The pattern generator used is from the YCSB benchmark.
Trace length: 10 million, Unique keys: 10000, Maximum possible hit rate: 99.9%
OPT = optimal replacement (Belady), RAND = random eviction; LRU and CLOCK are reference implementations from cache2k-benchmark.
More Graphs…
The above is only a subset of all traces. The complete set of graphs is available at Java Caching Benchmarks 2016 - Part 2 - The Graphs.
Conclusion
The new kids on the block, Caffeine and cache2k, perform superior to Guava and EHCache2 when operated
with a useful cache size. Cache2k seems to be ahead for analytical workloads. Other
workloads are in favour of Caffeine (UmassWebSearch1). I assume that Caffeine keeps hot data longer
than cache2k when the workload shifts.
In case the cache is too small, the scenario may become ‘LRU-friendly’
as we can see in the OrmAccessBusytime graph. Caffeine has a weak spot here. For cache sizes below 5000 it
actually performs below random eviction. Cache2k is also affected by this, but stays closely above random,
so at least it sometimes makes a useful decision. After informing Ben Manes, the author of Caffeine, he confirmed
this and is working on an improved version.
The new eviction algorithms of cache2k and Caffeine have a lot of internal tuning parameters. At the end of the day, it depends on how these parameters are set and which traces are considered more important to optimise for. Vice versa, it is always possible to bias a benchmark by selecting traces your favourite cache looks best with.
As stated in the last blog post, I am the author of cache2k. The goal of the benchmarks is to see how cache2k performs and to be able to detect performance regressions while developing and see in which areas to improve. Cache2k is optimised towards highly concurrent read accesses, as shown in the last blog post. Because of that engineering direction, there is less information available for the eviction algorithm. This is a general disadvantage for achieving the highest eviction efficiency. This comparison shows that it is well compensated.
The Missing Trace
For these benchmarks altogether 10 different traces are used. While other benchmarks pick only two or three traces, this shows a more diverse result and detects weak spots. But still this is not a useful or representative set of traces. We need more traces hitting a Java cache under different scenarios.
Update 2026: Typo and wording fixes, switch to UK English.