Overview
Designing an in-memory cache is a classic machine-coding question: implement get(key) and put(key, value) in O(1) with a capacity limit and an eviction policy, usually LRU (least recently used). The standard solution combines a hash map (key to node) with a doubly linked list ordered by recency; get moves a node to the front, put inserts at the front and evicts from the tail when full.
A strong LLD answer goes further: pluggable eviction policies (LRU, LFU, FIFO) via Strategy, TTL expiry, thread safety, statistics (hit rate), and a clean generic interface Cache<K, V>. These extensions show design skill beyond the data structure itself.
You keep the books you used most recently on your desk. When the desk is full and you need a new one, you put back the book you have not touched for the longest time.
When to use it
- Interview prompts: 'Design an LRU cache' or 'Design a cache with eviction policies'.
- Memoizing expensive computations or remote calls.
- Bounded memory with hot data reused often.
Where it shows up in interviews
Recognize it when: get and put in constant time with capacity.
- LRU Cache (LeetCode 146)
- LFU Cache (LeetCode 460)
Recognize it when: multiple eviction policies, TTL, thread safety.
- Design a generic cache library
- Design a cache layer for a service
Where it is used in real software
Java libraries with size limits, TTLs, stats, and near-optimal eviction (W-TinyLFU).
accessOrder=true plus removeEldestEntry gives a simple LRU cache.
allkeys-lru, allkeys-lfu, volatile-ttl chosen by configuration.
Key terms
- Eviction policy
- Rule choosing which entry to remove when full.
- LRU / LFU / FIFO
- Least recently used / least frequently used / first in, first out.
- TTL
- Entry expires after a time.
- Hit rate
- Fraction of gets served from the cache.
- Doubly linked list
- O(1) move and remove when you hold the node.
How it works, step by step
- 1Define the interface
get(key), put(key, value), size(), optional ttl.
- 2Map for O(1) lookup
key -> node.
- 3Linked list for recency
Head = most recent, tail = eviction candidate.
- 4Extract eviction as a strategy
EvictionPolicy with keyAccessed and evict.
- 5Add thread safety and TTL
Lock or striped locks; expiry on read and background cleanup.
STEP 1Three puts fill the cache. Most recent at the front: C, B, A.
Operation trace
LRU capacity 2
| Operation | Result | Order (MRU -> LRU) |
|---|---|---|
| put(1, 'a') | - | 1 |
| put(2, 'b') | - | 2, 1 |
| get(1) | 'a' | 1, 2 |
| put(3, 'c') | evicts 2 | 3, 1 |
| get(2) | miss | 3, 1 |
NOWOperation: put(1, 'a') | Result: - | Order (MRU -> LRU): 1
Every operation is O(1): one map lookup and a constant number of pointer changes.
Implementation
import java.util.*;import java.util.concurrent.locks.ReentrantLock; public final class LruCache<K, V> { private final class Node { final K key; V value; Node prev, next; Node(K key, V value) { this.key = key; this.value = value; } } private final int capacity; private final Map<K, Node> map = new HashMap<>(); private final Node head = new Node(null, null), tail = new Node(null, null); // sentinels private final ReentrantLock lock = new ReentrantLock(); private long hits, misses; public LruCache(int capacity) { if (capacity <= 0) throw new IllegalArgumentException("capacity must be positive"); this.capacity = capacity; head.next = tail; tail.prev = head; } public Optional<V> get(K key) { lock.lock(); try { Node n = map.get(key); if (n == null) { misses++; return Optional.empty(); } hits++; moveToFront(n); return Optional.of(n.value); } finally { lock.unlock(); } } public void put(K key, V value) { lock.lock(); try { Node n = map.get(key); if (n != null) { n.value = value; moveToFront(n); return; } if (map.size() == capacity) { Node lru = tail.prev; unlink(lru); map.remove(lru.key); } n = new Node(key, value); map.put(key, n); addFront(n); } finally { lock.unlock(); } } public double hitRate() { long total = hits + misses; return total == 0 ? 0 : (double) hits / total; } private void moveToFront(Node n) { unlink(n); addFront(n); } private void unlink(Node n) { n.prev.next = n.next; n.next.prev = n.prev; } private void addFront(Node n) { n.next = head.next; n.prev = head; head.next.prev = n; head.next = n; }}Complexity and performance
Map + linked list.
Node overhead per entry.
Trade-offs
LRU adapts quickly to changing access patterns; LFU keeps long-term popular items but reacts slowly and needs frequency buckets.
A single lock is simple; high-concurrency caches shard into segments with separate locks.
Variants and related techniques
Frequency buckets of doubly linked lists plus a min-frequency pointer.
When the cache fronts a store, decide how writes propagate.
Common mistakes
- Using an array or list scan for recency.
Fix: That makes operations O(n); use a linked list with node references.
- Forgetting to update the value and recency on put of an existing key.
Fix: Update and move to front.
- Not removing the evicted key from the map.
Fix: Store the key in the node so eviction can delete it.
Interview questions
How do you get O(1) for both get and put in an LRU cache?
A hash map gives O(1) lookup of the node; a doubly linked list lets you move a node to the front and remove the tail in O(1) because you already hold node references.
How would you support multiple eviction policies?
Extract an EvictionPolicy interface (on access, on remove, choose victim) and inject LRU, LFU, or FIFO implementations into a generic cache (Strategy pattern).
Practice problems
| Problem | Difficulty | What it trains |
|---|---|---|
| LRU Cache | Medium | Map + doubly linked list. |
| LFU Cache | Hard | Frequency buckets. |
| Thread-safe TTL cache with stats | Hard | Concurrency and expiry. |