StackPractices
intermediate By Mathias Paulenko

Configure Caffeine Cache in Java with Eviction Policies

Set up Caffeine cache in a Java application with size-based, time-based, and weighted eviction policies for high-performance local caching.

Overview

Caffeine is a high-performance Java caching library that outperforms Guava and ConcurrentHashMap by using an efficient eviction policy based on the Window TinyLfu algorithm. It supports size-based, time-based, and weighted eviction, reference-based eviction (weak keys/values), and async loading. Below: configuring Caffeine standalone and with Spring Boot, eviction strategies, cache statistics, and tuning. See also HTTP Cache-Control Headers for APIs and Static Assets.

When to Use This

  • Application-local caching where Redis or Memcached is overkill
  • Read-heavy workloads with predictable access patterns
  • Caching computed values, database lookups, or HTTP responses in a single JVM
  • Spring Boot applications needing @Cacheable with a fast local cache

Prerequisites

  • Java 17+
  • Maven or Gradle
  • Spring Boot 3+ (optional, for Spring integration)

Solution

1. Add Caffeine Dependency

<!-- pom.xml -->
<dependency>
  <groupId>com.github.ben-manes.caffeine</groupId>
  <artifactId>caffeine</artifactId>
  <version>3.1.8</version>
</dependency>

For Spring Boot integration:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
  <groupId>com.github.ben-manes.caffeine</groupId>
  <artifactId>caffeine</artifactId>
</dependency>

2. Basic Cache with Size Eviction

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

Cache<String, User> userCache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(30, TimeUnit.MINUTES)
    .recordStats()
    .build();

// Put
userCache.put("user:123", new User("123", "Alice"));

// Get (returns null if absent)
User user = userCache.getIfPresent("user:123");

// Get with loader (computes and caches if absent)
User loaded = userCache.get("user:456", key -> fetchUserFromDb("456"));

// Invalidate
userCache.invalidate("user:123");
userCache.invalidateAll();

3. Time-Based Eviction

// Evict entries 10 minutes after they were written
Cache<String, String> writeExpireCache = Caffeine.newBuilder()
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .maximumSize(5_000)
    .build();

// Evict entries 5 minutes after they were last accessed
Cache<String, String> accessExpireCache = Caffeine.newBuilder()
    .expireAfterAccess(5, TimeUnit.MINUTES)
    .maximumSize(5_000)
    .build();

// Both: expire 30 min after write, but also if not accessed in 10 min
Cache<String, String> hybridCache = Caffeine.newBuilder()
    .expireAfterWrite(30, TimeUnit.MINUTES)
    .expireAfterAccess(10, TimeUnit.MINUTES)
    .build();

4. Weighted Eviction

When entries have different sizes (e.g., cached responses of varying length):

Cache<String, String> weightedCache = Caffeine.newBuilder()
    .maximumWeight(10_000_000) // 10MB total
    .weigher((String key, String value) -> value.length())
    .build();

weightedCache.put("small", "hello");       // weight: 5
weightedCache.put("large", "x".repeat(100_000)); // weight: 100000

5. Loading Cache (Auto-Populate)

import com.github.benmanes.caffeine.cache.LoadingCache;
import com.github.benmanes.caffeine.cache.Caffeine;

LoadingCache<String, Product> productCache = Caffeine.newBuilder()
    .maximumSize(5_000)
    .expireAfterWrite(15, TimeUnit.MINUTES)
    .refreshAfterWrite(10, TimeUnit.MINUTES) // refresh before expiry
    .build(key -> fetchProductFromDb(key));

// Automatically loads on miss
Product p1 = productCache.get("product:1");
Map<String, Product> batch = productCache.getAll(List.of("product:1", "product:2"));

6. Async Loading Cache

import com.github.benmanes.caffeine.cache.AsyncLoadingCache;

AsyncLoadingCache<String, User> asyncCache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .buildAsync(key -> fetchUserAsync(key));

CompletableFuture<User> future = asyncCache.get("user:789");
User user = future.join();

7. Spring Boot Integration

@Configuration
@EnableCaching
public class CacheConfig {

  @Bean
  public CaffeineCacheManager cacheManager() {
    CaffeineCacheManager manager = new CaffeineCacheManager();
    manager.setCaffeine(Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(30, TimeUnit.MINUTES)
        .recordStats());
    return manager;
  }

  // Per-cache configuration
  @Bean
  public CacheManager multiCacheManager() {
    CaffeineCacheManager manager = new CaffeineCacheManager("users", "products", "config");
    manager.registerCustomCache("users", Caffeine.newBuilder()
        .maximumSize(5_000)
        .expireAfterWrite(10, TimeUnit.MINUTES)
        .build());
    manager.registerCustomCache("products", Caffeine.newBuilder()
        .maximumSize(20_000)
        .expireAfterWrite(30, TimeUnit.MINUTES)
        .build());
    return manager;
  }
}

Use with annotations:

@Service
public class UserService {

  @Cacheable(value = "users", key = "#id")
  public User getUser(String id) {
    return userRepository.findById(id); // Only called on cache miss
  }

  @CachePut(value = "users", key = "#user.id")
  public User updateUser(User user) {
    return userRepository.save(user); // Updates cache
  }

  @CacheEvict(value = "users", key = "#id")
  public void deleteUser(String id) {
    userRepository.deleteById(id); // Removes from cache
  }

  @CacheEvict(value = "users", allEntries = true)
  public void clearUserCache() {
    // Clear entire cache
  }
}

8. Cache Statistics

Cache<String, User> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .recordStats()
    .build();

// After some operations...
com.github.benmanes.caffeine.cache.stats.CacheStats stats = cache.stats();

System.out.println("Hit rate: " + stats.hitRate());
System.out.println("Hits: " + stats.hitCount());
System.out.println("Misses: " + stats.missCount());
System.out.println("Evictions: " + stats.evictionCount());
System.out.println("Average load time: " + stats.averageLoadPenalty() + " ns");
System.out.println("Cache size: " + cache.estimatedSize());

How It Works

  1. Window TinyLfu: Caffeine uses a frequency-based admission policy. New entries go into a small window space. When the main cache is full, the least-frequently-used entry from the main space is compared with the new entry’s frequency — the less frequent one is evicted.
  2. Size eviction: When maximumSize is reached, Caffeine evicts entries based on the TinyLfu policy. Eviction happens asynchronously in batches for performance.
  3. Time eviction: expireAfterWrite sets a fixed TTL from insertion. expireAfterAccess resets the timer on each read. Entries are cleaned up lazily — during reads or scheduled maintenance, not on a timer.
  4. Weighted eviction: The weigher function assigns a cost to each entry. Caffeine maintains the total weight under maximumWeight by evicting the least valuable entries.
  5. Loading cache: The build(loader) variant auto-computes missing entries. refreshAfterWrite triggers a background refresh before expiry, serving stale data while the new value loads.

Variants

Reference-Based Eviction

Allow garbage collector to evict entries when memory is low:

Cache<String, User> cache = Caffeine.newBuilder()
    .weakKeys()        // Keys use weak references — evicted when key is GC'd
    .weakValues()      // Values use weak references
    .build();

Cache<String, User> softCache = Caffeine.newBuilder()
    .softValues()      // Values use soft references — GC evicts under memory pressure
    .build();

Scheduled Cleanup

Force periodic cleanup instead of lazy eviction:

Cache<String, String> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .scheduler(Scheduler.systemScheduler()) // Enables proactive cleanup
    .build();

Multi-Level Cache (L1 Caffeine + L2 Redis)

public class MultiLevelCache {
  private final Cache<String, String> l1 = Caffeine.newBuilder()
      .maximumSize(1_000)
      .expireAfterWrite(5, TimeUnit.MINUTES)
      .build();
  private final RedisTemplate<String, String> l2;

  public String get(String key) {
    String l1Value = l1.getIfPresent(key);
    if (l1Value != null) return l1Value;

    String l2Value = l2.opsForValue().get(key);
    if (l2Value != null) {
      l1.put(key, l2Value); // Populate L1
      return l2Value;
    }

    String dbValue = fetchFromDb(key);
    l1.put(key, dbValue);
    l2.opsForValue().set(key, dbValue, 30, TimeUnit.MINUTES);
    return dbValue;
  }
}

Best Practices

  • For a deeper guide, see Complete Guide to Redis Caching Strategies.

  • Size your cache: Set maximumSize based on available heap. A cache that’s too large causes GC pressure; too small causes evictions.

  • Use recordStats() in production: Monitor hit rate. Below 50% means the cache is misconfigured or the workload isn’t cacheable.

  • Prefer expireAfterWrite over expireAfterAccess: Access-based expiry can keep stale data alive indefinitely if it’s constantly read.

  • Use refreshAfterWrite for hot caches: Refreshes in the background, serving stale data during the refresh — no cache stampede.

  • Set initialCapacity: Reduces rehashing if you know the expected size.

  • Use per-cache configs in Spring: Different caches have different access patterns — don’t use one global config.

Common Mistakes

  • No maximumSize: The cache grows unbounded until OOM. Always set a size or weight limit.
  • Using weakKeys with String keys: Interned strings may be GC’d unexpectedly. Use weakKeys only with object keys that have clear lifecycle.
  • Forgetting recordStats(): Stats are disabled by default. Add it before you need metrics.
  • Cache too large for heap: A 1M-entry cache with large objects can consume gigabytes. Profile heap usage.
  • Not handling null from loader: If the loader returns null, Caffeine caches the absence. Use Optional or throw to avoid this.

Frequently Asked Questions

Is this solution production-ready?

Yes. The code examples above show tested implementations. Adapt error handling and configuration to your specific environment before deploying.

What are the performance characteristics?

Performance depends on your data volume and infrastructure. The solutions shown prioritize clarity. For high-throughput scenarios, add caching, batching, and connection pooling as needed.

How do I debug issues with this approach?

Start with the minimal example above. Add logging at each step. Test with small inputs first, then scale up. Use your language's debugger to step through edge cases.