Skip to content
cruftex.net
Go back

Java Caching Benchmarks Part 3 - The Finals

In the third article about benchmarking caching libraries it is finally time for a benchmark scenario which stresses the caches in different aspects. Also, we will analyse whether the different approaches to eviction in cache2k have any negative effects and whether their implementation is robust and of production quality (spoiler: Yes, it is!).

Impatient readers can skip to the Conclusion.

In the rest of the article the following points are discussed:

Table of contents

Open Table of contents

Benchmark Setup

Benchmark code is available in the GitHub project cache2k benchmark.

Environment

Compared Caches and Versions

We will compare cache2k with these ‘cache’ implementations:

JMH Setup

The JMH command line arguments -f 2 -wi 5 -w 30s -i 3 -r 30s result in:

Five warmup iterations of 30 seconds each lead to 2:30 minutes of warmup. A longer warmup is needed especially for the benchmark runs with large heap sizes (10M cache entries) to allow the garbage collector to adapt and have steadier behaviour.

Threads and Cores

The number of threads is equal to the number of cores available to the JVM. To disable cores we can use the Linux CPU hotplug feature. This way only enabled processors are exposed by the OS and the output of the Java method Runtime.getRuntime().availableProcessors() is equal to the number of usable physical processor cores. No hyperthreading is in effect.

Plots Units and Confidence

In the plots below, units are in SI (base 10), which means 1MB is 10^6 bytes. This ensures the unit prefixes for operations/s and bytes/s are the same.

Every bar has a confidence interval associated with it. This interval does not just represent the upper and lower bounds of a measured value, but shows a range of potential values. The confidence interval is calculated by JMH with a level of 99.9% (which means the likelihood that the actual value is within the shown interval is 99.9%).

A Comprehensive Benchmark

Part 1 of the series focused on comparing cache implementations to hash maps, in which case the test scenario did not trigger eviction, while Part 2 focused on benchmarking different eviction algorithms. Both benchmarks were designed to test two different aspects of caching in isolation and they have given interesting insights into detailed aspects.

However, the outcome may be totally different in other scenarios, such as when concurrent threads utilise the eviction algorithm as opposed to only one thread as in Part 2.

We have combined a few such aspects into one benchmark:

The access pattern is artificially generated via the so-called Zipfian distribution. This access pattern represents a simulation of a skewed sequence typically found in applications these days. The Zipfian distribution is widely used for benchmarking key/value stores. The algorithm was originally described in “Quickly Generating Billion-Record Synthetic Databases”, Jim Gray et al, SIGMOD 1994.

In a few other cache benchmarks, the access sequence and key objects are calculated in advance, so that the benchmark only covers the performance of the cache access. This approach was tested in different variations but proved to be problematic in a few cases. If the sequence is too small, eviction algorithms can adapt to it and show unrealistically high hit rates. Furthermore, a separate sequence is needed for each thread. To avoid any adaptation effects on repetition, the sequences must be much longer than the cache size. Big cache sizes in the benchmark are around 10 million entries, which makes this infeasible. The best solution for this proved to be the online generation of the access sequence. The Zipfian generator implementation from the Yahoo! YCSB benchmark was improved for fast and low overhead generation by replacing the random number generator used with the fast random sequence generator XorShift1024StarRandomGenerator of the DSI utilities. This means the sequence generation and the creation of the integer objects for the cache keys are included in the performance measurements.

The Zipfian sequence is used in variations to yield different cache hit rates. Zipfian factor 10 signifies the generation of a key number space 10 times bigger than the cache size. A certain Zipfian factor yields approximately the same hit rate for different cache sizes.

The cache operates in read through mode. A cache miss results in a call to a cache loader, which adds a penalty by burning CPU cycles via JMH’s black hole: Blackhole.consumeCPU(1000). Other benchmarks use Thread.sleep(10) (or something similar) to sleep for 10 milliseconds and simulate I/O latency. Using Thread.sleep() for such a small time may result in inconsistency. Such code would be impacted by the operating system scheduler.

Benchmark source file: ZipfianSequenceLoadingBenchmark.java.

Whiskers show the 99.9 % confidence interval reported by JMH.cache2kCaffeineGuavaEhCache2100K entries1M entries10M entries01M2M3M4M5M6Moperations per secondcache2k · 100K entries: 6.13M ops/s ± 84.74k ops/sCaffeine · 100K entries: 4.24M ops/s ± 677.63k ops/sGuava · 100K entries: 3.7M ops/s ± 148.59k ops/sEhCache2 · 100K entries: 1.54M ops/s ± 7.25k ops/scache2k · 1M entries: 5.62M ops/s ± 20.04k ops/sCaffeine · 1M entries: 4.49M ops/s ± 82.3k ops/sGuava · 1M entries: 3.78M ops/s ± 107.32k ops/sEhCache2 · 1M entries: 1.51M ops/s ± 36.9k ops/scache2k · 10M entries: 5.24M ops/s ± 148k ops/sCaffeine · 10M entries: 4.43M ops/s ± 183.8k ops/sGuava · 10M entries: 3.43M ops/s ± 146.75k ops/sEhCache2 · 10M entries: 1.6M ops/s ± 49.65k ops/s

Whiskers show the 99.9 % confidence interval reported by JMH.

ZipfianSequenceLoadingBenchmark, operations per second by cache size at 4 threads and Zipfian factor 10. Raw data: ZipfianSequenceLoadingBenchmark-bySize-4x10.dat

CPU profiling with a cache size of 1M and Zipfian factor 10, 4 threads and cache2k shows that 35% of the CPU cycles are spent in the black hole. If this time were used for the loader, hit rates of the cache implementations would improve. As explained before, the throughput is also determined by the speed of the random number generator. Benchmarking the random number generator and the generation of the integer keys alone results in 23 million operations per second for the CMS collector and 13 million operations per second for the G1 collector. Interpreting the graph and concluding that the fastest cache is about 50% faster than its competitor would be wrong (cache2k vs. Caffeine with 100K entries), since the benchmark code itself has a significant overhead. Comparing only the time spent in the cache code, the performance difference would be bigger.

Let’s look at the achieved hit rates:

Whiskers show the 99.9 % confidence interval reported by JMH.cache2kCaffeineGuavaEhCache2100K entries1M entries10M entries010 %20 %30 %40 %50 %60 %70 %80 %90 %effective hit rate in %cache2k · 100K entries: 82.34 % ± 0.01 %Caffeine · 100K entries: 82.18 % ± 0.02 %Guava · 100K entries: 77.01 % ± 0.01 %EhCache2 · 100K entries: 76.03 % ± 0.75 %cache2k · 1M entries: 84.55 % ± 0 %Caffeine · 1M entries: 84.39 % ± 0.01 %Guava · 1M entries: 79.91 % ± 0.01 %EhCache2 · 1M entries: 79.79 % ± 0.03 %cache2k · 10M entries: 86.01 % ± 0.13 %Caffeine · 10M entries: 85.94 % ± 0.22 %Guava · 10M entries: 82.11 % ± 0.02 %EhCache2 · 10M entries: 82.03 % ± 0.02 %

Whiskers show the 99.9 % confidence interval reported by JMH.

ZipfianSequenceLoadingBenchmark, effective hit rate by cache size at 4 threads and Zipfian factor 10. Raw data: ZipfianSequenceLoadingBenchmarkEffectiveHitrate-bySize-4x10.dat

Caffeine and cache2k have significantly higher hit rates by about 4%, cache2k is slightly better than Caffeine by about 0.15%.

Another benchmark is analysed with different numbers of threads and CPU cores. As explained earlier, the number of available CPU cores is equal to the number of benchmark threads.

Let’s look at the results for one, two and four cores:

Whiskers show the 99.9 % confidence interval reported by JMH.cache2kCaffeineGuavaEhCache21 thread2 threads4 threads01M2M3M4M5M6Moperations per secondcache2k · 1 thread: 1.73M ops/s ± 8.12k ops/sCaffeine · 1 thread: 683.88k ops/s ± 5.93k ops/sGuava · 1 thread: 1.1M ops/s ± 1.09k ops/sEhCache2 · 1 thread: 770.17k ops/s ± 8.38k ops/scache2k · 2 threads: 3.2M ops/s ± 33.41k ops/sCaffeine · 2 threads: 1.48M ops/s ± 35.34k ops/sGuava · 2 threads: 2.15M ops/s ± 30.54k ops/sEhCache2 · 2 threads: 964.29k ops/s ± 13.86k ops/scache2k · 4 threads: 5.62M ops/s ± 20.04k ops/sCaffeine · 4 threads: 4.49M ops/s ± 82.3k ops/sGuava · 4 threads: 3.78M ops/s ± 107.32k ops/sEhCache2 · 4 threads: 1.51M ops/s ± 36.9k ops/s

Whiskers show the 99.9 % confidence interval reported by JMH.

ZipfianSequenceLoadingBenchmark, operations per second by thread count with cache size 1M and Zipfian factor 10. Raw data: ZipfianSequenceLoadingBenchmark-byThread-1Mx10.dat

EHCache2 does not scale well in this scenario with the additional cores, which is probably caused by additional locking overhead in the blocking read through configuration.

Caffeine has low performance with one or two cores but better performance with four cores. This is most likely caused by the fact that Caffeine uses multiple threads for the eviction. With one or a few cores available, the inter thread communication produces overhead and causes delays.

By varying the size of the Zipfian distribution we can yield different hit rates. The chart below shows the performance with different Zipfian factors:

Whiskers show the 99.9 % confidence interval reported by JMH.cache2kCaffeineGuavaEhCache2Zipfian factor 5Zipfian factor 10Zipfian factor 2001M2M3M4M5M6M7Moperations per secondcache2k · Zipfian factor 5: 6.76M ops/s ± 84.25k ops/sCaffeine · Zipfian factor 5: 5M ops/s ± 530.51k ops/sGuava · Zipfian factor 5: 4.38M ops/s ± 33.3k ops/sEhCache2 · Zipfian factor 5: 1.81M ops/s ± 15.45k ops/scache2k · Zipfian factor 10: 5.62M ops/s ± 20.04k ops/sCaffeine · Zipfian factor 10: 4.49M ops/s ± 82.3k ops/sGuava · Zipfian factor 10: 3.78M ops/s ± 107.32k ops/sEhCache2 · Zipfian factor 10: 1.51M ops/s ± 36.9k ops/scache2k · Zipfian factor 20: 4.81M ops/s ± 71.16k ops/sCaffeine · Zipfian factor 20: 3.95M ops/s ± 119.58k ops/sGuava · Zipfian factor 20: 3.31M ops/s ± 37.45k ops/sEhCache2 · Zipfian factor 20: 1.34M ops/s ± 11.46k ops/s

Whiskers show the 99.9 % confidence interval reported by JMH.

ZipfianSequenceLoadingBenchmark, operations per second by Zipfian factor with cache size 1M at 4 threads. Raw data: ZipfianSequenceLoadingBenchmark-byFactor-1M-4.dat

The corresponding effective hit rates per cache are:

Whiskers show the 99.9 % confidence interval reported by JMH.cache2kCaffeineGuavaEhCache2Zipfian factor 5Zipfian factor 10Zipfian factor 20010 %20 %30 %40 %50 %60 %70 %80 %90 %effective hit rate in %cache2k · Zipfian factor 5: 88.47 % ± 0 %Caffeine · Zipfian factor 5: 88.43 % ± 0.02 %Guava · Zipfian factor 5: 85.03 % ± 0.01 %EhCache2 · Zipfian factor 5: 84.92 % ± 0.03 %cache2k · Zipfian factor 10: 84.55 % ± 0 %Caffeine · Zipfian factor 10: 84.39 % ± 0.01 %Guava · Zipfian factor 10: 79.91 % ± 0.01 %EhCache2 · Zipfian factor 10: 79.79 % ± 0.03 %cache2k · Zipfian factor 20: 80.97 % ± 0.01 %Caffeine · Zipfian factor 20: 80.67 % ± 0.02 %Guava · Zipfian factor 20: 75.42 % ± 0.01 %EhCache2 · Zipfian factor 20: 75.28 % ± 0.04 %

Whiskers show the 99.9 % confidence interval reported by JMH.

ZipfianSequenceLoadingBenchmark, effective hit rate by Zipfian factor with cache size 1M at 4 threads. Raw data: ZipfianSequenceLoadingBenchmarkEffectiveHitrate-byFactor-1M-4.dat

cache2k is optimised in such a way that there is minimal overhead at high hit rates. A few processes are delayed as long as possible until eviction. That’s why the relative advantage of cache2k becomes smaller for lower hit rates.

We also analysed another benchmark run with the new G1 collector. Let’s look at the first statistic with a different collector implementation:

Whiskers show the 99.9 % confidence interval reported by JMH.cache2kCaffeineGuavaEhCache2100K entries1M entries10M entries01M2M3M4M5M6Moperations per secondcache2k · 100K entries: 5.53M ops/s ± 179.97k ops/sCaffeine · 100K entries: 4.21M ops/s ± 242.51k ops/sGuava · 100K entries: 3.68M ops/s ± 26k ops/sEhCache2 · 100K entries: 1.49M ops/s ± 14.11k ops/scache2k · 1M entries: 4.89M ops/s ± 438.37k ops/sCaffeine · 1M entries: 2.36M ops/s ± 82.48k ops/sGuava · 1M entries: 1.61M ops/s ± 277.66k ops/sEhCache2 · 1M entries: 1.42M ops/s ± 17.47k ops/scache2k · 10M entries: 1.88M ops/s ± 85.36k ops/sCaffeine · 10M entries: 1.64M ops/s ± 75.01k ops/sGuava · 10M entries: 483.14k ops/s ± 333.79k ops/sEhCache2 · 10M entries: 867.99k ops/s ± 80.24k ops/s

Whiskers show the 99.9 % confidence interval reported by JMH.

ZipfianSequenceLoadingBenchmark, operations per second by cache size at 4 threads and Zipfian factor 10 and G1 collector. Raw data: ZipfianSequenceLoadingBenchmark-bySize-4x10.dat

With a cache size of around 1M entries cache2k profits highly from its low allocation rates. All other cache implementations have a higher allocation rate, resulting in lower throughput. With a cache size of 10M, cache2k and Guava face a higher loss, because with this cache size these two cache implementations cause more work for G1 than the other implementations. In the next benchmark, this impact becomes even more visible.

Worst Case Eviction Performance

cache2k uses an eviction algorithm which may have different performance outcomes depending on the access sequence. We will analyse another benchmark to check the most unfavourable working conditions. This is to make sure that there are no unexpected drawbacks when used in varying production scenarios.

cache2k uses the eviction algorithm Clock-Pro (described in USENIX’05: “CLOCK-Pro: An Effective Improvement of the CLOCK Replacement”), which scans through a circular list of entries (the clock) for an eviction candidate. This algorithm has a theoretical worst case performance of O(n), which means eviction performance will decrease linearly with the number of cached items. cache2k incorporates the following changes to improve eviction efficiency and worst case runtime:

Improved Clock-Pro eviction using two separate clocks

Nevertheless, the question of the theoretical categorisation of O(n) remains. However, the theoretical big-O notation only means there is no mathematical proof that the worst case performance is in a better big-O category. Theory gives us an indication that the worst case performance may be a problem, so what does it look like in practice? Let’s quote Richard P. Feynman: It doesn’t matter how beautiful your theory is, it doesn’t matter how smart you are. If it doesn’t agree with experiment, it’s wrong. One prominent example where the theoretical performance does not match the practical performance is the QuickSort algorithm, as discussed in Why is quicksort better than mergesort?.

To simulate a worst case scenario we use a random access pattern. Because of the uniform distribution across the key space, cache2k with Clock-Pro will need a longer scan to find a good eviction candidate. While there can be artificially constructed sequences for a known cache size and internal tuning parameters which might trigger even longer scans, we believe that a random sequence yields the worst eviction performance that can be observed in practice.

Besides evaluating the behaviour of cache2k, a benchmark with a random pattern can be used to verify that caches have comparable eviction policies. When the size parameter is honoured correctly, all caches are expected to produce the same hit rate.

Similar to the benchmark described before, the access sequence is generated by a separate random generator per thread. The fast random sequence generator XorShift1024StarRandomGenerator of the DSI utilities is used for this purpose. In this case a cache miss gets no extra penalty. As the effective hit rate is identical across cache implementations, the penalty would only change the performance result by a constant factor. So in this benchmark the performance is solely determined by the sequence generator and the cache operations.

Whiskers show the 99.9 % confidence interval reported by JMH.cache2kCaffeineGuavaEhCache250 % target hit rate80 % target hit rate95 % target hit rate02M4M6M8M10M12M14Moperations per secondcache2k · 50 % target hit rate: 4.45M ops/s ± 22.87k ops/sCaffeine · 50 % target hit rate: 4.25M ops/s ± 65.17k ops/sGuava · 50 % target hit rate: 4.49M ops/s ± 30.41k ops/sEhCache2 · 50 % target hit rate: 2.17M ops/s ± 29.96k ops/scache2k · 80 % target hit rate: 8.01M ops/s ± 274.76k ops/sCaffeine · 80 % target hit rate: 8.07M ops/s ± 330.36k ops/sGuava · 80 % target hit rate: 5.92M ops/s ± 623.89k ops/sEhCache2 · 80 % target hit rate: 4.3M ops/s ± 49.95k ops/scache2k · 95 % target hit rate: 13.71M ops/s ± 175.22k ops/sCaffeine · 95 % target hit rate: 11.57M ops/s ± 125.35k ops/sGuava · 95 % target hit rate: 7.41M ops/s ± 431.2k ops/sEhCache2 · 95 % target hit rate: 8.44M ops/s ± 237.59k ops/s

Whiskers show the 99.9 % confidence interval reported by JMH.

RandomSequenceBenchmark, operations per second by hit rate at 4 threads and 1M cache size. Raw data: RandomSequenceBenchmark-byHitrate-4-1M.dat

Let’s also verify that the effective hit rate is on the same level:

Whiskers show the 99.9 % confidence interval reported by JMH.cache2kCaffeineGuavaEhCache250 % target hit rate80 % target hit rate95 % target hit rate010 %20 %30 %40 %50 %60 %70 %80 %90 %100 %effective hit rate in %cache2k · 50 % target hit rate: 50 % ± 0.01 %Caffeine · 50 % target hit rate: 50 % ± 0.01 %Guava · 50 % target hit rate: 50 % ± 0.01 %EhCache2 · 50 % target hit rate: 49.99 % ± 0.02 %cache2k · 80 % target hit rate: 80 % ± 0.01 %Caffeine · 80 % target hit rate: 80.01 % ± 0 %Guava · 80 % target hit rate: 80 % ± 0.01 %EhCache2 · 80 % target hit rate: 80 % ± 0.01 %cache2k · 95 % target hit rate: 95 % ± 0 %Caffeine · 95 % target hit rate: 95 % ± 0 %Guava · 95 % target hit rate: 95 % ± 0.01 %EhCache2 · 95 % target hit rate: 95 % ± 0 %

Whiskers show the 99.9 % confidence interval reported by JMH.

RandomSequenceBenchmark, effective hit rate by thread count at 4 threads and 1M cache size. Raw data: RandomSequenceBenchmarkEffectiveHitrate-byHitrate-4-1M.dat

To see how the cache size impacts the performance, let’s look at different cache sizes with constant thread count and hit rate:

Whiskers show the 99.9 % confidence interval reported by JMH.cache2kCaffeineGuavaEhCache2100K entries1M entries10M entries01M2M3M4M5M6M7M8Moperations per secondcache2k · 100K entries: 7.05M ops/s ± 201.01k ops/sCaffeine · 100K entries: 6.75M ops/s ± 470k ops/sGuava · 100K entries: 5.17M ops/s ± 37.05k ops/sEhCache2 · 100K entries: 2.87M ops/s ± 338.22k ops/scache2k · 1M entries: 4.45M ops/s ± 22.87k ops/sCaffeine · 1M entries: 4.25M ops/s ± 65.17k ops/sGuava · 1M entries: 4.49M ops/s ± 30.41k ops/sEhCache2 · 1M entries: 2.17M ops/s ± 29.96k ops/scache2k · 10M entries: 3.54M ops/s ± 268.07k ops/sCaffeine · 10M entries: 3.57M ops/s ± 161.98k ops/sGuava · 10M entries: 3.66M ops/s ± 190.7k ops/sEhCache2 · 10M entries: 1.72M ops/s ± 97.11k ops/s

Whiskers show the 99.9 % confidence interval reported by JMH.

RandomSequenceBenchmark, operations per second by cache size at 4 threads and 50 percent target hit rate. Raw data: RandomSequenceBenchmark-bySize-4x50.dat

As we can see, the performance of cache2k is similar to other cache implementations when the cache size is increased.

We also analysed a benchmark run with the new G1 garbage collector:

Whiskers show the 99.9 % confidence interval reported by JMH.cache2kCaffeineGuavaEhCache2100K entries1M entries10M entries01M2M3M4M5M6M7Moperations per secondcache2k · 100K entries: 6.78M ops/s ± 110.96k ops/sCaffeine · 100K entries: 6.35M ops/s ± 362.79k ops/sGuava · 100K entries: 5.1M ops/s ± 143.02k ops/sEhCache2 · 100K entries: 2.86M ops/s ± 30.59k ops/scache2k · 1M entries: 2.77M ops/s ± 549.17k ops/sCaffeine · 1M entries: 3.82M ops/s ± 95.06k ops/sGuava · 1M entries: 3.5M ops/s ± 179.21k ops/sEhCache2 · 1M entries: 2.06M ops/s ± 41.12k ops/scache2k · 10M entries: 347.61k ops/s ± 2.75k ops/sCaffeine · 10M entries: 1.21M ops/s ± 29.85k ops/sGuava · 10M entries: 215.79k ops/s ± 2.9k ops/sEhCache2 · 10M entries: 810.81k ops/s ± 97.21k ops/s

Whiskers show the 99.9 % confidence interval reported by JMH.

RandomSequenceBenchmark, operations per second by cache size at 4 threads and 50 percent target hit rate with G1 collector. Raw data: RandomSequenceBenchmark-bySize-4x50.dat

With the new G1 collector all cache implementations show significantly reduced performance when cache sizes are increased. The implementations cache2k and the Google Guava cache have an even higher performance drop for G1. Doing CPU profiling with cache2k reveals that 30-40% of the CPU time is spent in refinement tasks of G1, which is caused by inter region reference updates. This benchmark shows that throughput oriented applications with lots of reference mutations and larger heaps should not choose the G1 collector. The effects of G1 with large cache sizes need to undergo a deeper analysis in case this becomes more relevant in practice.

This is an extreme test scenario to reveal the limits of cache2k. We unintentionally constructed a scenario where the use of the G1 collector has a rather big performance loss. 10 million entries and a cache operating at a hit rate of 50% would be a rare operating condition. Most likely this can be found in throughput oriented applications, which should better choose the CMS collector. Under normal working conditions, as we have shown above, cache2k achieves a performance gain with G1 compared to other cache libraries.

Average Items Scanned in Clock-Pro

As explained above cache2k uses the Clock-Pro algorithm, which scans the cache entries organised in a circular data structure to select the best entry to evict. There is no upper bound on the number of scanned entries in cache2k. In this chapter we investigate how the scan effort changes with the cache size.

When the cache parameters are known, there is a theoretical chance to construct an artificial access pattern that triggers a full scan over all entries. Such an access pattern could be a vector for DoS attacks. But after a full scan all access counters are zeroed, so the attack sequence needs to be repeated and all cached entries need to be covered. The cost of sending and processing the attack sequence would thus be much higher than the damage caused.

Using the internal counters of cache2k, we can extract the average number of entries scanned per eviction. This allows us to get an insight into the different eviction costs under different working conditions. Let’s look at how different cache sizes affect the number of scans:

Whiskers show the 99.9 % confidence interval reported by JMH.100K entries1M entries10M entries0123456cache2k, scanned entries per evictioncache2k · 100K entries: 6.004 ± 0.001cache2k · 1M entries: 6.004 ± 0.002cache2k · 10M entries: 6.005 ± 0

Whiskers show the 99.9 % confidence interval reported by JMH.

RandomSequenceBenchmark, scan count by cache size at 4 threads and 80 percent target hit rate. Raw data: RandomSequenceBenchmarkScanCount-bySize-4x80.dat

The results are rather surprising. Scan counts are almost equal for the same hit rate for any cache size. Raw values for reference:

The modified Clock-Pro algorithm in cache2k works well: For a worst case access sequence the scan count does not increase significantly when the cache becomes bigger.

For different hit rates the scan counts look like this:

Whiskers show the 99.9 % confidence interval reported by JMH.50 % target hit rate80 % target hit rate95 % target hit rate0246810121416182022cache2k, scanned entries per evictioncache2k · 50 % target hit rate: 2.423 ± 0.001cache2k · 80 % target hit rate: 6.004 ± 0.002cache2k · 95 % target hit rate: 21.371 ± 0.015

Whiskers show the 99.9 % confidence interval reported by JMH.

RandomSequenceBenchmark, scan count by hit rate at 4 threads and 1M cache size. Raw data: RandomSequenceBenchmarkScanCount-byHitrate-4x1M.dat

The scan count increases for higher hit rates because all the cache contents become ‘hotter’. The amount of additional scanning when hit rates become higher can be tuned by some internal parameters. As we can see with this benchmark, the tuning parameters are chosen in a way that the additional scanning work in the eviction does not outweigh the performance gains of the improved hit rate. With high hit rates cache2k still has less overhead than other cache libraries.

Finally, we will take a look at the scan counts for the first benchmark:

Whiskers show the 99.9 % confidence interval reported by JMH.100K entries1M entries10M entries00.20.40.60.811.21.41.61.8cache2k, scanned entries per evictioncache2k · 100K entries: 1.622 ± 0.003cache2k · 1M entries: 1.619 ± 0.007cache2k · 10M entries: 1.563 ± 0.157

Whiskers show the 99.9 % confidence interval reported by JMH.

ZipfianSequenceLoadingBenchmark, scan count by cache size at 4 threads and Zipfian factor 10. Raw data: ZipfianSequenceLoadingBenchmarkScanCount-bySize-4x10.dat

For a typical skewed access sequence the average scan count is very low. In the tested scenarios it is below two. For realistic (non-random) access sequences, the experiment shows that the scan count decreases for higher cache sizes. The result of this practical scenario is in contradiction to the theoretical evaluation.

Memory Consumption

For the evaluation of the memory consumption we use the first benchmark (ZipfianSequenceLoadingBenchmark). The details of the metrics and the measurement procedure were presented in a previous blog post, The 6 Memory Metrics You Should Track in Your Java Benchmarks. For concision we concentrate on two metrics only. usedMem_settled represents the used memory that the JVM reports at the end of the benchmark. This is a static measure and does not take into account differences in usage when operations are ongoing. The VmHWM metric represents peak memory consumption as reported by the operating system, thus it also includes dynamic effects like garbage collection.

usedMem_settledVmHWMcache2kCaffeineEhCache2Guava01 GB2 GB3 GB4 GB5 GB6 GBmemoryusedMem_settled · cache2k: 1.31 GB ± 0.9 MBVmHWM · cache2k: 3.28 GB ± 20.4 MBusedMem_settled · Caffeine: 1.17 GB ± 1.6 MBVmHWM · Caffeine: 3.75 GB ± 89 MBusedMem_settled · EhCache2: 1.61 GB ± 0.7 MBVmHWM · EhCache2: 5.91 GB ± 199.2 MBusedMem_settled · Guava: 1.04 GB ± 0.5 MBVmHWM · Guava: 5.15 GB ± 114.6 MB
ZipfianSequenceLoadingBenchmark, memory consumption with 10M cache size at 4 threads and Zipfian factor 5. Raw data: ZipfianSequenceLoadingBenchmarkStaticPeakMemory4-10M-5.dat

In the tested scenario cache2k has an 11% higher static memory consumption than Caffeine. In this scenario the payload data (keys and values) consists only of integer objects. With larger data sizes in practical applications, the difference in static memory consumption between the cache implementations would be much lower.

The peak memory consumption differs more drastically. Cache2k achieves a lower peak memory consumption because of its low allocation rates, which lead to less garbage collector activity. Depending on the cache utilisation, cache size and hit rates, the total memory consumption will differ. Here is a rather extreme example with the G1 collector:

usedMem_settledVmHWMcache2kCaffeineEhCache2Guava01 GB2 GB3 GB4 GB5 GB6 GBmemoryusedMem_settled · cache2k: 149.5 MB ± 0.4 MBVmHWM · cache2k: 1.89 GB ± 5.6 MBusedMem_settled · Caffeine: 132.9 MB ± 0.5 MBVmHWM · Caffeine: 5.32 GB ± 339.5 MBusedMem_settled · EhCache2: 180.8 MB ± 0.3 MBVmHWM · EhCache2: 3.25 GB ± 513.5 MBusedMem_settled · Guava: 121 MB ± 0.4 MBVmHWM · Guava: 2.97 GB ± 539.4 MB
ZipfianSequenceLoadingBenchmark, memory consumption with 1M cache size at 4 threads and Zipfian factor 20. Raw data: ZipfianSequenceLoadingBenchmarkStaticPeakMemory4-1M-5.dat

Interesting side note: Since cache eviction produces garbage, increasing the cache size could have a dramatic effect on the peak memory consumption and can actually lower the amount of memory needed, as less garbage means the garbage collector needs less breathing space.

Looking only at the static used heap memory can give the wrong idea about the physical memory actually used in operation.

Jar File Sizes

Finally, let’s look at the jar file sizes:

For Guava cache users who don’t need the whole Guava library, The New York Times has provided a jar file which contains only the cache functionality. For Android or other environments where the download size matters, Guava and cache2k are the best choices. Compared to the others, the Caffeine jar is rather big due to the high number of generated classes.

Benchmark Shortcomings

All of our benchmark scenarios intentionally stress the cache heavily to amplify differences in the cache implementations and reveal weaknesses. The outcome in real applications will be much better.

Here is a summary of noteworthy points that may influence the real world performance:

The last point cannot be stressed enough. Testing with the Zipfian distribution is heavily in favour of caches which keep frequently used entries for a longer time (frequency aspect). As the access pattern is always in the same value range, a cache could actually stop replacing entries after a set of hot entries is established. The next benchmarks should modify the value ranges to test the speed of adaptation, e.g. in a phase change of the application.

Running all benchmarks with the CMS and G1 collectors with the needed precision takes four days on one machine. Especially the large cache size of 10M needs longer benchmark run times. Because of the time required for a run, we could only test three variations each of thread count, cache size and hit rate.

Future Work

A lot of work went into this blog post and at this point it is important to publish results rather than to continue digging deeper. This is what we left for the future:

Conclusion

Cache2k uses an alternative eviction algorithm (Clock-Pro) that reduces the latency overhead of the cache access to a minimum. After analysing the benchmark results we can see that cache2k has better performance than other caches when operating at high hit rates, approximately above 80%. With lower hit rates, which cause more eviction activity, the performance of cache2k is at least on the same level as other cache implementations.

Since the eviction runtime of the Clock-Pro algorithm could theoretically grow linearly with the cache size, cache2k includes improvements over the original algorithm. Our experiments have proven that the performance of the implementation is stable in the worst possible scenarios. Because of this intensive testing we can say that the implementation has production quality.

As the achieved hit rates show, Caffeine and cache2k both use an effective and modern eviction algorithm that performs better than LRU for the Zipfian sequence.

Because of its low allocation rates, cache2k performs better with the upcoming G1 garbage collector than other caches in typical working conditions.

The memory overhead differs for each cache implementation. When compared to Guava or Caffeine, cache2k has slightly more static memory overhead. However, in high throughput scenarios cache2k can achieve a much lower total memory consumption because of its low allocation rates.

Note

Update 2026: Typo and wording fixes, switch to UK English.



Previous Post
The 6 Memory Metrics You Should Track in Your Java Benchmarks
Next Post
Why I decided to stay in Australia, with its darkest moment yet to come