Caffeine: High-Performance Local Caching in Java
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.