Fading Coder

An Old Coder’s Final Dance

Home > Tech > Content

Caffeine: High-Performance Local Caching in Java

Tech 2

Modern applications frequently mix distributed caches (such as Redis or Memcached) with fast in-process caches to minimize latency and reduce backand load. Caffeine is a high-throughput, low-latency in-JVM cache for Java that improves upon older libraries like Guava Cache with better eviction accuracy and concurrency design. Spring Framework 5 (encluding Spring Boot 2) adopted Caffeine as the preferred local cache (SPR-13797: https://jira.spring.io/browse/SPR-13797).

Performance overview

Caffeine’s benchmarks use JMH (the standard Java microbenchmark harness) to avoid JVM warmup and measurement pitfalls. Typical scenarios include:

  • 8 threads reading (100% reads)
  • 6 readers + 2 writers (75% reads / 25% writes)
  • 8 writers (100% writes)

Across these patterns, Caffeine consistently outperforms Guava Cache due to its windowed TinyLFU admission policy and lock-free, segmented design. Refer to Caffeine’s official benchmarks for up-to-date numbers.

Setup

Maven dependency (Java 11+ for 3.x):

<!-- https://mvnrepository.com/artifact/com.github.ben-manes.caffeine/caffeine -->
<dependency>
  <groupId>com.github.ben-manes.caffeine</groupId>
  <artifactId>caffeine</artifactId>
  <version>3.1.8</version>
</dependency>

Guava compaitbility types (optional):

<dependency>
  <groupId>com.github.ben-manes.caffeine</groupId>
  <artifactId>guava</artifactId>
  <version>3.1.8</version>
</dependency>

Building a cache

import com.github.benmanes.caffeine.cache.*;
import java.time.Duration;

Cache<String, Object> cache = Caffeine.newBuilder()
    .initialCapacity(256)              // pre-allocate internal data structures
    .maximumSize(1_000)                // entry count cap (cannot be used with maximumWeight)
    .expireAfterWrite(Duration.ofSeconds(3)) // TTL measured from last write
    .recordStats()                     // collect hit/miss/eviction metrics
    .build();

Key builder options:

  • initialCapacity: expected starting size
  • maximumSize: max number of entries (mutually exclusive with maximumWeight)
  • maximumWeight + weigher: capacity by weight (e.g., bytes) instead of count
  • expireAfterWrite: expire N time after create/update
  • expireAfterAccess: expire N time after last read/write
  • expireAfter(Expiry): custom per-entry expiration
  • refreshAfterWrite: refresh entries in background after N time (with a loader)
  • weakKeys, weakValues, softValues: reference-based caching
  • recordStats: enable metrics

Note: If both expireAfterWrite and expireAfterAccess are set, an entry expires when either condition is met (whichever comes first). maximumSize and maximumWeight cannot be combined.

Putting and retrieving entries

Manual population:

Cache<String, String> manual = Caffeine.newBuilder().build();
manual.put("user:17", "Bob");
System.out.println(manual.getIfPresent("user:17")); // Bob

Compute-on-miss with a mapping function:

Cache<String, String> computed = Caffeine.newBuilder().build();

String value = computed.get("feature:beta", k -> loadFromStore(k));
System.out.println(value);

private static String loadFromStore(String key) {
  // In practice, call DB/HTTP/etc.
  return "v1-" + key;
}

Synchronous loading (LoadingCache):

LoadingCache<String, String> loading = Caffeine.newBuilder()
    .expireAfterWrite(Duration.ofMinutes(5))
    .build(k -> loadFromStore(k));

System.out.println(loading.get("config:foo"));

Asynchronous loading (AsyncLoadingCache):

import java.util.concurrent.*;

AsyncLoadingCache<String, String> async = Caffeine.newBuilder()
    .expireAfterWrite(Duration.ofMinutes(5))
    .buildAsync(k -> CompletableFuture.supplyAsync(() -> loadFromStore(k)));

CompletableFuture<String> future = async.get("profile:42");
String profile = future.join();

Eviction and expiration policies

Size-based eviction

Evict by entry count:

LoadingCache<String, String> sized = Caffeine.newBuilder()
    .maximumSize(1)
    .build(k -> "val:" + k);

sized.put("k1", "v1");
sized.put("k2", "v2");
sized.put("k3", "v3");

sized.cleanUp(); // process pending maintenance work
System.out.println(sized.getIfPresent("k1")); // likely null
System.out.println(sized.getIfPresent("k2")); // likely null
System.out.println(sized.getIfPresent("k3")); // likely v3

Evict by total weight:

Cache<String, byte[]> weighted = Caffeine.newBuilder()
    .maximumWeight(10_000)
    .weigher((String k, byte[] v) -> v.length)
    .build();

weighted.put("img:1", new byte[6_000]);
weighted.put("img:2", new byte[6_000]); // eviction may occur to honor max weight

Time-based expiration

expireAfterWrite:

LoadingCache<String, String> byWrite = Caffeine.newBuilder()
    .expireAfterWrite(Duration.ofSeconds(3))
    .build(k -> "val:" + k);

byWrite.put("token", "abc");
Thread.sleep(1_000);
System.out.println(byWrite.getIfPresent("token")); // abc
Thread.sleep(1_000);
System.out.println(byWrite.getIfPresent("token")); // abc
Thread.sleep(1_500);
System.out.println(byWrite.getIfPresent("token")); // null (expired)

expireAfterAccess:

LoadingCache<String, String> byAccess = Caffeine.newBuilder()
    .expireAfterAccess(Duration.ofSeconds(3))
    .build(k -> "val:" + k);

byAccess.put("session", "s1");
Thread.sleep(1_000);
System.out.println(byAccess.getIfPresent("session")); // s1
Thread.sleep(1_000);
System.out.println(byAccess.getIfPresent("session")); // s1
Thread.sleep(1_000);
System.out.println(byAccess.getIfPresent("session")); // s1
Thread.sleep(3_100);
System.out.println(byAccess.getIfPresent("session")); // null (no access for >3s)

Custom expiration with Expiry:

import com.github.benmanes.caffeine.cache.Expiry;
import java.util.concurrent.TimeUnit;

LoadingCache<String, String> customTtl = Caffeine.newBuilder()
    .removalListener((k, v, cause) ->
        System.out.println("removed key=" + k + ", cause=" + cause))
    .expireAfter(new Expiry<String, String>() {
      @Override
      public long expireAfterCreate(String key, String value, long currentTime) {
        // Return duration in nanoseconds
        return key.startsWith("token:")
            ? TimeUnit.SECONDS.toNanos(1)
            : TimeUnit.SECONDS.toNanos(5);
      }
      @Override
      public long expireAfterUpdate(String key, String value, long currentTime, long currentDuration) {
        // Keep the remaining TTL on update
        return currentDuration;
      }
      @Override
      public long expireAfterRead(String key, String value, long currentTime, long currentDuration) {
        // Do not change TTL on reads
        return currentDuration;
      }
    })
    .build(k -> "val:" + k);

Note: expireAfter(Expiry) cannot be combined with expireAfterWrite or expireAfterAccess.

Reference-based caching

Cache<Object, Object> refs = Caffeine.newBuilder()
    .weakKeys()    // keys are weakly referenced
    .weakValues()  // values are weakly referenced
    // .softValues() // optional; subject to GC pressure and may be eagerly reclaimed
    .build();

Reference-based options allow the GC to reclaim entries; behavior depends on memory pressure and reachability.

Removal, cleanup, and stats

Invalidate entries manually:

Cache<String, String> c = Caffeine.newBuilder().build();
c.put("a", "1");

c.invalidate("a");                     // single key
c.invalidateAll(java.util.Arrays.asList("x", "y")); // batch
c.invalidateAll();                       // everything

Listen for removals and examine causes (EXPIRED, SIZE, COLLECTED, MANUAL, REPLACED):

Cache<String, String> withListener = Caffeine.newBuilder()
    .maximumSize(2)
    .removalListener((k, v, cause) -> System.out.println(k + " removed: " + cause))
    .build();

withListener.put("k1", "v1");
withListener.put("k2", "v2");
withListener.put("k3", "v3"); // may evict one due to size

Collect and print statistics:

Cache<String, Integer> statsCache = Caffeine.newBuilder()
    .recordStats()
    .build();

statsCache.put("n", 42);
statsCache.getIfPresent("n");    // hit
statsCache.getIfPresent("m");    // miss
System.out.println(statsCache.stats());

Guava Cache migration hints

  • Most Guava CacheBuilder concepts map directly to Caffeine’s Caffeine builder (size, time-based expiration, reference types, loaders, listeners).
  • Caffeine offers a Guava compatibility module if you need drop-in types during migration.
  • Caffeine’s eviction policy uses W-TinyLFU to improve hit rates under skewed access patterns while maintaining strong concurrency.

Related Articles

Understanding Strong and Weak References in Java

Strong References Strong reference are the most prevalent type of object referencing in Java. When an object has a strong reference pointing to it, the garbage collector will not reclaim its memory. F...

Comprehensive Guide to SSTI Explained with Payload Bypass Techniques

Introduction Server-Side Template Injection (SSTI) is a vulnerability in web applications where user input is improper handled within the template engine and executed on the server. This exploit can r...

Implement Image Upload Functionality for Django Integrated TinyMCE Editor

Django’s Admin panel is highly user-friendly, and pairing it with TinyMCE, an effective rich text editor, simplifies content management significantly. Combining the two is particular useful for bloggi...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.