The labs on this site need JavaScript to run. The same content is written out below.
Learn › Spring › Other features
Cache abstraction
With @Cacheable on a method, when does the method body not run?
In Spring, you only need to put @Cacheable on a method. When the method is called again with the same arguments, the body does not run, and the value that was returned before is taken from the cache. The code that looks up the cache and stores values in it is run by a proxy on your behalf. The layer that Spring puts in between, so that the same annotations work with whatever cache store is used, is called the cache abstraction.
Checked against real execution · All 40 combinations were run with @EnableCaching and ConcurrentMapCacheManager on Spring Framework 7.0.9 and checked against the results
Concepts in this lab
- @Cacheable
- This annotation stores the result of a method in a cache so that, when the method is called again with the same arguments, the value in the cache is returned without running the body. A proxy receives the call and looks up the cache first, and only when the value is not there does it run the body and then store the result. By default, the cache key is the arguments of the method, and cacheNames sets which cache the result goes into. If you write a condition in unless, a result for which the condition is true is not stored (for example, when the result is null). Because this is the work of a proxy, it is not applied when the method is called with this from within the same class.
- @CacheEvict and @CachePut
- These are two annotations used to keep the values in a cache in step with the source data. @CacheEvict removes the value for the given key from the cache when the method is called, so that the next lookup runs the body again. Unlike @Cacheable, @CachePut does not skip the body. It always runs the body and puts the returned value in the cache, overwriting the existing value. If neither of them is on a method that changes data, the value from before the change remains in the cache, and the code that looks it up receives the stale value.
- Cache
- Storing frequently used data or computed results ahead of time in a place that can be accessed quickly, and reusing them. Responses get faster and the load on the source drops, because the source (usually a database) is not queried every time. In return, you must manage the problem of stale values that remain when the source changes.
- Proxy
- This is a stand-in object that receives calls in place of the real object. When it receives a call, it does some additional work and then passes the call on to the real object. Spring wraps a bean that needs a feature such as AOP or @Transactional in such an object and registers it in the container, so what other beans receive by injection is this stand-in, not the real object.
- Self-invocation
- This means that an object directly calls another of its own methods from inside the same object. Proxy-based features apply only to calls that come in from outside through the proxy, so settings such as @Transactional, @Async, and @Cacheable do not apply to a method that is called internally through this. The usual fix is to move that method to another bean.
Try these
- Default settings — find is called twice with the same argument. The body runs only once.
- With a different argument — The key is different, so the body runs again.
- Evicting with @CacheEvict — The value was removed from the cache, so the body runs again.
- Replacing the value with @CachePut — The body of rename always runs, and the cache is updated with the new value.
- Changing only the DB and leaving the cache as it is — The second find returns the value from before the change.
- Calling from within the same class — Caching is not applied, so the body runs every time.
- A null result is cached too — A product that does not exist is looked up twice. Turn on unless and compare.
Running with the default settings
- productService.find(1L) call #1: Another bean makes the call. The call arrives at the proxy first. The proxy sees @Cacheable and, before running the body, looks up key 1 in the cache "product". The proxy does not know where the cache is actually stored. It only calls get and put on the Cache object that the CacheManager hands out, so this code and the annotations stay the same even if the store is changed from an in-memory map to Redis. Spring calls this structure the cache abstraction.
- Cache lookup: Key 1 is not in the cache. Nothing has been put in the cache yet. The proxy calls find on the real object.
- find body runs (run #1): findCount becomes 1. The body finds the product with id 1 in the DB and returns "Keyboard".
- The proxy stores the result in the cache: key 1 → Keyboard. The next call with the same argument uses this value.
- productService.find(1L) call #2: Another bean makes the call. The call arrives at the proxy first. The proxy sees @Cacheable and, before running the body, looks up key 1 in the cache "product".
- Cache lookup: Key 1 is in the cache. The proxy does not run the body of find and returns what the cache holds: "Keyboard". The number of times the body has run stays at 1.
Result of this run
- When the method is called again with the same argument, the proxy returns the value from the cache and does not run the body. The longer the body takes, as with a DB query or an external call, the larger the effect.
Code
// another bean makes these calls in this order
productService.find(1L);
productService.find(1L);
@Service
class ProductService {
// a map used in place of a DB
private final Map<Long, String> db = new HashMap<>(
Map.of(1L, "Keyboard", 2L, "Mouse"));
private int findCount;
@Cacheable(cacheNames = "product")
public String find(Long id) {
findCount++; // number of times the body has run
return db.get(id); // null if there is no such id
}
}
Assumptions of this model
All 40 combinations were run on Spring Framework 7.0.9 (the version included in Spring Boot 4.1.1) on this PC with @EnableCaching and ConcurrentMapCacheManager, and these were checked against the results: the number of times the find body ran, the value returned by the second find, and the cache contents at the end. The same runs also confirmed that the body of rename runs even when the cache has a value, and that removing a key that is not in the cache with @CacheEvict does not cause an error. ConcurrentMapCacheManager is the simplest cache manager, which stores values in maps in the memory of the application, and it has no expiration time (TTL) and no size limit. Expiration and requests that arrive at the same time are covered in the "Caching and TTL" lab. Whether null can be put in a cache depends on the settings of the cache implementation, and ConcurrentMapCacheManager stores null by default (the behavior of other cache implementations was not verified by execution). The DB was replaced with an in-memory map both on the screen and in the runs. The code on the screen assumes that @EnableCaching is turned on, and to use a parameter name as in key = "#id", the code must be compiled with the -parameters option. When the code was compiled without this option and run on this PC, an IllegalArgumentException saying that the key is null was thrown at the moment rename was called (the build settings of a project created with Spring Boot include this option). The reason why proxy-based features are not applied to calls made from within the same class is covered in detail in the "Proxies and self-invocation" lab. This lab covers only the case where the calls are made one after another on a single thread. The case where several threads look up the same key for the first time at the same moment was not run.
Previous lab: Calling external APIs and timeouts · Next lab: Security filter chain