This gives away every stage. For a clean attempt, practise it first, then come back.
The short version
- An LRU cache needs two things at once: find any key instantly, and know which key was used longest ago. No single data structure does both in O(1).
- A hash map finds nodes; a doubly linked list keeps them in order of use. The map points into the list, so every operation is a lookup plus a few pointer changes.
- Small details carry the design: the node stores its own key (so eviction can clean the map), the list is doubly linked (so a node can unlink itself), and sentinel nodes remove every null check.
- A thread-safe map is not enough. Even get() changes the list, so a read-write lock doesn’t help either. One lock is correct; shards make it scale, if you pick the shard with the right bits.
- Expiry, statistics and other eviction rules are added by wrapping the cache or writing a sibling class, never by editing the tested one.
How this walkthrough is built
An LRU cache is the classic low-level design problem with a data-structure heart. It’s asked at almost every level, from new graduates to senior engineers, and the bar rises with the level. A junior answer gets O(1) get and put working. A senior answer also makes it safe under threads, explains why the obvious fixes fail, and extends it without rewriting it.
We’ll build it in the same order you would in an interview. Each step uses only what the steps before it produced:
| Step | What you do | What comes out of it |
|---|---|---|
| 1. Understand the problem | Ask questions, write down requirements | What “least recently used” means, exactly |
| 2. Break it into entities | Find the pieces, and pick the data structures | A hash map plus a doubly linked list, and why |
| 3. Choose a direction | Top-down for designing, bottom-up for coding | The order you’ll work in |
| 4. Design each class | Decide what each class knows and does | A “card” per class |
| 5. Connect the classes | Pick the right kind of relationship | Who owns whom |
| 6. Draw the diagram | Build it up in three versions | The complete class diagram |
| 7. Name the patterns | Spot where the requirements asked for one | Interfaces, Observer, and why not Strategy yet |
| 8. Walk the flows | Trace get and put call by call | Three details you’d otherwise miss |
| 9. Write the code | Bottom-up, one class at a time | Code that compiles and runs |
| 10. Make it safe | Find shared state, fix the races, measure | One lock, then shards, with real numbers |
| 11. Extend it | Answer follow-ups | Wrappers and sibling classes, no edits |
The one sentence to remember: an LRU cache is a hash map that finds things, joined to a list that remembers order. Everything else on this page is detail around that sentence, and the details are where interviews are won.
Step 1Understand the problem
Picture the real thing first
Think about the “recent files” menu in your code editor. It shows, say, the last ten files you opened. Open an eleventh, and the one you haven’t touched for the longest time drops off the bottom. Open a file that’s already in the list, and it jumps to the top without anything dropping off.
That’s exactly an LRU cache. LRU stands for least recently used: when there’s no room, throw away the thing that was used longest ago. The bet is that something you used a minute ago is more likely to be needed again than something you used yesterday.
Real systems make this bet everywhere. Your browser keeps recently viewed images. A database keeps recently read pages in memory. An app server keeps recently loaded user profiles so it doesn’t ask the database every time. In each case memory is limited, and the cache has to decide what to forget.
Ask before you design
Interviewer: Design an LRU cache.
You: Sure. A few questions first. What operations do callers need? I’m assuming get and put.
Interviewer: Yes. Get a value by key, and put a key with a value.
You: Is the size limit a number of entries, or an amount of memory?
Interviewer: Number of entries. It’s fixed when the cache is created.
You: Does a get count as “using” the entry, so it becomes most recent?
Interviewer: Yes. Both get and put count as use.
You: If I put a key that’s already there, is that an update?
Interviewer: Yes. Update the value and make it most recent. Nothing should be evicted for that.
You: What are the speed requirements?
Interviewer: Both get and put in constant time, O(1).
You: Will several threads use the same cache at once?
Interviewer: Yes, it’ll sit inside a web server.
You: Should callers find out when something is evicted? For example, to save it somewhere first.
Interviewer: Good idea. Yes, let callers register for that.
You: What about expiry by time, or other eviction rules like least frequently used?
Interviewer: Not for now. We might come back to them.
You: Last one. What types are keys and values?
Interviewer: Any types. Make it generic.
Each question changed something in the design:
| Question | What the answer decided |
|---|---|
| Which operations? | The public surface: get, put, plus remove and size for completeness. |
| Entries or memory? | Capacity is a simple count. (Byte-based limits are a follow-up.) |
| Does get count as use? | get changes the order. This one answer will matter a lot for thread safety. |
| Put on an existing key? | Update in place, move to front, no eviction. A common source of off-by-one bugs. |
| O(1) both? | Rules out every “obvious” structure on its own. Forces the map-plus-list design. |
| Several threads? | Concurrency is a core requirement, not a follow-up. |
| Eviction callback? | A listener interface: the Observer pattern. |
| Expiry, other rules? | Out of scope now, but the design must leave room for them. |
| Generic? | Cache, with type parameters throughout. |
Functional requirements
- FR1.
get(key)returns the value if the key is present, and marks it as the most recently used. - FR2.
put(key, value)inserts a new entry or updates an existing one, and marks it as the most recently used. - FR3. The cache holds at most a fixed number of entries, set when it’s created. When it’s full, putting a new key first evicts the least recently used entry.
- FR4.
remove(key)deletes an entry.size()says how many entries there are. - FR5. Callers can register to be told when an entry is evicted.
- FR6. Keys and values can be any type.
Non-functional requirements
- NFR1: Fast.
getandputrun in O(1) time, no matter how big the cache is. - NFR2: Safe under concurrency. Many threads can call it at once, and its internal structure never breaks.
- NFR3: Bounded. It never holds more entries than its capacity, not even for a moment that another thread could see.
- NFR4: Easy to extend. Expiry, statistics and other eviction rules can be added without editing the core class.
O(1) means “constant time”: the work doesn’t grow as the cache grows. A cache with a million entries answers as fast as one with ten. If you’ve only met this idea briefly, here’s the practical meaning: no loops over the entries, ever, inside get or put.
Assumptions we’ll write down
| Assumption | Why it’s reasonable |
|---|---|
| Null keys and null values are rejected | A missing key and a key mapped to null would look the same to callers |
get returns an Optional instead of null | It makes “not found” impossible to ignore |
A put that updates doesn’t evict | The number of entries doesn’t change |
| Capacity of zero or less is an error | A cache that can hold nothing is a bug in the caller’s setup |
| Everything is in one process’s memory | A shared cache across servers is a different problem (see the follow-ups) |
Step 2Break it into entities
In most LLD problems this step is about finding classes. In this one it’s mostly about finding the right data structures, because the classes follow from them. So we’ll do the usual noun hunt quickly, then spend real time on the structure choice.
Pull out the nouns
From the requirements: cache, key, value, entry, capacity, order of use, least recently used entry, eviction, listener, thread.
| Noun | Own data? | Own rules? | Becomes |
|---|---|---|---|
| Cache | entries, capacity | evict when full; get and put mark use | LRUCache class, behind a Cache interface |
| Entry | key, value, its place in the order | — | Node (we’ll see why it’s a node and not just a pair) |
| Order of use | which entry is newest and oldest | move to front, remove from back | DoublyLinkedList class |
| Lookup by key | key → entry | — | a HashMap, used inside the cache |
| Capacity | — | — | a field on the cache |
| Eviction | — | — | a step inside put, not a class |
| Listener | — | — | EvictionListener interface |
| Key, value | — | — | type parameters K and V |
| Thread | — | — | nothing: threads are the callers, not part of the cache |
The interesting row is “order of use”. In the parking lot, every class was a real-world thing. Here, the most important class is an idea: the order in which entries were used. That’s normal for data-structure problems. The trick is noticing that the order has its own rules (move to front, remove the oldest) and so deserves its own class, instead of being scattered through the cache’s methods.
The data structure trap
Most candidates either jump straight to “hash map plus doubly linked list” (memorised, and the interviewer can tell) or try something simpler that quietly breaks O(1). The strong move is to reason through the options out loud. Each one fails in a way that points at the next.
Option 1: just a hash map. get and put are O(1). But when the cache is full, which key do you evict? A hash map has no idea which key was used longest ago. Dead end.
Option 2: a hash map plus a “last used” time on each entry. Now you know when each key was used. But to find the oldest, you have to look at every entry:
// evicting means scanning everything: O(n)
K oldest = null;
long oldestTime = Long.MAX_VALUE;
for (Map.Entry> e : map.entrySet()) { 1
if (e.getValue().lastUsed < oldestTime) {
oldestTime = e.getValue().lastUsed;
oldest = e.getKey();
}
} - 1a million entries, a million checks
get is O(1), but every eviction is O(n). For a full cache, that’s every put.
Option 3: a hash map plus a priority queue ordered by time. A priority queue (a heap) gives the oldest in O(1) and removes it in O(log n). Better. But every get changes an entry’s time, and moving an entry inside a heap is O(log n) at best (and Java’s PriorityQueue makes it O(n), because removing an arbitrary element means searching for it). Close, but not O(1).
Option 4: a list in order of use. Newest at the front, oldest at the back. Evicting is “remove from the back”: O(1). Marking as used is “move to the front”. But to move an entry, you first have to find it in the list, which is O(n).
Look at what options 1 and 4 are each missing. The map can find in O(1) but has no order. The list has order but can’t find. So combine them: the map stores, for each key, a direct pointer to that key’s node in the list. Finding is a map lookup. Moving is a few pointer changes on a node you’re already holding. Neither needs a loop.
| Design | get | put (with eviction) | Problem |
|---|---|---|---|
| Hash map alone | O(1) | — | Can’t tell which key is oldest |
| Map + timestamps | O(1) | O(n) | Must scan to find the oldest |
| Map + priority queue | O(log n) or worse | O(log n) | Every use reorders the heap |
| List alone | O(n) | O(1) | Must scan to find a key |
| Map + doubly linked list | O(1) | O(1) | — |
Three details that make it work
The combined design has three small details. Each one looks optional until you try to write the code without it.
1. The list must be doubly linked. To unlink a node from the middle of a list, you need to change the pointer in the node before it. In a singly linked list, a node doesn’t know what’s before it, so you’d have to walk from the start to find out: O(n). With a prev pointer on every node, a node can unlink itself in O(1).
2. The node must store its own key. When we evict, we take the node at the back of the list. Then we have to remove that key from the map too, or the map points at a node that’s no longer in the list. But the list holds nodes, and if a node only held a value, we wouldn’t know which map key to remove. So each node keeps its key, even though the map already has it. A little extra memory buys O(1) eviction.
3. Sentinel nodes at both ends. A sentinel is a dummy node that never holds data. We put one permanently at the front (head) and one at the back (tail). Then every real node always has a real prev and a real next, even when it’s the only node. Without sentinels, every insert and unlink needs if (node == first), if (node == last) and if (list is empty) checks, and those checks are exactly where bugs hide.
Say all three out loud in the interview. They’re what separate an understood design from a memorised one.
Pull out the verbs
The verbs from the requirements: find, mark as used, insert, update, evict, remove, notify.
| Use case | Who starts it | Steps inside it |
|---|---|---|
| UC1: Get | Any caller | find the node; if found, move it to the front; return its value |
| UC2: Put | Any caller | if the key exists: update value, move to front. Otherwise: if full, evict the back node and tell listeners; add a new node at the front |
| UC3: Remove | Any caller | find the node; unlink it from the list; remove it from the map |
Give each job to the class that knows the most
Information Expert: give a job to the class that already has the information to do it.
| Job | Information needed | Owner |
|---|---|---|
| Find the node for a key | the key-to-node index | LRUCache (through its map) |
| Move a node to the front, unlink it, take the oldest | the list’s pointers and sentinels | DoublyLinkedList |
| Hold one entry’s key, value and neighbours | the entry itself | Node |
| Decide when to evict | the current size and the capacity | LRUCache |
| Keep map and list in step | both of them | LRUCache: it’s the only class that has both |
| React to an eviction | whatever the caller wants to do | the caller’s EvictionListener |
Notice what the list does not know: keys as lookups, capacity, eviction rules. It’s a plain ordered list of nodes with O(1) moves. That keeps it simple and testable on its own. All the policy lives in LRUCache.
CRC cards
| Class | Responsibilities | Collaborators |
|---|---|---|
Node | hold key, value, and links to neighbours | — |
DoublyLinkedList | add at front; unlink any node; move to front; remove last | Node |
LRUCache | get, put, remove, size; keep map and list consistent; evict when full; lock | DoublyLinkedList, Node, HashMap, EvictionListener |
Cache | the contract callers depend on | — |
EvictionListener | be told about each eviction | — |
Find what will change
| Will change | Hint | Becomes |
|---|---|---|
| Extra behaviour around the cache: expiry, statistics, logging | “might come back to expiry” | a Cache interface, so wrappers can stand in for the cache |
| What happens on eviction | “let callers register” | EvictionListener interface |
| The eviction rule itself (LRU, LFU…) | “other rules later” | not an interface yet. Explained in step 7. |
The last row is a deliberate decision, and it’s worth defending in the interview. We’ll come back to it.
Step 3Top-down or bottom-up?
Top-down starts with what callers need:
- Callers need
get(key)andput(key, value). SoCacheandLRUCacheexist. - To get in O(1), the cache needs to find a key instantly. So it holds a
HashMap. - To evict the oldest in O(1), it needs entries in order of use. So it holds a
DoublyLinkedList. - To move an entry in O(1), the list needs nodes that can unlink themselves. So
Nodehasprevandnext.
Bottom-up goes the other way: write Node, then a DoublyLinkedList you can test by itself, then the LRUCache that combines it with a map.
For this problem, bottom-up coding matters even more than usual. The list is where nearly all the bugs live: a missed pointer update leaves the list broken in a way that only shows up much later. If you write and check the list first, the cache on top of it is short and almost boring. So again: design top-down, code bottom-up.
Step 4Design each class
LRUCache: the class that keeps two structures in step
Job: answer get, put and remove in O(1), and keep the map and the list describing exactly the same set of entries.
| Knows | Does | Doesn’t do |
|---|---|---|
| its capacity | get(key) → Optional | pointer surgery (the list does it) |
a HashMap | put(key, value) | decide what a caller does on eviction |
a DoublyLinkedList | remove(key) → boolean | |
| an eviction listener | size() | |
| a lock |
The single most important rule inside this class: the map and the list must always contain the same nodes. Every method changes both or neither. If a key is in the map but not the list, it will never be evicted. If a node is in the list but not the map, it takes up space forever and can’t be found. Most broken LRU caches break this rule somewhere.
DoublyLinkedList: order of use, nothing else
Job: keep nodes in order from most to least recently used, with O(1) changes.
| Knows | Does | Doesn’t do |
|---|---|---|
a head sentinel and a tail sentinel | addFirst(node) | know about keys as lookups |
| its size | unlink(node) | know the capacity |
moveToFront(node) | decide when to evict | |
removeLast() → node |
It’s written by hand, not taken from Java’s LinkedList, and the reason is worth knowing. Java’s LinkedList doesn’t give you its nodes. To move an element, you’d call remove(object), which searches from the start: O(n). We need to hold a node and unlink it directly.
Node: one entry
Job: hold one entry and its two neighbours.
| Knows | Does |
|---|---|
key (final) | nothing: it’s plain data |
value (can change on update) | |
prev, next |
The key is final because a node never changes which key it belongs to. The value isn’t, because put on an existing key updates it in place, which is cheaper than replacing the node.
Cache and EvictionListener: the contracts
Cache has four methods: get, put, remove, size. Callers depend on this, never on LRUCache directly, so a wrapper that adds expiry or statistics can be handed to them instead.
EvictionListener has one method, evicted(key, value). Because it has a single method, a caller can pass a lambda: (k, v) -> database.save(k, v).
Step 5How the classes relate
| Relationship | In this design | Why that kind |
|---|---|---|
| Realization | LRUCache implements Cache | callers depend on the contract |
| Composition | LRUCache owns its DoublyLinkedList and its map | they’re private parts; they mean nothing without the cache |
| Composition | the list owns its two sentinel nodes | created with the list, never shared |
| Association | the list links to Nodes; the map points to the same Nodes | both structures share each node; neither alone owns it |
| Association | LRUCache holds an EvictionListener | given from outside, lives on its own |
| Self-association | a Node points to other Nodes (prev, next) | that’s what makes it a list |
The shared node is the heart of the design: one node object, reachable from two structures. The map reaches it by key; the list reaches it by position. That’s why updating one node is visible to both instantly, and why both must always agree about which nodes exist.
Step 6The class diagram, built up
Version 1: the two structures
Start with the core idea: a cache holding a map and a list, both pointing at nodes.
Version 2: the contracts
Add the interface callers depend on, and the listener for evictions.
Version 3: the whole picture
Check it against the requirements
| Requirement | Where it lives |
|---|---|
| FR1: get marks as used | LRUCache.get → order.moveToFront |
| FR2: put inserts or updates, marks as used | LRUCache.put, both branches end at the front |
| FR3: fixed capacity, evict least recent | capacity check in put → order.removeLast + map.remove |
| FR4: remove and size | LRUCache.remove, LRUCache.size |
| FR5: eviction notifications | EvictionListener, called from put |
| FR6: any types | type parameters K, V everywhere |
| NFR1: O(1) | map lookup + constant pointer changes; no loops |
| NFR2: concurrency | one ReentrantLock around every operation (step 10) |
| NFR3: bounded | eviction happens before the insert, inside the lock |
| NFR4: extensible | Cache interface for wrappers; listener for reactions |
Step 7The patterns, and why each one is here
| Pattern | Where | Problem it solved |
|---|---|---|
| Programming to an interface | Cache | callers can be given a wrapped or different cache without changing |
| Observer | EvictionListener | callers react to evictions without the cache knowing what they do |
| Decorator (in the follow-ups) | TtlCache, RecordingCache | add expiry or statistics by wrapping, not editing |
| Sentinel objects | head and tail | remove every special case for empty lists and end nodes |
Why the eviction rule isn’t a Strategy (yet)
A tempting “senior-looking” design pulls the eviction rule out into an interface: EvictionPolicy with methods like keyAccessed(key) and evictKey(), and an LRUPolicy that implements it. Then LFU is just another policy.
It sounds clean, but look at what it costs. The policy needs its own way to find a key’s position in O(1), so it needs its own key-to-node map. Now every key is stored in two maps: the cache’s map of values and the policy’s map of positions. Twice the memory for the index, and two structures to keep in sync on every operation.
There’s also a deeper problem. LRU and LFU don’t just evict differently; they need different data structures to be O(1). LRU needs one list. O(1) LFU needs a list per usage count and a pointer to the smallest count (you’ll see it in step 11). An interface general enough for both ends up very vague.
So the honest answer is: *for one eviction rule, a dedicated class is simpler and faster. If the system genuinely needs swappable rules, write each rule as its own Cache implementation, sharing the Cache interface.* That’s what we do in the follow-ups. Saying this, with the reason, impresses more than adding the interface by reflex.
Why not a Singleton
“There’s one cache for the app, so make it a Singleton” comes up often. Same answer as the parking lot: tests need fresh caches with known sizes, apps often need several caches (users, sessions, product pages), and a global object hides who depends on it. Create caches where the app starts and pass them to the code that needs them.
Step 8Walk the flows
Watch it run
Before the sequence diagrams, see the whole thing work. A cache with room for three entries:
get: a hit
- Lock, so no other thread changes the structures while we work.
- One map lookup finds the node directly. No searching.
- The list moves that node to the front: unlink it (two pointer changes on its neighbours) and add it after
head(four pointer changes). - Unlock and return the value, wrapped in an
Optional.
A miss is shorter: the map returns null, we unlock and return Optional.empty(). The list isn’t touched.
put: a new key into a full cache
- Check whether the key exists. It doesn’t, so this is an insert.
- The cache is full, so evict first: take the last node from the list, then remove its key from the map. This is detail 2 from step 2 paying off: the node knows its key.
- Create the new node, add it at the front, and put it in the map.
- Unlock, then tell the listener. Step 10 explains why the order matters.
put: an existing key
If the map already has the key, the flow is: update the node’s value, move it to the front, unlock. No eviction, no new node. Forgetting this branch, and evicting on every put, is one of the most common bugs in this problem.
A full trace
Here’s a real run with capacity 3, printing the list from most to least recent after each operation:
put A [A]
put B [B, A]
put C [C, B, A]
get A = 1 [A, C, B]
put D [D, A, C] evicted [B=2]
get B = miss [D, A, C]
put C=30 [C, D, A] get C = 30
put E [E, C, D] evicted [B=2, A=1]Read it line by line. After get A, A jumps to the front, so B becomes the oldest. put D evicts B, not A, because A was just used. put C=30 updates C in place and moves it to the front with no eviction. put E then evicts A, which had become the oldest.
What walking the flows taught us
| Found while walking | Consequence |
|---|---|
| Eviction takes a node from the list but must also clean the map | the node stores its key |
| Updating an existing key must not evict anything | put checks for the key before checking capacity |
| The listener is caller code that could be slow or could throw | call it after releasing the lock |
| A miss doesn’t change anything | get returns before touching the list |
The life of an entry
| Entry is | Moves to | When |
|---|---|---|
| (not in cache) | Most recent | put of a new key |
| Anywhere in the list | Most recent | get hit, or put of an existing key |
| Most recent | Gradually older | other keys are used |
| Least recent | Evicted | put of a new key while full; listener told |
| Anywhere | Removed | remove(key); listener not told (the caller asked for it) |
When things go wrong
| Situation | What happens | Where |
|---|---|---|
null key or value | NullPointerException straight away | get, put |
| Capacity of zero or less | IllegalArgumentException when created | constructor |
| Listener throws | the cache is already consistent; the exception reaches the put caller | put, after unlocking |
| Two threads change the list at once | prevented by the lock | every method |
Step 9Write the code, bottom-up
Every block below is part of one program that compiles and runs. The outputs on this page come from running it.
9.1 The contracts
interface Cache {
Optional get(K key);
void put(K key, V value);
boolean remove(K key);
int size();
}
interface EvictionListener { void evicted(K key, V value); } Generics are the parts: placeholders for “whatever key type and value type the caller chooses”. A Cache only accepts String keys and only returns User values, checked by the compiler.
9.2 Node
final class Node {
final K key; 1
V value; 2
Node prev, next;
Node(K key, V value) { this.key = key; this.value = value; }
} - 1never changes; needed to clean the map on eviction
- 2changes when put() updates an existing key
9.3 DoublyLinkedList
This is where the bugs live, so it gets the most care. Draw boxes and arrows on paper the first few times you write addFirst and unlink; there’s no shame in it, and interviewers like seeing it.
final class DoublyLinkedList {
private final Node head = new Node<>(null, null); 1
private final Node tail = new Node<>(null, null); 2
private int size;
DoublyLinkedList() { head.next = tail; tail.prev = head; }
void addFirst(Node n) {
n.prev = head;
n.next = head.next; 3
head.next.prev = n;
head.next = n;
size++;
}
void unlink(Node n) {
n.prev.next = n.next; 4
n.next.prev = n.prev;
n.prev = null; 5
n.next = null;
size--;
}
void moveToFront(Node n) { unlink(n); addFirst(n); }
Node removeLast() {
if (size == 0) return null;
Node last = tail.prev;
unlink(last);
return last;
}
int size() { return size; }
} - 1sentinel before the most recent
- 2sentinel after the least recent
- 3the old first node (or tail, if empty)
- 4neighbours skip over n
- 5n no longer points into the list
Look at unlink. Thanks to the sentinels, n.prev and n.next are never null for a node in the list, even if it’s the only one: its neighbours are head and tail. So there are no if statements at all. Without sentinels, this method needs four branches.
Note (3) is the line people most often get wrong. The order of the four assignments in addFirst matters: head.next must be read before it’s overwritten.
Why clear n.prev and n.next in note (5)? It isn’t strictly needed for correctness, but it means a removed node doesn’t keep its old neighbours alive in memory, and if some bug ever tries to unlink the same node twice, it fails loudly with a NullPointerException instead of silently corrupting the list.
9.4 LRUCache: the fields and get
final class LRUCache implements Cache {
private final int capacity;
private final Map> map;
private final DoublyLinkedList order = new DoublyLinkedList<>();
private final EvictionListener listener;
private final ReentrantLock lock = new ReentrantLock();
LRUCache(int capacity) { this(capacity, (k, v) -> { }); } 1
LRUCache(int capacity, EvictionListener listener) {
if (capacity <= 0)
throw new IllegalArgumentException("capacity must be positive");
this.capacity = capacity;
this.map = new HashMap<>(capacity * 4 / 3 + 1); 2
this.listener = Objects.requireNonNull(listener);
}
public Optional get(K key) {
Objects.requireNonNull(key);
lock.lock();
try {
Node n = map.get(key);
if (n == null) return Optional.empty();
order.moveToFront(n);
return Optional.of(n.value);
} finally { lock.unlock(); } 3
} - 1no listener: do nothing
- 2sized so it never resizes
- 3always released
Note (2): a HashMap grows (and copies everything) when it’s 75% full. We know the maximum size in advance, so we make it big enough from the start. No surprise slow put halfway through the day.
Note (3): try ... finally guarantees the lock is released even if something throws. Forgetting this is how a whole server freezes: one exception, and every thread waits forever for a lock nobody will release.
9.5 LRUCache: put
public void put(K key, V value) {
Objects.requireNonNull(key);
Objects.requireNonNull(value);
Node victim = null;
lock.lock();
try {
Node n = map.get(key);
if (n != null) { 1
n.value = value;
order.moveToFront(n);
return;
}
if (map.size() == capacity) { 2
victim = order.removeLast();
map.remove(victim.key);
}
n = new Node<>(key, value);
order.addFirst(n);
map.put(key, n);
} finally { lock.unlock(); }
if (victim != null) listener.evicted(victim.key, victim.value); 3
} - 1existing key: update, no eviction
- 2full: make room first
- 3outside the lock
Three decisions, matching the three notes:
- The existing-key check comes first. If it came after the capacity check, updating a key in a full cache would evict something for no reason.
- Evict before inserting, so the cache never holds
capacity + 1entries, even for an instant (NFR3). - The listener runs after the lock is released. It’s someone else’s code: it might write to a database and take 50 milliseconds, or it might call back into the cache. Inside the lock, a slow listener would stall every thread, and a listener that calls
cache.get()could even deadlock on some lock types. Outside, the cache is already consistent and other threads carry on.
9.6 LRUCache: remove and size
public boolean remove(K key) {
lock.lock();
try {
Node n = map.remove(key);
if (n == null) return false;
order.unlink(n);
return true;
} finally { lock.unlock(); }
}
public int size() {
lock.lock();
try { return map.size(); } finally { lock.unlock(); }
}
} Even size() takes the lock. Without it, a thread could read the size in the middle of another thread’s put, and Java gives no guarantee about what it sees.
9.7 Putting it together
List evicted = new ArrayList<>();
LRUCache c = new LRUCache<>(3, (k, v) -> evicted.add(k + "=" + v));
c.put("A", 1);
c.put("B", 2);
c.put("C", 3);
c.get("A");
c.put("D", 4); 1
c.get("B"); 2
c.put("C", 30); 3
c.put("E", 5); 4 - 1evicts B: A was just used
- 2miss
- 3update in place
- 4evicts A
The output is the trace from step 8, plus the constructor check:
put D [D, A, C] evicted [B=2]
get B = miss [D, A, C]
put C=30 [C, D, A] get C = 30
put E [E, C, D] evicted [B=2, A=1]
capacity 0: capacity must be positiveStep 10Make it safe when threads share it
Interviewer: This sits in a web server with many threads. Is it safe?
You: Not without care. Let me list what’s shared and changes: the map, the list’s pointers, and the size count. Every one of them changes on put, and the list changes on every get too.
Why a thread-safe map isn’t enough
The tempting fix is to swap HashMap for ConcurrentHashMap and call it done. The map is now safe. But the list isn’t, and the list is where the danger is. Here’s what can happen when two threads get neighbouring keys at once:
| Time | Thread 1: get(B) | Thread 2: get(C) | List |
|---|---|---|---|
| 1 | unlink B: A.next = C | A → C, but C still says its prev is B | |
| 2 | unlink C: C.prev.next = C.next | C’s prev is still B, so it sets B.next, not A.next | |
| 3 | A still points to C, which is no longer in the list | ||
| 4 | addFirst(B) | addFirst(C) | pointers now form a shape that isn’t a list at all |
Each method is correct on its own. Interleaved, they corrupt the list. We tested exactly this design: a ConcurrentHashMap with an unprotected list, eight threads, 200,000 random gets and puts each.
unlocked run 0: crashed: NullPointerException
unlocked run 1: crashed: NullPointerException
unlocked run 2: crashed: NullPointerExceptionThree runs, three crashes. In production it’s often worse than a crash: a list with a loop in it makes the next removeLast or a debugging walk spin forever, with no error message at all.
Why a read-write lock doesn’t help
The next idea is a read-write lock: many readers at once, one writer at a time. It’s the right tool when reads don’t change anything. But in an LRU cache, every get changes the list, because it moves the node to the front. So every get would need the write lock, and you’re back to one-at-a-time, with a more complicated lock. This is the requirement from step 1, “get counts as use”, paying off. Mentioning it shows you understood why the problem is hard.
One lock: correct
The simple answer is one lock around every operation, which is what our code does. Is it correct? Same test, eight threads, then check that the list walked forwards matches the list walked backwards, its size matches the map’s size, and the cache never went over capacity:
locked run 0: size=100 healthy=true
locked run 1: size=100 healthy=true
locked run 2: size=100 healthy=trueEvery operation is a handful of pointer changes, so the lock is held for well under a microsecond. For most applications, this is the right answer and you should stop here.
Making it faster: shards
Interviewer: What if the lock becomes a bottleneck?
You: Split the cache into several smaller caches, called shards, each with its own lock. A key always goes to the same shard, chosen from its hash. Threads working on different shards never wait for each other.
final class ShardedCache implements Cache {
private final List> shards = new ArrayList<>();
ShardedCache(int shardCount, int capacityPerShard) {
for (int i = 0; i < shardCount; i++) shards.add(new LRUCache<>(capacityPerShard));
}
private LRUCache shardFor(K key) {
int mixed = key.hashCode() * 0x9E3779B9; 1
return shards.get((mixed >>> 16) % shards.size()); 2
}
public Optional get(K key) { return shardFor(key).get(key); }
public void put(K key, V value) { shardFor(key).put(key, value); }
public boolean remove(K key) { return shardFor(key).remove(key); }
public int size() { return shards.stream().mapToInt(LRUCache::size).sum(); }
} - 1scramble all the bits
- 2pick by the high bits
Notes (1) and (2) hide a real bug we hit while building this page. Our first version picked the shard with hashCode % 16, the obvious choice. It was slower than one lock, even with a single thread. The reason: Java’s HashMap also uses the low bits of the hash to choose a bucket. If shard 3 only ever receives keys whose low bits are 0011, then inside shard 3’s map, all those keys land in one-sixteenth of the buckets and pile up. Multiplying by a large odd constant scrambles the bits, and taking the shard from the high bits leaves the low bits varied for the map.
Here are real timings: 4 million operations (80% gets) on a cache of 16,000 entries, on an 8-core laptop.
| Threads | One lock | 4 shards | 16 shards |
|---|---|---|---|
| 1 | 138 ms | 175 ms | 189 ms |
| 8 | 301 ms | 283 ms | 215 ms |
Two honest lessons in that table. First, with eight threads, sixteen shards beat one lock by about 30%. Second, eight threads with one lock took longer than one thread did. When each operation is this tiny, threads spend more time handing the lock and the data back and forth between CPU cores than doing work. Sharding helps, but it doesn’t make the problem go away.
That’s why high-performance caches go further. Java’s Caffeine library doesn’t reorder its list on every read; it records reads in a small buffer and applies them in batches. Redis doesn’t keep an exact LRU order at all; when it needs to evict, it samples a few keys and evicts the oldest of the sample. Both trade a perfectly exact order for far less coordination. You don’t need to build these in an interview, but knowing they exist, and why, is exactly the kind of depth senior interviewers look for. Mention them and move on.
One more trade-off to name: with shards, “least recently used” is only exact within a shard. The cache as a whole might evict a key from a busy shard while an older key sits in a quiet one. For a cache, that’s almost always fine.
Step 11Follow-ups, extended without editing
Entries that expire
Interviewer: Session data should disappear after 5 minutes, even if there’s room.
Bad solutionA timer per entry
Schedule a task for each put that deletes the key 5 minutes later. A million entries means a million scheduled tasks, and a key that’s updated must cancel and reschedule its task. Lots of moving parts, and lots of memory.
Good solutionCheck the time on read
Store the expiry time with each value. On get, if it’s past, treat it as missing and remove it. No background work at all.
Great solutionDo it in a wrapper, so the core never changes
final class TtlCache implements Cache {
private record Entry(V value, Instant expiresAt) { }
private final LRUCache> inner; 1
private final Duration ttl;
private final Clock clock;
TtlCache(int capacity, Duration ttl, Clock clock) {
this.inner = new LRUCache<>(capacity);
this.ttl = ttl;
this.clock = clock;
}
public Optional get(K key) {
Optional> e = inner.get(key);
if (e.isEmpty()) return Optional.empty();
if (!clock.instant().isBefore(e.get().expiresAt())) { 2
inner.remove(key);
return Optional.empty();
}
return Optional.of(e.get().value());
}
public void put(K key, V value) {
inner.put(key, new Entry<>(value, clock.instant().plus(ttl)));
}
public boolean remove(K key) { return inner.remove(key); }
public int size() { return inner.size(); }
} - 1the tested cache, unchanged
- 2expired: drop it
ttl after 4 min: ayush
ttl after 6 min: expired, size now 0The clock is passed in, so the test moved time forward instead of waiting six minutes. One trade-off to mention: an expired entry that’s never read again still takes a slot until LRU eviction reaches it. If that matters, add a background sweep that runs every minute and removes expired entries. For most caches, LRU clears them soon enough.
Hit rate statistics
Interviewer: We want to know how often the cache actually helps.
Same trick, a wrapper:
final class RecordingCache implements Cache {
private final Cache inner;
private final LongAdder hits = new LongAdder(), misses = new LongAdder(); // (1)
RecordingCache(Cache inner) { this.inner = inner; }
public Optional get(K key) {
Optional v = inner.get(key);
(v.isPresent() ? hits : misses).increment();
return v;
}
public void put(K key, V value) { inner.put(key, value); }
public boolean remove(K key) { return inner.remove(key); }
public int size() { return inner.size(); }
double hitRate() {
long h = hits.sum(), total = h + misses.sum();
return total == 0 ? 0 : (double) h / total;
}
} LongAdder (note 1) is a counter built for many threads incrementing at once: each thread mostly updates its own private cell, and sum() adds them up. It avoids the very contention we measured in step 10.
Because it wraps any Cache, it works around LRUCache, TtlCache or ShardedCache alike: new RecordingCache<>(new TtlCache<>(...)). That’s the payoff of the Cache interface from step 2.
Least frequently used instead
Interviewer: Some keys are used constantly, all day. A burst of one-off keys shouldn’t push them out.
That’s what LFU (least frequently used) is for: evict the key used the fewest times, and among ties, the oldest. The O(1) version is clever, so it’s worth seeing:
final class LFUCache implements Cache {
private final int capacity;
private final Map values = new HashMap<>();
private final Map counts = new HashMap<>();
private final Map> byCount = new HashMap<>(); // (1)
private int minCount; // (2)
LFUCache(int capacity) { this.capacity = capacity; }
public synchronized Optional get(K key) {
if (!values.containsKey(key)) return Optional.empty();
touch(key);
return Optional.of(values.get(key));
}
public synchronized void put(K key, V value) {
if (values.containsKey(key)) { values.put(key, value); touch(key); return; }
if (values.size() == capacity) {
K victim = byCount.get(minCount).iterator().next(); 3
byCount.get(minCount).remove(victim);
values.remove(victim);
counts.remove(victim);
}
values.put(key, value);
counts.put(key, 1);
byCount.computeIfAbsent(1, c -> new LinkedHashSet<>()).add(key);
minCount = 1; 4
}
private void touch(K key) {
int c = counts.get(key);
counts.put(key, c + 1);
byCount.get(c).remove(key);
if (c == minCount && byCount.get(c).isEmpty()) minCount = c + 1;
byCount.computeIfAbsent(c + 1, x -> new LinkedHashSet<>()).add(key);
}
public synchronized boolean remove(K key) {
if (!values.containsKey(key)) return false;
int c = counts.remove(key);
byCount.get(c).remove(key);
values.remove(key);
return true;
}
public synchronized int size() { return values.size(); }
} - 3least used, oldest first
- 4a new key is always the rarest
The idea: group keys by how many times they’ve been used (note 1), and remember the smallest group that isn’t empty (note 2). A LinkedHashSet keeps insertion order, so the first key in the smallest group is the least used and the oldest among equals (note 3). Every step is a map lookup or a set operation: O(1).
Same operations on both caches (capacity 2: put A, put B, get A twice, get B once, put C):
lfu after put C: A=true B=false C=true
lru after put C: A=false B=true C=trueLRU evicted A, the heavy user, because B was touched more recently. LFU kept A because it was used more often. Neither is “better”; they bet on different patterns. LFU’s weakness is the opposite: a key that was popular yesterday keeps its high count and can sit in the cache long after it stops being useful. Real systems often blend the two.
Notice this is a sibling class implementing the same Cache interface, not a policy plugged into LRUCache. That’s the decision from step 7, now justified: the data structures are completely different.
“Can’t you just use LinkedHashMap?”
Interviewer: Java already has something for this, doesn’t it?
Yes. LinkedHashMap can keep entries in access order, and lets you decide when to drop the eldest:
final class JdkLru extends LinkedHashMap {
private final int capacity;
JdkLru(int capacity) { super(16, 0.75f, true); this.capacity = capacity; } 1
@Override protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > capacity; 2
}
} - 1true = access order
- 2checked after each put
LinkedHashMap version: [C, A, D](It prints least recent first, the opposite of our trace, but it’s the same order: put A, B, C, get A, put D evicted B.)
Say this in the interview, early. It shows you know the library. Then the interviewer will almost always say “now build it yourself”, because the point of the question is the data structures. It’s also not thread-safe, and it briefly holds capacity + 1 entries during a put, since it removes the eldest after inserting. In real code, for a single-threaded cache, it’s a perfectly good choice.
A cache shared by many servers
Interviewer: We have 50 app servers. Each has its own cache, so the same user is loaded 50 times. Can they share?
That’s no longer a low-level design question. It becomes a separate cache service, like Redis or Memcached, reached over the network, with questions about which server holds which key, what happens when one dies, and how stale data is kept in check. The caching lesson covers that side. A good answer here names the boundary: “Inside one process, this class. Across servers, a cache service, and this class can still sit in front of it as a small local layer for the hottest keys.”
Mistakes people make on this one
- Reciting “hash map plus doubly linked list” without explaining why simpler structures fail.
- A singly linked list, then discovering unlink is O(n).
- Forgetting the key in the node, so eviction can’t clean the map.
- Updating an existing key by evicting first, so the cache loses an entry for nothing.
- Inserting before evicting, so the cache briefly holds one entry too many.
- *Using Java’s
LinkedList* and callingremove(object), which searches. - No sentinels, and a tangle of null checks with one missing.
- ”Use ConcurrentHashMap” as the whole thread-safety answer. The list still breaks.
- A read-write lock, without noticing that every get writes.
- Calling the eviction listener inside the lock.
- *Picking shards with
hash % n* and colliding with the map’s own buckets.
How this answer gets scored
| Moment | What earns credit | What costs you |
|---|---|---|
| Requirements | Asking whether get counts as use, about updates, threads and eviction callbacks | Assuming, then fixing it later |
| Data structures | Reasoning through why map-only, list-only and heaps fail | Jumping to the answer with no reasons |
| Details | Doubly linked, key in the node, sentinels, each explained | Getting them right by luck, or missing one |
| Classes | A list class with no policy, a cache that keeps both structures in step | One class with pointer code everywhere |
| Code | Correct pointer order, evict before insert, existing-key branch, try/finally | Off-by-one capacity, unlock missing on exceptions |
| Concurrency | Why a safe map and a read-write lock both fail; one lock; listener outside it | “It’s thread-safe because of ConcurrentHashMap” |
| Scaling | Sharding with sensible hashing, and honest limits | Claiming more threads always means more speed |
| Follow-ups | Wrappers for expiry and stats; LFU as a sibling; knowing LinkedHashMap | Editing the core class for every feature |
The whole design on one page
- Structures: a
HashMapto find, aDoublyLinkedListto order. The map points into the list. - Details: doubly linked (O(1) unlink), the node keeps its key (O(1) eviction), sentinels at both ends (no null checks).
- get: look up, move to front, return. A miss touches nothing.
- put: existing key → update and move to front. New key → evict the last node if full (list and map), then add at front. Tell listeners after unlocking.
- Threads: every get writes, so a safe map or a read-write lock isn’t enough. One lock is correct; shards scale, chosen by well-mixed high bits.
- Follow-ups:
Cacheinterface → wrappers for expiry and statistics; LFU as a sibling class;LinkedHashMapwhen you’re allowed.
Stay in the loop
Get new problems and walkthroughs by email
One short email when something new is published: a deep-dive walkthrough, an LLD problem, or a real outage retold. No spam, unsubscribe any time.