What Is Cache Eviction?
Cache eviction is the process of removing existing entries from a cache to make room for new ones. Because every cache has a fixed capacity, something has to be discarded once that capacity is reached, and the rule that decides what gets discarded is called the eviction policy.
The choice matters more than it first appears. Two caches of identical size, holding identical data under an identical workload, can produce very different hit rates depending on which policy they use. Since cache memory is expensive, getting more value out of the same footprint is usually cheaper than buying more of it.
How Does Cache Eviction Work?
A cache is a bounded store sitting in front of something slower. When the application writes a new entry and the cache is already at its limit, it has three options: reject the write, grow without bound, or remove something. Growing without bound is not really an option, and rejecting writes is only appropriate where losing data silently would be worse than failing loudly. For most caches, that leaves eviction.
The mechanism is straightforward. On each write, the cache checks whether adding the entry would exceed its configured limit. If it would, the eviction policy selects a victim, that entry is removed, and the new one takes its place. The evicted entry is simply gone from the cache. The next request for it produces a cache miss, and the application falls back to the database or origin service.
The bookkeeping a policy needs is not free: knowing which entry was least recently used means updating an ordering structure on every read, which costs both memory and CPU. This is why most production caches approximate their policies rather than implementing them exactly.
It is worth separating eviction from cache invalidation, which is a different problem with a similar-sounding name. Eviction is driven by capacity and is concerned with space. Invalidation is driven by correctness and is concerned with staleness: it removes an entry because the underlying data changed and the cached copy is now wrong.
Cache Eviction vs. Cache Expiration
Eviction and expiration are frequently confused, and the distinction is worth getting right because most production caches use both at once.
Expiration is time-based. You attach a time to live (TTL) to an entry, and once that period elapses the entry is no longer served. Expiration is predictable: you decide up front how long the data stays valid, which makes it the right tool for data with a known freshness window, such as a session token or a rate-limit counter. One wrinkle: "expired" and "freed from memory" are not always the same moment. Redis and Valkey reclaim expired keys lazily, either when the key is next touched or when a background cycle happens to sample it, so an expired entry can occupy memory for some time after it stops being readable.
Eviction is capacity-based. It happens only when the cache reaches its configured limit, and it removes entries that have not expired and are otherwise perfectly valid. Eviction is non-deterministic from the application's point of view: you cannot predict which entry will be dropped or when, because it depends on the access pattern and how full the cache happens to be.
| Cache expiration (TTL) | Cache eviction | |
|---|---|---|
| Trigger | Time elapsed | Cache is full |
| Predictable? | Yes | No |
| Removes | Stale entries | Valid entries |
| Configured by | TTL or max idle time | Size limit plus a policy |
| Purpose | Bound how stale data can get | Bound how much memory is used |
In practice you want both. TTLs cap how far the cache can drift from the source of truth, while an eviction policy keeps total memory within budget when traffic spikes. Neither substitutes for the other: a cache with only TTLs can still exhaust memory, and a cache with only a size limit can serve stale data indefinitely.
Common Cache Eviction Policies
| Policy | What it removes | Best suited to |
|---|---|---|
| LRU (Least Recently Used) | The entry untouched for the longest time | Workloads with strong recency, where recent data is likely to be needed again |
| LFU (Least Frequently Used) | The entry accessed the fewest times | Workloads with a stable hot set that is accessed far more than everything else |
| FIFO (First In, First Out) | The oldest entry, regardless of use | Simple pipelines where insertion order approximates usefulness |
| Random | An arbitrary entry | Very large caches where tracking overhead outweighs the benefit of precision |
| TTL-based | The entry closest to expiring | Caches where every entry already carries an expiry |
| No eviction | Nothing; writes fail instead | Stores where silently losing data is worse than rejecting a write |
LRU is the most commonly chosen policy and a sensible starting point for most applications. It is cheap to approximate, it adapts quickly when the working set changes, and it degrades gracefully. A Java LRU cache is typically built from a hash table paired with a queue or linked list that maintains recency order.
LRU vs LFU: Which Should You Use?
The two policies optimize for different signals. LRU asks when an entry was last touched; LFU asks how often.
LRU suits workloads driven by recency: user sessions, activity feeds, recently viewed items. It also adapts quickly when the access pattern shifts, since a single read is enough to promote an entry. Its weakness is scanning — a batch job or crawler pulls a flood of entries through the cache that will never be requested again, flushing the useful ones out along the way.
LFU suits workloads with a heavily skewed access distribution. Real systems often follow something close to an 80/20 pattern, where a small fraction of keys serves the large majority of requests: a few hundred popular products in a catalog of millions, or a handful of configuration keys read on every request. LFU protects that hot set, and because a one-off scan never accumulates a meaningful count, it resists the scanning problem. Its weakness is inertia — an entry popular last week can hold a high count long after demand fades, which is why practical implementations decay counters over time.
As a rough rule: if your access pattern shifts continuously, choose LRU. If it is stable and heavily skewed, choose LFU. If you are unsure, start with LRU and measure your hit rate before changing anything.
Cache Eviction in Redis and Valkey
Redis and Valkey handle eviction through two settings. maxmemory defines the memory ceiling, and maxmemory-policy decides what happens when that ceiling is reached. Both LRU and LFU are supported, and neither is enabled by default — you opt into one of eight values:
-
noeviction— reject writes once memory is full (the default) -
allkeys-lru/volatile-lru— approximated LRU across all keys, or only keys with a TTL set -
allkeys-lfu/volatile-lfu— approximated LFU across all keys, or only keys with a TTL set -
allkeys-random/volatile-random— random selection, across all keys or only keys with a TTL -
volatile-ttl— the key with the shortest remaining time to live
A common trap with the four volatile-* policies: they only consider keys that carry an expiry. If no key in the dataset has a TTL set, there is nothing eligible to evict and the server falls back to refusing writes, exactly as noeviction would.
Two further details are easy to miss. First, both LRU and LFU here are approximations. Rather than maintaining a global ordering, the server samples a small number of candidate keys and evicts the best one from that sample; the sample size is controlled by maxmemory-samples and defaults to 5. LFU tracks access frequency with a probabilistic counter and applies a decay period so that counts fall off over time. The results are close to true LRU and LFU at a fraction of the cost.
Second, maxmemory-policy is a server-wide setting. One policy governs every key on the instance. If part of your dataset would be better served by LFU and another part by LRU, a single Redis or Valkey configuration cannot express that. You can read more about the surrounding options in our overview of Redis caching.
Cache Eviction in Java With Redisson
Redisson is a Java client for Redis and Valkey that provides more than 50 distributed objects and services. Among them are cache implementations that let you set an eviction policy per collection, rather than accepting one policy for the whole server.
RMapCache is a map that can be bounded by entry count or by total bytes stored, with the bound maintained by Redisson itself:
RMapCache<String, SomeObject> map = redisson.getMapCache("anyMap");
// bound the map to 10,000 entries, evicting in LRU order
map.trySetMaxSize(10_000);
// or bound it using LFU order instead
map.trySetMaxSize(10_000, EvictionMode.LFU);
// expiration works alongside eviction, not instead of it:
// TTL of 60 seconds and a max idle time of 30 seconds
map.put("key", new SomeObject(), 60, TimeUnit.SECONDS, 30, TimeUnit.SECONDS);
Applications that read far more often than they write can add a local cache, which keeps hot entries inside the JVM and avoids a network round trip on every read. The local tier has its own independent eviction policy:
LocalCachedMapOptions<String, SomeObject> options =
LocalCachedMapOptions.<String, SomeObject>name("anyMap")
.evictionPolicy(EvictionPolicy.LFU) // LRU, LFU, SOFT, WEAK or NONE
.cacheSize(10_000)
.syncStrategy(SyncStrategy.INVALIDATE)
.timeToLive(Duration.ofMinutes(10))
.maxIdle(Duration.ofSeconds(30));
RLocalCachedMap<String, SomeObject> map = redisson.getLocalCachedMap(options);
Alongside LRU and LFU, the local cache offers two policies with no server-side equivalent. SOFT holds entries through soft references, so the garbage collector releases them when the JVM is running low on memory. WEAK uses weak references, so entries are collected as soon as nothing else refers to them, at the next garbage collection cycle rather than under memory pressure. NONE disables size-based eviction while leaving TTL and max idle time active.
The local cache can also be backed by Caffeine rather than Redisson's own implementation. Redisson PRO adds data partitioning for local cached maps, distributing entries across cluster shards so the cache scales horizontally rather than being bounded by a single node.
The same eviction settings are available through Redisson's Spring Cache, Hibernate and JCache integrations, so you can configure eviction without changing how your application code reads and writes.
For a fuller treatment of the trade-offs between the two main policies, see our guide to choosing between LRU and LFU. For the broader picture of running a cache across multiple nodes, see distributed caching in Java with Valkey and Redis.