The idea
What it is
“Design an LRU cache.” Four words, and the interviewer will not add a fifth. It is the most-asked machine-coding problem there is, which means the bar is not “can you produce something that works” — it is “can you produce the right thing, quickly, and then extend it.”
A cache sits between a caller and something slow. When the caller asks for a key, the cache either has it — and answers in about 100 nanoseconds — or it does not, and someone has to pay a database round trip of roughly 10 milliseconds. That is a hundred thousand to one. The cache is small on purpose, so the only interesting question is: when it is full, which key do you throw away?
The whole system in three sentences
A map holds key → value so lookup is O(1). A policy holds an ordering of the keys so that “who dies next” is also O(1). Cache owns the map, the capacity and the counters; the policy owns the order — and nothing else in the cache knows whether that order means recently used or frequently used.
What is actually being graded
- Is it genuinely O(1)? Not “fast” — O(1). If any path scans the entries to find a victim, or walks a list to find a predecessor, you have failed the only quantitative requirement the problem has.
- Can you draw the pointers? The interviewer will ask you to talk through
moveToFront. “It moves the node to the front” is not an answer. The six assignments are the answer. - Did you use sentinels? A
HEADand aTAILnode that hold no data delete every null check in the file. Skipping them is where the NullPointerException comes from. - Is the eviction rule swappable? “Now make it LFU” is the follow-up, every time. If the list logic is welded into
get(), that request is a rewrite. If it is behind an interface, it is a new class and zero edits. - Does it run, and can you show it working? A
main()that replays a fixed access trace and prints the contents and the hit ratio after each step. Numbers, not claims.
Mechanics
How it works
Step 1 · Clarify — 4 minutes
- What is the capacity measured in — entries or bytes? — say entries, and add the one sentence that shows you know better: “real caches weigh entries, because a 2 KB value and a 2 MB value are not the same tenant of a fixed memory budget.”
- What should
getreturn on a miss? — an emptyOptional(ornull, orundefined). Not an exception. A miss is the normal case, not an error. - Does the cache load on a miss, or does the caller? — simplest is: the caller loads and calls
put. Mention that a loading cache which takes a supplier is the nicer API and is what Caffeine and Guava do. - LRU only, or should the policy be pluggable? — this is the question that decides the shape of your answer. Ask it early and they will usually say “start with LRU, then we will talk.” That is your cue to build the seam from the first minute.
- Will several threads use it? — say yes, one lock, and be ready for the twist in the follow-ups:
getmutates. - TTL, persistence, distribution, stampede protection? — out of scope in one sentence. TTL is expiry, which is a different axis from eviction, and mixing them up costs you time. Depth on that lives in In-memory key-value store.
Step 2 · Why neither structure alone works
Start from the two operations and ask what each costs. get(key) needs to find a value by key. evict() needs to find the least-recently-used entry. There is no single ordinary structure that does both in constant time — and understanding why is the entire design.
Step 3 · The pointer surgery, spelled out
“And then it moves to the front” is where most candidates stop. Do not. Moving a node is two operations — unlink and insert at head — and between them there are exactly six assignments. Say them, write them, and the interviewer stops probing.
head.next = n overwrites the pointer you needed in step 3 and step 5 — which is why the order is not arbitrary and why writing it out beats waving at it.The sentinels are not decoration
HEAD and TAIL are two real nodes that hold no key and no value, wired to each other at construction. With them, n.prev and n.next are never null for any node in the list, so unlink and insertAtHead have zero branches. Without them, every one of those six lines needs an “is this the first node? is this the only node?” guard, and the first thing that breaks in the interview is the empty-list case. Two wasted objects, an entire class of bugs deleted.
private void unlink(Node<K> n) {
n.prev.next = n.next; // 1
n.next.prev = n.prev; // 2
n.prev = null; n.next = null;
}
private void insertAtHead(Node<K> n) {
n.next = head.next; // 3
n.prev = head; // 4
head.next.prev = n; // 5 <- must happen BEFORE line 6
head.next = n; // 6
}
// "this key was just used" — the whole of LRU
void recordAccess(K key) {
Node<K> n = nodes.get(key);
if (n == null) return;
unlink(n);
insertAtHead(n);
}
// "who dies next?" — no scan, no comparison, no loop
K evictCandidate() {
return tail.prev == head ? null : tail.prev.key;
}Why the list must be doubly linked
This is the question that separates people who memorised the answer from people who understand it. You found the node through the map in O(1). To unlink it you need to change its predecessor's next pointer. With only next pointers, the node has no idea who its predecessor is — and the only way to find out is to walk from the head until you find the node whose next is you.
prev field is not a nicety — it is the difference between the design working and the design being a linear scan with extra steps.Step 4 · The two flows, precisely
recordAccess, which writes to the linked list. There is no such thing as a read-only get in this design, and that fact comes back in the concurrency follow-up.put on a key that is already present must not grow the cache and must not evict anything — but it must count as a use. Candidates who write evictIfFull(); insert(); unconditionally throw away a live entry on every overwrite.Evict before you insert, not after
Inserting first and then trimming works, but for one moment the cache holds capacity + 1 entries — and if the new key itself is chosen as the victim (possible in some policies), you have just inserted and deleted the thing you were asked to store. Check size() >= capacity, evict, then insert. Same principle as do everything that can fail before the first irreversible step in Coffee Machine.
Step 5 · The seam — EvictionPolicy is an interface
Here is the moment the problem stops being a LeetCode exercise and becomes a design answer. Cache should own storage, capacity and counters. It should not own an opinion about who dies. Give the opinion its own type, with four methods:
interface EvictionPolicy<K> {
void recordInsert(K key); // a brand-new key entered the cache
void recordAccess(K key); // an existing key was read or overwritten
void recordRemove(K key); // a key left the cache
K evictCandidate(); // who dies next — null if empty
}Now read Cache.get() again: it looks up the value, bumps a counter, and calls recordAccess. It never says the word recency. Swap new LruPolicy() for new LfuPolicy() in the constructor and zero lines inside Cache change. That is Strategy doing exactly what it is for, and it is the Open/Closed (OCP) principle stated as code: open to a new policy, closed to editing the cache.
Cache plus Node — is a complete, working LRU cache. The right half is what makes “now make it LFU” a five-minute answer. Notation: Class diagrams; the flows above are Sequence diagrams.The trap most candidates fall into
One class called LRUCache, with unlink and moveToFront written directly inside get(). It works, it passes the test, and then the interviewer says “now make it LFU” — and there is nowhere to put the change. You end up either rewriting the class or adding an if (mode == LFU) branch through the hot path. Ten extra minutes at minute 30 to pull the interface out is the single highest-value spend in this round.
The honest cost of the seam
In the welded version the map stores key → Node and the node holds the value, so a hit is one hash lookup. With a policy interface, Cache keeps key → value and the policy keeps its own key → Node, so a hit is two hash lookups and one extra object per entry. Still O(1), still nothing to worry about at interview scale — but say it out loud. Knowing what your abstraction costs is worth more than pretending it is free.
Step 6 · LFU is a different structure, not a tweak
“Now evict the least-frequently-used key instead.” The naive move is to add a count field to each node and scan for the minimum — which is O(n) and undoes everything you just built. LFU needs its own three pieces:
key → frequency— how many times each key has been touched.frequency → a doubly-linked list of the keys at that frequency, ordered by recency inside the bucket.minFreq— a single integer holding the lowest frequency currently in use. This is the whole trick: without it, finding the victim means searching the buckets.
On access, a key moves from bucket f to bucket f+1. If bucket f is now empty and f == minFreq, then minFreq++ — and that is the only place minFreq ever increases, because a key can only ever gain one frequency at a time. On insert, the new key goes into bucket 1 and minFreq = 1, because a fresh key is always the new minimum. Eviction takes the oldest entry in bucket minFreq — so the tie inside a frequency bucket is broken by recency, which is to say LFU has an LRU hiding inside it. Say that sentence; interviewers ask about the tie-break specifically.
minFreq when you explain this. Everything else in LFU is bookkeeping; that one integer is what turns “find the minimum” from a search into a lookup.LFU's real flaw — say it before they ask
A key that got hammered a thousand times during a one-off batch job at 3am has a frequency of 1000 forever, and it will outlive keys that are genuinely hot today. That is cache pollution, and pure LFU has no cure for it. The fixes are all forms of forgetting: aging (periodically halve every count), a decay factor on each access, or a windowed count that only remembers the recent past. That last one is what TinyLFU does, and it is what modern caches like Caffeine actually ship — a small window LRU in front, admitting into a large LFU-governed main region using a sketch of recent frequencies.
The one-line answer to “LRU or LFU?”
LRU bets that what you touched recently you will touch again; LFU bets that what you touch often you will touch often. LRU is cheap, adapts instantly to a change in workload, and is destroyed by a single full scan that sweeps the whole cache. LFU resists that scan, and in exchange it clings to yesterday's hot keys. Real systems use a hybrid for exactly this reason.
Thread safety — where the surprise is
Ask any candidate to make a cache thread-safe and they reach for a read-write lock: “many readers, one writer.” It is the wrong instinct here, and knowing why is worth a point. get() is not a read. It calls recordAccess, which unlinks a node and relinks it at the head — six pointer writes on a structure shared by everyone. Two concurrent gets on different keys can corrupt the list into a cycle, and then a later traversal never terminates.
- The 60-minute answer: one lock (
synchronized, or aReentrantLock) aroundgetandput. Four characters of code and it is correct. Say it, write it, move on. Background: Locks, Mutex, Semaphore. - Why a
ReadWriteLockdoes not help: everything takes the write lock anyway, because every operation mutates. You get the complexity of Read-Write locks and none of the concurrency. - How you would actually scale it: striping — split the key space into N independent segments, each with its own map, list and lock, so keys that hash to different segments never contend. The cost is that eviction becomes per-segment, so the policy is now approximate across the whole cache. That is a fine trade and it is what real caches do.
- What production caches do instead: Caffeine keeps the reads lock-free by writing each access into a small ring buffer and replaying those buffers onto the linked list later, in one thread. The reordering leaves the read path entirely. Mention it in one sentence; do not attempt it.
Capacity in entries or in bytes?
Counting entries is fine for an interview and wrong for production: 1000 entries could be 4 MB or 4 GB. Real caches take a weigher — a function from an entry to its cost — and enforce a total weight. It is a one-line extension of the same design: evict while totalWeight > maxWeight instead of while size > capacity. Mention it; do not build it. And TTL is a separate axis — an entry can be evicted for being cold or dropped for being stale, and the two mechanisms do not know about each other. Depth on that belongs in In-memory key-value store.
Metrics — the answer to “how would you know it is working?”
Three counters and one derived number: hits, misses, evictions, and hit ratio = hits / (hits + misses). It costs four lines and it is the only evidence that the cache is earning its memory. A hit ratio near zero means the cache is too small or the workload has no locality; a huge eviction count with a decent hit ratio means it is working hard but running hot. Print them in your main() — it turns “it works” into a number the interviewer can read.
What the seam actually buys you
The 60 minutes
The follow-ups
- “Java has this built in, doesn't it?” → yes:
new LinkedHashMap<>(cap, 0.75f, true)withremoveEldestEntryoverridden is a six-line LRU, and thetrueis access order. Name it — it shows you know the library. Then say the quiet part: “in this round I assume you want the hand-rolled version, since that is what is being tested.” Offering the shortcut and declining it reads better than not knowing it. - “Now make it LFU.” → the whole point of the seam. New class, buckets plus
minFreq,Cacheuntouched. If they push further: the tie inside a bucket is LRU, and pure LFU pollutes. - “Make it thread-safe.” → one lock; explain that
getmutates so a read-write lock buys nothing; mention striping and Caffeine's buffered replay as how you would scale it. - “Distribute it across ten servers.” → a per-node LRU means ten caches with ten independent hit ratios and ten copies of the hot keys. Either route keys by consistent hashing so each key lives on one node, or accept the duplication and treat it as ten small caches. Invalidation across nodes is the genuinely hard part.
- “Write-through, write-back or write-around?” → write-through updates cache and store together (simple, always consistent, slower writes); write-back updates the cache and flushes later (fast, and you can lose data); write-around writes straight to the store and lets the next read populate the cache (good when writes are rarely re-read soon).
- “What happens when a hot key expires and a thousand requests miss at once?” → the thundering herd. One request loads and the rest wait on the same future, or you refresh slightly before expiry. One sentence, then stop — it is a whole problem of its own.
- “How would you test it?” → a fixed access trace with expected contents after each step, plus the boundary cases: capacity 1,
puton an existing key,geton a missing key, and evicting down to empty.
How this round is lost
- A singly-linked list. It looks fine until you try to unlink a node found through the map, and then the whole O(1) claim collapses into a walk from the head.
- No sentinels. Every pointer assignment grows a null check, the empty-list and single-node cases get their own branches, and the first
NullPointerExceptionlands about four minutes later. - Forgetting to move the node on
get. This is the worst failure in the whole problem, because the code still works. Nothing throws. Nothing looks wrong. Your LRU is quietly a FIFO, and the only symptom is a hit ratio that is worse than it should be. The prototype has a chip for exactly this — see what you notice. puton an existing key evicting something. Overwriting a value must not change the size and must not kill a neighbour. Test it; it is one line and everybody skips it.- The policy welded into the cache class. It works, and then “now make it LFU” has nowhere to land, and you spend the last fifteen minutes untangling
get()instead of writing a second policy. - Scanning for the LFU minimum. Adding a
countfield and callingCollections.minis the single most common way to answer the LFU follow-up wrongly.minFreqexists precisely so you never search.
Interactive prototype
See it. Build it. Break it.
A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.
About this simulation
A live capacity-3 cache with the linked list drawn as real prev/next pointers between sentinel HEAD and TAIL. Click a key on the pad — on a hit the node lifts out, the neighbours' arrows close the gap, and it drops in at the MRU end; on a miss it loads and the tail flashes red and dies. Press ▶ Replay trace under 🔁 LRU, hit ↺ Reset, switch to 📊 LFU and replay the identical trace: a different key gets evicted and the hit ratio changes. Then try ⚠️ FIFO bug — recordAccess() does nothing, nothing looks broken, and the hit ratio quietly drops. cap 2 / 3 / 4 changes the pressure.
Hands-on
Try these yourself
Open the prototype above, predict what happens, then verify.
Watch the map and the list disagree
Start on 🔁 LRU at cap 3. Click A, then B, then C — each is a miss, each is loaded and inserted at the MRU end. Now look at the two panels: the HashMap on the left lists the keys in a fixed, meaningless order, while the list on the right is ordered by recency. That is the point of the whole design. The map cannot answer “who is oldest?” and the list cannot answer “where is B?”
Watch the pointer surgery
Click A again — a hit. It happens in two beats: first the node lifts out of the list and the two neighbours' arrows close the gap (n.prev.next = n.next, n.next.prev = n.prev), then it drops in at the head. The .callline shows policy.recordAccess("A") while it happens. Notice that at no point does anything walk the list — the map handed the node straight over.
Force an eviction
With A, B and C in a cap-3 cache, click D. Miss, and the cache is full: the tail node flashes red and dies before D is inserted. Watch the order — evict first, then insert, so the cache never briefly holds four. Then click cap 2 and see two nodes evicted immediately, and cap 4 and see the pressure disappear.
Run the trace under LRU
Hit ↺ Reset and press ▶ Replay trace. It replays A A A B C B D E B A one step at a time. Watch step 7: A is evicted — the key that was used three times at the start, thrown away because it had gone quiet. Note the final numbers: 4 hits, 6 misses, 3 evictions, 40%.
Run the identical trace under LFU
Hit ↺ Reset, click 📊 LFU, and press ▶ Replay trace again. Same ten clicks. This time the nodes carry a freq badge and regroup into frequency buckets with a minFreq pointer, and step 7 evicts C instead of A. A survives, so step 10 is a hit rather than a miss: 5 hits, 5 misses, 2 evictions, 50%. Also notice which of C and D dies first — both sit at frequency 1, and the tie is broken by recency.
Find the bug that works
Reset, click ⚠️ FIFO bug, and replay once more. In this mode recordAccess() does nothing — the exact line a tired candidate forgets. Nothing throws, nothing looks broken, the animation still runs. The only symptom is the number: 3 hits, 30%, the worst of the three. This is why “it works” is not evidence, and why you print the hit ratio in your main().
Build it from memory
Blank file, in this order: Node with key, prev, next → LruPolicy with the two sentinels wired together in the constructor, then unlink, insertAtHead, recordAccess, evictCandidate → the EvictionPolicy interface pulled out of it → Cache with the map, the capacity, the counters and a put that handles the existing-key path separately → main() replaying the trace and printing the hit ratio. Then add LfuPolicy without touching Cache. If you had to change one line inside Cache, your interface is in the wrong place.
In practice
When to use it — and what trips people up
The shape you just learned
Strip the caching away and there are two reusable ideas here. The first: when one structure cannot answer both of your questions, keep two and make them point at each other. The second: when a rule is likely to change, give the rule its own type — because the caller then never has to know which rule is in force.
- Any LRU-ish eviction — connection pools that close the idlest connection, session stores, image and tile caches, browser back/forward stacks.
- Ordered maps in general — a hash map plus a linked list is exactly what
LinkedHashMapand Python'sdictare, and now you know why insertion order is free in one and not the other. - Task schedulers — the “which task runs next” decision is the same seam as “which key dies next”, and it belongs behind an interface for exactly the same reason. See Strategy.
- Rate limiters and quota buckets — a counter per key with a way to find the extreme value quickly; the
minFreqtrick generalises to “maintain the answer instead of searching for it”. - Undo stacks and MRU file lists — the same list, the same move-to-front, the same tail-drop when the list gets too long.
The 20-second version to say out loud
“A map gives me O(1) lookup but no order; a doubly-linked list gives me O(1) reordering but no lookup. I keep both — the map's values are the list's nodes — so get is a lookup plus six pointer assignments and eviction is tail.prev. Sentinels remove the null checks. The eviction rule lives behind an interface, so LFU is a new class rather than a rewrite.”
Where this design stops working
- At high concurrency. One lock around a structure that every read mutates is a hard serialisation point. The escapes are striping (approximate eviction, real parallelism) or moving the reordering off the read path entirely, which is what Caffeine's buffered replay does.
- When entries have wildly different sizes. Counting entries stops meaning anything; you need a weigher and a byte budget, and then eviction is “drop until under the limit” rather than “drop one”.
- When the workload scans. One pass over a large table walks LRU's entire contents out of the cache and leaves nothing useful behind. This is precisely what LFU and TinyLFU exist to resist.
- When the cache is distributed. A per-node LRU means N independent caches, N hit ratios, and N copies of the hot keys. Routing keys to owners fixes the duplication and introduces invalidation, which is a harder problem than eviction ever was.
- When staleness matters more than memory. Then you want expiry, not eviction, and the two mechanisms are independent — an entry can be evicted while fresh and go stale while resident.
If you only remember one thing
The map finds it, the list orders it, the interface decides who dies. Those three clauses are the whole design, in that order — and the third one is what turns a correct LeetCode answer into a machine-coding answer.
What it gives you
- A hash map plus a doubly-linked list gives genuinely O(1) get, put and evict, with no scan anywhere in the design.
- Sentinel head and tail nodes delete every null check from the pointer code, which removes the most common source of a crash in this round.
- Putting eviction behind an interface makes LRU, LFU, FIFO and Random four small classes that share one unchanged Cache.
- LFU's minFreq integer turns finding the least-used key from an O(n) search into a single lookup, and it can only ever move up by one.
- Hit, miss and eviction counters cost four lines and are the only objective evidence that the cache is earning the memory it occupies.
Common mistakes
- The policy seam costs one extra hash lookup and one extra node object per entry compared with the welded version where the map's value is the node.
- Every operation mutates shared state, so one lock is the honest answer and a read-write lock buys nothing — throughput is capped until you stripe.
- LRU is destroyed by a single scan of a large dataset, which sweeps every useful entry out of the cache in one pass.
- Pure LFU never forgets, so a key that was hot once during a batch job outlives keys that are genuinely hot today unless you add aging or a window.
- Capacity counted in entries says nothing about memory, so a cache of 1000 entries can be four megabytes or four gigabytes without any code changing.
Reference
Code & further reading
A minimal reference implementation and pointers worth bookmarking.
import java.util.*;
/** WHICH KEY DIES NEXT — the only decision that varies between caches. */
interface EvictionPolicy<K> {
void recordInsert(K key); // a brand-new key entered the cache
void recordAccess(K key); // an existing key was read or overwritten
void recordRemove(K key); // a key left the cache
K evictCandidate(); // who dies next; null when empty
List<K> victimOrder(); // demo only: first element dies first
String name();
}
/**
* LRU. A doubly-linked list of keys with the most recently used at the head.
* The two sentinels are the whole trick: head.next and tail.prev always exist,
* so unlink() and insertAtHead() contain zero null checks and zero branches.
*/
final class LruPolicy<K> implements EvictionPolicy<K> {
private static final class Node<K> {
final K key;
Node<K> prev, next;
Node(K key) { this.key = key; }
}
private final Map<K, Node<K>> nodes = new HashMap<>();
private final Node<K> head = new Node<>(null); // MRU side — sentinel, holds no data
private final Node<K> tail = new Node<>(null); // LRU side — sentinel, holds no data
LruPolicy() { head.next = tail; tail.prev = head; }
/* ---- the pointer surgery: two assignments out, four assignments in ---- */
private void unlink(Node<K> n) {
n.prev.next = n.next; // 1
n.next.prev = n.prev; // 2
n.prev = null; n.next = null;
}
private void insertAtHead(Node<K> n) {
n.next = head.next; // 3
n.prev = head; // 4
head.next.prev = n; // 5 must run BEFORE 6
head.next = n; // 6
}
@Override public void recordInsert(K key) {
Node<K> n = new Node<>(key);
nodes.put(key, n);
insertAtHead(n);
}
/** THE line people forget. Delete it and LRU silently becomes FIFO. */
@Override public void recordAccess(K key) {
Node<K> n = nodes.get(key);
if (n == null) return;
unlink(n);
insertAtHead(n);
}
@Override public void recordRemove(K key) {
Node<K> n = nodes.remove(key);
if (n != null) unlink(n);
}
@Override public K evictCandidate() {
return tail.prev == head ? null : tail.prev.key; // no scan, no comparison
}
@Override public List<K> victimOrder() {
List<K> out = new ArrayList<>();
for (Node<K> n = tail.prev; n != head; n = n.prev) out.add(n.key);
return out;
}
@Override public String name() { return "LRU"; }
}
/**
* LFU. Three pieces and nothing else:
* key -> frequency
* freq -> the keys at that frequency, oldest first
* minFreq — so finding the victim is a lookup, never a search
* LinkedHashSet IS a hash map plus a doubly-linked list, so add / remove /
* first-element are all O(1). Re-adding a key moves it to the end = newest.
*/
final class LfuPolicy<K> implements EvictionPolicy<K> {
private final Map<K, Integer> freq = new HashMap<>();
private final Map<Integer, LinkedHashSet<K>> buckets = new HashMap<>();
private int minFreq = 0;
@Override public void recordInsert(K key) {
freq.put(key, 1);
buckets.computeIfAbsent(1, f -> new LinkedHashSet<>()).add(key);
minFreq = 1; // a fresh key is always the new minimum
}
@Override public void recordAccess(K key) {
Integer f = freq.get(key);
if (f == null) return;
LinkedHashSet<K> from = buckets.get(f);
from.remove(key);
if (from.isEmpty()) {
buckets.remove(f);
if (minFreq == f) minFreq = f + 1; // the ONLY place minFreq goes up
}
freq.put(key, f + 1);
buckets.computeIfAbsent(f + 1, x -> new LinkedHashSet<>()).add(key);
}
@Override public void recordRemove(K key) {
Integer f = freq.remove(key);
if (f == null) return;
LinkedHashSet<K> bucket = buckets.get(f);
if (bucket == null) return;
bucket.remove(key);
if (bucket.isEmpty()) buckets.remove(f);
}
/** The tie inside a bucket is broken by recency — LFU has an LRU inside it. */
@Override public K evictCandidate() {
LinkedHashSet<K> bucket = buckets.get(minFreq);
if (bucket == null || bucket.isEmpty()) return null;
return bucket.iterator().next();
}
@Override public List<K> victimOrder() {
List<Integer> levels = new ArrayList<>(buckets.keySet());
Collections.sort(levels);
List<K> out = new ArrayList<>();
for (int f : levels) out.addAll(buckets.get(f));
return out;
}
@Override public String name() { return "LFU"; }
}
/** FIFO is LRU with recordAccess() emptied out. That is the entire difference. */
final class FifoPolicy<K> implements EvictionPolicy<K> {
private final Deque<K> queue = new ArrayDeque<>();
@Override public void recordInsert(K key) { queue.addLast(key); }
@Override public void recordAccess(K key) { /* nothing — insertion order only */ }
@Override public void recordRemove(K key) { queue.remove(key); }
@Override public K evictCandidate() { return queue.peekFirst(); }
@Override public List<K> victimOrder() { return new ArrayList<>(queue); }
@Override public String name() { return "FIFO (the bug)"; }
}
/** Owns storage, capacity and counters. Owns NO opinion about who dies. */
final class Cache<K, V> {
private final int capacity;
private final Map<K, V> store = new HashMap<>();
private final EvictionPolicy<K> policy;
private long hits, misses, evictions;
Cache(int capacity, EvictionPolicy<K> policy) {
if (capacity <= 0) throw new IllegalArgumentException("capacity must be > 0");
this.capacity = capacity;
this.policy = policy;
}
/** A READ MUTATES: recordAccess rewrites the list, so get() is guarded too. */
public synchronized Optional<V> get(K key) {
V value = store.get(key);
if (value == null) { misses++; return Optional.empty(); }
hits++;
policy.recordAccess(key);
return Optional.of(value);
}
public synchronized void put(K key, V value) {
if (store.containsKey(key)) { // PATH 1 — update. Size unchanged, nobody dies.
store.put(key, value);
policy.recordAccess(key);
return;
}
if (store.size() >= capacity) { // PATH 2 — new key: make room FIRST
K victim = policy.evictCandidate();
if (victim != null) {
store.remove(victim);
policy.recordRemove(victim);
evictions++;
}
}
store.put(key, value);
policy.recordInsert(key);
}
public synchronized int size() { return store.size(); }
public synchronized List<K> victimOrder() { return policy.victimOrder(); }
public synchronized String stats() {
long total = hits + misses;
double ratio = total == 0 ? 0 : (100.0 * hits) / total;
return String.format("hits=%d misses=%d evictions=%d hitRatio=%.0f%%", hits, misses, evictions, ratio);
}
}
public class CacheDemo {
private static final String[] TRACE = { "A", "A", "A", "B", "C", "B", "D", "E", "B", "A" };
/** The slow thing the cache exists to avoid. */
private static String load(String key) { return "value-of-" + key; }
private static void run(EvictionPolicy<String> policy, int capacity) {
Cache<String, String> cache = new Cache<>(capacity, policy);
System.out.println("---- " + policy.name() + " - capacity " + capacity + " ----");
for (int step = 0; step < TRACE.length; step++) {
String key = TRACE[step];
boolean hit = cache.get(key).isPresent();
String note = "";
if (!hit) {
List<String> before = cache.victimOrder();
String victim = (cache.size() >= capacity && !before.isEmpty()) ? before.get(0) : null;
cache.put(key, load(key));
if (victim != null) note = " evicted " + victim;
}
System.out.printf(" %2d get(%s) %-4s next-to-die %s%s%n",
step + 1, key, hit ? "HIT" : "MISS", cache.victimOrder(), note);
}
System.out.println(" " + cache.stats());
System.out.println();
}
public static void main(String[] args) {
run(new LruPolicy<>(), 3);
run(new LfuPolicy<>(), 3);
run(new FifoPolicy<>(), 3); // LRU with recordAccess() removed — watch the hit ratio
}
}
/* ---- expected output ------------------------------------------------------
---- LRU - capacity 3 ----
1 get(A) MISS next-to-die [A]
2 get(A) HIT next-to-die [A]
3 get(A) HIT next-to-die [A]
4 get(B) MISS next-to-die [A, B]
5 get(C) MISS next-to-die [A, B, C]
6 get(B) HIT next-to-die [A, C, B]
7 get(D) MISS next-to-die [C, B, D] evicted A
8 get(E) MISS next-to-die [B, D, E] evicted C
9 get(B) HIT next-to-die [D, E, B]
10 get(A) MISS next-to-die [E, B, A] evicted D
hits=4 misses=6 evictions=3 hitRatio=40%
---- LFU - capacity 3 ----
6 get(B) HIT next-to-die [C, B, A]
7 get(D) MISS next-to-die [D, B, A] evicted C
8 get(E) MISS next-to-die [E, B, A] evicted D
9 get(B) HIT next-to-die [E, A, B]
10 get(A) HIT next-to-die [E, B, A]
hits=5 misses=5 evictions=2 hitRatio=50%
---- FIFO (the bug) - capacity 3 ----
hits=3 misses=7 evictions=4 hitRatio=30%
--------------------------------------------------------------------------- */References & further reading
7 sources- Docsdocs.oracle.com
LinkedHashMap — Java API documentation
The six-line LRU: access-order mode plus removeEldestEntry. Read it so you can name the shortcut and then explain why you are not using it.
- Paperarxiv.org
An O(1) algorithm for implementing the LFU cache eviction scheme
Shah, Mitra and Matani. Eight pages, and it is exactly the frequency-bucket construction with minFreq that the lesson builds.
- Paperarxiv.org
TinyLFU: A Highly Efficient Cache Admission Policy
The answer to LFU's never-forgetting problem: a windowed frequency sketch used as an admission filter. This is what modern caches actually ship.
- Docsgithub.com
Caffeine — Efficiency
Hit-ratio graphs for LRU, LFU and W-TinyLFU on real traces. The clearest evidence anywhere that policy choice is a measurable thing, not a taste.
- Docsredis.io
Redis — key eviction policies
How a production system does it: approximated LRU by sampling rather than an exact list, plus LFU with a logarithmic counter and a decay period.
- Articledanluu.com
2-choices eviction — Dan Luu
A short piece on how sampling two random entries and evicting the older one gets you most of LRU's quality with none of its bookkeeping.
- Book
Java Concurrency in Practice — Goetz et al.
Chapter 5 covers exactly the trap in this lesson: a method that looks like a read but mutates shared state, and why a read-write lock does not save you.
Knowledge check
Did it land?
Quick questions, answers revealed on submit. Sign in to save your best score.
question 01 / 08
Why is a HashMap on its own not enough to build an LRU cache?
question 02 / 08
Why must the linked list be doubly linked rather than singly linked?
question 03 / 08
What do the sentinel HEAD and TAIL nodes actually buy you?
question 04 / 08
A candidate implements the list correctly but forgets to move the node on a cache hit. What happens?
question 05 / 08
What must put(key, value) do when the key is already present?
question 06 / 08
In an O(1) LFU cache, how do you find the least-frequently-used key without scanning?
question 07 / 08
The interviewer asks you to make the cache thread-safe. Why does a ReadWriteLock not help the way people expect?
question 08 / 08
You have a working LRU cache and the interviewer says “now make it LFU”. What determines whether that is a five-minute answer or a rewrite?
0/8 answered