Caching with @Cacheable
Reviewed & published by Brayan K
Spring's caching abstraction lets a single annotation — @Cacheable — remember an expensive method's result, while @CacheEvict keeps it fresh and providers like Caffeine or Redis do the storing.
Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
Before You Start
You should understand Spring beans and services, and be comfortable with maps and lambdas for the memoization demos.
What You'll Learn
- How @Cacheable memoizes a method's result
- How cache keys are derived from arguments
- Turning caching on with @EnableCaching
- Keeping data fresh with @CacheEvict / @CachePut
- Choosing a provider: Caffeine vs Redis
- Avoiding stale data and unbounded caches
The Big Idea — A Sticky Note on Your Monitor
💡 Analogy: Imagine someone keeps asking you for the office Wi-Fi password. The first time you walk to IT and fetch it (the expensive call); then you scribble it on a sticky note on your monitor. Every later request is answered instantly from the note — that's @Cacheable. But when IT changes the password, the note is now wrong, so you must tear it down and write the new one: that's @CacheEvict. The note can live on your own desk (Caffeine, fast but only yours) or on a shared whiteboard everyone can see (Redis, slower to reach but shared by the whole team).
Fetch the slow answer once, reuse it cheaply — and remember to update the note when the truth changes.
1️⃣ The Core — Remember Results by Key
@Cacheable is memoization: store a method's result keyed by its arguments so the expensive work happens once per key. The plain-Java demo uses computeIfAbsent to make that mechanism explicit.
import java.util.*;
import java.util.function.*;
public class Main {
// @Cacheable in one idea: remember a method's result keyed by its argument,
// so an EXPENSIVE call only happens ONCE per key. We build that wrapper by
// hand (memoization) to make the mechanism concrete.
static int computeCalls = 0;
static int slowSquare(int n) { // pretend this is expensive
computeCalls++;
return n * n;
}
public static void main(String[] args) {
Map<Integer, Integer> cache = new HashMap<>();
Function<Integer, Integer> cached = key ->
cache.computeIfAbsent(key, Main::slowSquare); // compute once per key
System.out.println("square(4) = " + cached.apply(4)); // MISS -> computes
System.out.println("square(4) = " + cached.apply(4)); // HIT -> cached
System.out.println("square(5) = " + cached.apply(5)); // MISS -> computes
System.out.println("expensive computations: " + computeCalls);
}
}2️⃣ Staying Correct — Eviction
A cache speeds reads but can go stale. When the source data changes you must remove the old entry — that's @CacheEvict. Forget it and readers keep seeing the outdated value.
import java.util.*;
public class Main {
// @CacheEvict keeps the cache CORRECT: when the underlying data changes, you
// must remove the stale entry, or readers keep seeing the old value. We model
// a put-that-evicts to show why eviction matters.
static Map<Integer, String> store = new HashMap<>(Map.of(1, "Ada"));
static Map<Integer, String> cache = new HashMap<>();
static String getName(int id) { // @Cacheable-style read
return cache.computeIfAbsent(id, store::get);
}
static void rename(int id, String name) { // @CacheEvict-style write
store.put(id, name);
cache.remove(id); // EVICT the stale entry
}
public static void main(String[] args) {
System.out.println("first read : " + getName(1)); // caches "Ada"
rename(1, "Ada Lovelace"); // update + evict
System.out.println("after update: " + getName(1)); // re-reads fresh value
System.out.println("without eviction we'd still see: Ada (stale)");
}
}3️⃣ The Spring Way — @EnableCaching + Annotations
Turn caching on once with @EnableCaching, then annotate methods: @Cacheable to read-through, @CacheEvict to invalidate, @CachePut to refresh. Spring applies the logic via AOP proxies.
@Cacheable("books") // read-through: skip body on a hit
@CacheEvict(value="books", key="#book.isbn") // invalidate on write
@CachePut(value="books", key="#book.isbn") // run + refresh entry4️⃣ Choosing a Provider — Caffeine vs Redis
Spring caches against whatever provider is on the classpath. Caffeine is a fast in-process cache for a single instance; Redis is a distributed cache shared across instances and surviving restarts.
# Choosing a CACHE PROVIDER (Spring caches against whichever is on the classpath).
# --- Option A: Caffeine (fast, in-process, single-instance) ---
# build dependency: com.github.ben-manes.caffeine:caffeine
spring.cache.type=caffeine
spring.cache.caffeine.spec=maximumSize=10000,expireAfterWrite=5m
# --- Option B: Redis (distributed, shared across instances) ---
# build dependency: spring-boot-starter-data-redis
# spring.cache.type=redis
# spring.data.redis.host=localhost
# spring.data.redis.port=6379
# spring.cache.redis.time-to-live=300000
# Rule of thumb:
# Caffeine -> one app instance, ultra-low latency, no extra infrastructure.
# Redis -> many instances must share the SAME cache; survives restarts.🧠 Quick Recall
Predict before you peek.
Answer: no — it's a cache hit, so the stored value is returned and the body is skipped.
Answer: @CacheEvict (or @CachePut) on the write removes/refreshes the affected entry.
Answer: Redis — a distributed cache. Caffeine is local to a single JVM.
🎯 YOUR TURN — Memoized Fibonacci
Complete fib(n) using the cache map so each n is computed only once.
import java.util.*;
import java.util.function.*;
public class Main {
// YOUR TURN: implement a memoized (cached) Fibonacci.
// Use the provided cache map so each n is computed only once. Count calls
// to the 'compute' path to prove caching works.
//
// Expected output:
// fib(10) = 55
static Map<Integer, Long> cache = new HashMap<>();
static long fib(int n) {
// if n < 2 return n
// otherwise return cache.computeIfAbsent(n, k -> fib(k-1) + fib(k-2));
return 0;
}
public static void main(String[] args) {
System.out.println("fib(10) = " + fib(10));
}
}🧩 MINI-CHALLENGE — Read-Through Cache with Eviction
Implement get (cache-through) and set (update + evict) and prove the read after a set is fresh.
Common Errors
- ❌ Forgetting @EnableCaching. The annotations do nothing. Fix: add it to a config class.
- ❌ Not evicting on update. Readers see stale data. Fix: add @CacheEvict/@CachePut on writes.
- ❌ Self-invocation calls. Calling a cached method from within the same bean bypasses the proxy. Fix: call via the injected bean.
- ❌ Wrong key. Too coarse a key returns wrong results for different inputs. Fix: use a precise SpEL key.
- ❌ Unbounded cache. Memory grows forever. Fix: set a maximum size and TTL.
- ❌ Caching cheap or volatile methods. No benefit, added risk. Fix: cache expensive, stable, repeated calls.
Pro Tips
- 💡 Set a TTL and max size (e.g. Caffeine's expireAfterWrite, maximumSize) to bound memory and staleness.
- 💡 Pick keys deliberately with SpEL, e.g. key = "#book.isbn", to avoid collisions.
- 💡 Use Redis when you scale out so every instance shares one consistent cache.
- 💡 Measure first: cache the genuinely hot, expensive paths shown by your metrics — not everything.
📋 Quick Reference
| Concept | Syntax / idea | Note |
|---|---|---|
| enable | @EnableCaching | On a config class |
| read-through | @Cacheable("books") | Skip body on hit |
| key | key = "#book.isbn" | SpEL, default = args |
| invalidate | @CacheEvict | Remove stale entry |
| refresh | @CachePut | Run + update cache |
| clear all | @CacheEvict(allEntries=true) | Whole cache |
| in-process | Caffeine | Fast, single node |
| distributed | Redis | Shared, survives restart |
| mechanism | AOP proxy | Intercepts the call |
| risk | stale data | Evict + TTL |
📚 Resources / Keep Learning
- 📘 Cache Abstraction — Spring Framework reference
- 📗 Caching — Spring Boot reference
- 📗 Caffeine cache — project wiki
🎉 Lesson Complete!
You now understand how @Cacheable memoizes results by key, how keys are derived, how @CacheEvict and @CachePut keep data fresh, how @EnableCaching turns it all on, and when to choose Caffeine versus Redis.
Practice quiz
What does @Cacheable do?
- Deletes a cache
- Stores a method's result so subsequent calls with the same key return the cached value
- Encrypts the result
- Runs the method twice
Answer: Stores a method's result so subsequent calls with the same key return the cached value. @Cacheable caches the return value keyed by the method arguments; later matching calls skip the method body and return the cached value.
Which annotation enables Spring's caching abstraction?
- @EnableCaching
- @StartCache
- @CacheOn
- @Caching
Answer: @EnableCaching. You add @EnableCaching to a configuration class to turn on Spring's annotation-driven caching.
What forms the default cache key for a @Cacheable method?
- The method name
- The current time
- The method's parameters
- A random UUID
Answer: The method's parameters. By default the key is derived from the method arguments; you can customise it with a SpEL key expression.
What does @CacheEvict do?
- Adds an entry
- Reads an entry
- Locks the cache
- Removes one or all entries from a cache (e.g. after an update)
Answer: Removes one or all entries from a cache (e.g. after an update). @CacheEvict removes entries so stale data is purged — for example when the underlying record changes.
When a @Cacheable method is called a SECOND time with the same arguments...
- The method body runs again
- The cached value is returned and the body is skipped
- An exception is thrown
- The cache is cleared
Answer: The cached value is returned and the body is skipped. A cache hit returns the stored value without executing the method body — that is the whole point.
Which of these is a common cache provider used with Spring?
- Caffeine
- Tomcat
- Jackson
- Log4j
Answer: Caffeine. Caffeine is a popular high-performance in-memory cache; Redis is common for distributed caching.
Why might you choose Redis over an in-memory cache like Caffeine?
- It is simpler to set up
- It needs no network
- It is a distributed cache shared across multiple app instances
- It is always faster
Answer: It is a distributed cache shared across multiple app instances. Redis is an external, shared cache, so multiple application instances see the same cached data — useful when scaling out.
What does @CachePut do?
- Always skips the method
- Deletes the cache
- Disables caching
- Always executes the method and updates the cache with the result
Answer: Always executes the method and updates the cache with the result. @CachePut runs the method every time and stores its result, keeping the cache in sync without short-circuiting.
A risk of caching is...
- Slower reads
- Serving stale data if entries aren't evicted/expired correctly
- Higher network usage always
- It breaks compilation
Answer: Serving stale data if entries aren't evicted/expired correctly. Caches can return outdated values; correct eviction, TTLs, and invalidation keep cached data fresh.
Spring's caching is implemented via...
- bytecode rewriting at runtime by the JVM
- proxies/AOP that intercept the method call
- a separate thread per cache
- database triggers
Answer: proxies/AOP that intercept the method call. Spring uses AOP proxies to intercept calls to cacheable methods and apply the cache logic around them.
Continue this course
- Previous: Monitoring with Actuator
- Next: Containerizing a Spring Boot App — Dockerfiles, layered jars and buildpacks
- Quick reference: Java cheat sheet
Frequently asked questions
What does @Cacheable actually do?
@Cacheable wraps a method so that its return value is stored in a named cache, keyed by the method's arguments. The first time the method is called with a particular set of arguments, the body runs and the result is cached; every subsequent call with the same arguments returns the cached value and skips the method body entirely. This is ideal for expensive operations — a slow database lookup, a remote API call, a heavy computation — that are repeated with identical inputs and produce stable results. You enable the whole mechanism with @EnableCaching on a configuration class.
How does Spring know which cached entry to return?
By default, Spring builds the cache key from the method's parameters, so findByIsbn("123") and findByIsbn("456") are cached separately. When a method has multiple parameters or you want a custom key, you supply a SpEL expression via the key attribute — for example key = "#book.isbn" to key on a field of an argument. Getting the key right is essential: too coarse a key returns wrong results across different inputs, while a unique-enough key ensures each distinct input has its own cached value.
What is the difference between @CacheEvict and @CachePut?
@CacheEvict removes entries from a cache — you put it on methods that change the underlying data (updates, deletes) so stale cached values are purged and the next read fetches fresh data; you can evict a single key or, with allEntries = true, clear the whole cache. @CachePut, by contrast, always executes the method and then stores its return value in the cache, refreshing the entry without ever short-circuiting the call. Use @Cacheable to avoid work, @CachePut to update the cache while still doing the work, and @CacheEvict to invalidate.
Which cache provider should I use — Caffeine or Redis?
Spring's caching is an abstraction; it delegates storage to a provider on the classpath. Caffeine is a fast, in-process, in-memory cache — perfect for a single application instance that wants the lowest possible latency with no extra infrastructure, but its data is local to that one JVM. Redis is an external, distributed cache shared across many instances, so every node sees the same cached data and entries survive an app restart; the trade-off is the added network hop and operational cost of running Redis. Choose Caffeine for single-node speed, Redis for a shared cache when you scale out.
What are the dangers of caching, and how do I avoid them?
The classic danger is stale data: if the underlying value changes but the cache isn't invalidated, readers keep seeing the old value. Guard against it with correct eviction (@CacheEvict on writes), time-to-live expiry, and careful key design. Other pitfalls include unbounded caches that grow until they exhaust memory (set a maximum size), caching values that are cheap to compute (no benefit, just complexity), and caching across instances inconsistently (use a distributed cache like Redis when you scale out). As an old saying goes, cache invalidation is one of the hard problems — so test it.