Low Level DesignWorked problems
Design an LRU cache
Design an LRU cache step by step for a low level design interview. O(1) get and put with a hash map and a doubly linked list, a pluggable eviction policy, TTLs, and thread safety, with code in Java, Python, C#, Go and TypeScript.
- Read first
- Object modelingThread safety basics
- Building blocks
- Caching
- Related
- Rate limiter
“Design an LRU cache with O(1) get and put.” Sometimes it’s a coding question with a size limit and two methods. Sometimes it’s “design a cache”, and LRU is where you’re expected to land.
A cache keeps a small number of values close at hand, so we don’t have to fetch them again from somewhere slow, like a database. It has a size limit. When it’s full and a new value arrives, something has to leave. LRU stands for “least recently used”: the value that leaves is the one nobody has asked for in the longest time.
Many people know the answer by heart: a hash map plus a doubly linked list. In the design version of this question, that’s only the start. Why does each part exist, and what breaks without it? How would a different eviction rule, or values that expire, fit without rewriting the cache? And the question that trips most people: why is a read-write lock wrong here?
The answer in one minute
Section titled “The answer in one minute”If you only have a minute, this is the design. The rest of the page explains how we get here.
- A hash map finds each value, and a linked list remembers the order of use. The map points straight at a node in the list. So a read can move its node to the “most recent” end in a few steps, without searching. Get, put and eviction are all O(1), meaning they take the same time however big the cache is.
- The eviction rule is a separate class. The cache stores values. A small policy object decides which key leaves. LRU is one policy; others can be swapped in.
- Values expire lazily. Each value carries its expiry time. We check it when the value is read, using a clock we pass in, so tests don’t have to wait.
- Listeners hear about every removal, and why. For example, “evicted” or “expired”. A listener that saves changed values back to the database needs to know the reason.
- A read changes the cache. Moving a node is a write. So many threads need a real lock, not a read-write lock. For speed, we split the cache into independent parts (shards), each with its own lock.
We’ll build this in four stages, then deal with many threads at once.
Scope it
Section titled “Scope it”Before drawing anything, we ask a few questions. Each answer changes the structure or the locking. Anything that doesn’t, we assume and move on.
Design an LRU cache.
Whether you pin down the size limit (number of entries or bytes), threads and expiry in two or three minutes, since those change the structure and the locking. Jumping straight into the linked list looks like reciting a memorized answer.
Here are the questions that matter:
- Is the size limit a number of entries, or bytes? Entries. A limit in bytes needs the size of each value, and one put may have to evict several values.
- Do many threads share it? Yes, it’s a cache inside a server. With one thread, the whole concurrency section goes away.
- Do values expire? Yes, after a fixed time, called the TTL (“time to live”). Without it, there’s no clock and no expiry check.
- Is LRU the only rule? LRU now, but other rules may follow. If LRU were forever, the rule could stay inside the cache.
- Does anyone need to know when values leave? Yes: metrics, and saving changed values back to the database.
These answers give us one promise that matters more than the others: get, put and eviction are each O(1), all at once. The obvious designs give up one of the three. Three smaller promises come with it. The cache is safe and fast with many threads. A new eviction rule doesn’t change the cache. And time is passed in, so tests can check expiry without sleeping.
We leave out loading values on a miss (an extension), saving the cache to disk, and spreading it across several machines.
Go deeperThree more questions, and what each changes1 min
| Question | We assume | If the answer were different |
|---|---|---|
| Who loads missing values? | The caller, after a miss | A loading cache calls a loader itself and must stop duplicate loads |
| Null values allowed? | No | Null needs a separate “present” flag, or get can’t tell a miss from null |
| Exact LRU, or “close to LRU”? | Close is fine at scale | Exact LRU across the whole cache limits how far concurrency can go |
Build it
Section titled “Build it”We’ll build the design in four stages. Each stage adds only what a new requirement needs. In each diagram, the new parts are outlined, and the colors show what each class does (the key is under every diagram).
Stage 1: A map and a queue
Section titled “Stage 1: A map and a queue”Start with something that works. What’s the simplest LRU you can write?
Whether you can produce a correct version quickly and say exactly where it’s slow. Pointing out the slow part yourself, before being asked, is the good sign. Defending it as “fine” is the bad one.
Start with the smallest thing that works. A map holds the values. A queue holds the keys in order of use, oldest at the front. A get moves its key to the back of the queue. A put into a full cache evicts the key at the front.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// The smallest cache that evicts the least recently used key: a map for the values,// and a queue that keeps keys in the order they were last used (oldest first).class LruCache<K, V> { private final int capacity; private final Map<K, V> values = new HashMap<>(); private final ArrayDeque<K> order = new ArrayDeque<>(); // front = least recently used
LruCache(int capacity) { if (capacity < 1) throw new IllegalArgumentException("capacity must be at least 1"); this.capacity = capacity; }
// A miss is normal for a cache, so it is an empty result, not an exception. Optional<V> get(K key) { if (!values.containsKey(key)) return Optional.empty(); touch(key); // reading counts as using: the key moves to the back return Optional.of(values.get(key)); }
void put(K key, V value) { if (values.containsKey(key)) { touch(key); // an update is a use too, but never an eviction } else { if (values.size() == capacity) values.remove(order.pollFirst()); // evict the oldest order.addLast(key); } values.put(key, value); }
// flaw: remove(key) walks the queue to find the key, so every get is O(n). // With a million entries, a cache hit costs more than the miss it saves. Stage 2. private void touch(K key) { order.remove(key); order.addLast(key); }}from typing import Generic, Hashable, TypeVar
K = TypeVar("K", bound=Hashable)V = TypeVar("V")
# The smallest cache that evicts the least recently used key: a dict for the values,# and a list that keeps keys in the order they were last used (oldest first).class LruCache(Generic[K, V]): def __init__(self, capacity: int): if capacity < 1: raise ValueError("capacity must be at least 1") self._capacity = capacity self._values: dict[K, V] = {} self._order: list[K] = [] # index 0 = least recently used
# A miss is normal for a cache, so it returns None rather than raising. def get(self, key: K) -> V | None: if key not in self._values: return None self._touch(key) # reading counts as using: the key moves to the back return self._values[key]
def put(self, key: K, value: V) -> None: if key in self._values: self._touch(key) # an update is a use too, but never an eviction else: if len(self._values) == self._capacity: del self._values[self._order.pop(0)] # evict the oldest self._order.append(key) self._values[key] = value
# flaw: list.remove walks the list to find the key, so every get is O(n). # With a million entries, a cache hit costs more than the miss it saves. Stage 2. def _touch(self, key: K) -> None: self._order.remove(key) self._order.append(key)// The smallest cache that evicts the least recently used key: a dictionary for the values,// and a list that keeps keys in the order they were last used (oldest first).public sealed class LruCache<TKey, TValue> where TKey : notnull{ private readonly int _capacity; private readonly Dictionary<TKey, TValue> _values = new(); private readonly List<TKey> _order = new(); // _order[0] = least recently used
public LruCache(int capacity) { ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1); _capacity = capacity; }
// A miss is normal for a cache, so it is a false return, not an exception. public bool TryGet(TKey key, out TValue value) { if (!_values.TryGetValue(key, out value!)) return false; Touch(key); // reading counts as using: the key moves to the back return true; }
public void Put(TKey key, TValue value) { if (_values.ContainsKey(key)) { Touch(key); // an update is a use too, but never an eviction } else { if (_values.Count == _capacity) { _values.Remove(_order[0]); // evict the oldest _order.RemoveAt(0); } _order.Add(key); } _values[key] = value; }
// flaw: Remove walks the list to find the key, so every get is O(n). // With a million entries, a cache hit costs more than the miss it saves. Stage 2. private void Touch(TKey key) { _order.Remove(key); _order.Add(key); }}// LRUCache is the smallest cache that evicts the least recently used key: a map for the// values, and a slice that keeps keys in the order they were last used (oldest first).type LRUCache[K comparable, V any] struct { capacity int values map[K]V order []K // order[0] = least recently used}
func NewLRUCache[K comparable, V any](capacity int) *LRUCache[K, V] { if capacity < 1 { panic("capacity must be at least 1") } return &LRUCache[K, V]{capacity: capacity, values: map[K]V{}}}
// Get reports a miss with ok=false: a miss is normal for a cache, not an error.func (c *LRUCache[K, V]) Get(key K) (v V, ok bool) { if v, ok = c.values[key]; ok { c.touch(key) // reading counts as using: the key moves to the back } return v, ok}
func (c *LRUCache[K, V]) Put(key K, value V) { if _, ok := c.values[key]; ok { c.touch(key) // an update is a use too, but never an eviction } else { if len(c.values) == c.capacity { delete(c.values, c.order[0]) // evict the oldest c.order = c.order[1:] } c.order = append(c.order, key) } c.values[key] = value}
// touch has to find the key in the slice first (flaw: O(n) on every get). With a million// entries, a cache hit costs more than the miss it saves. Stage 2.func (c *LRUCache[K, V]) touch(key K) { i := slices.Index(c.order, key) c.order = append(slices.Delete(c.order, i, i+1), key)}// The smallest cache that evicts the least recently used key: a map for the values,// and an array that keeps keys in the order they were last used (oldest first).class LruCache<K, V> { private readonly values = new Map<K, V>(); private readonly order: K[] = []; // order[0] = least recently used
constructor(private readonly capacity: number) { if (capacity < 1) throw new RangeError('capacity must be at least 1'); }
// A miss is normal for a cache, so it returns undefined rather than throwing. get(key: K): V | undefined { if (!this.values.has(key)) return undefined; this.touch(key); // reading counts as using: the key moves to the back return this.values.get(key); }
put(key: K, value: V): void { if (this.values.has(key)) { this.touch(key); // an update is a use too, but never an eviction } else { if (this.values.size === this.capacity) this.values.delete(this.order.shift()!); // evict the oldest this.order.push(key); } this.values.set(key, value); }
// flaw: indexOf and splice walk the array, so every get is O(n). // With a million entries, a cache hit costs more than the miss it saves. Stage 2. private touch(key: K): void { this.order.splice(this.order.indexOf(key), 1); this.order.push(key); }}It’s correct, but one step is slow. To move a key to the back, the queue first has to find it, and that means checking up to n keys, one by one. We write that as O(n): the time grows with the number of entries. With a million entries, every hit could cost a million comparisons. That’s slower than the database call the cache was meant to save.
What would fix it? If each entry already knew where it sits in the order, we could take it out directly, without searching. That’s stage 2.
Stage 2: O(1) with a linked list
Section titled “Stage 2: O(1) with a linked list”Make get and put O(1).
Whether you can explain why each part is needed: the map finds the node, the list keeps the order, the two links make removing a node quick, and the key in the node makes eviction quick. A correct structure without the reasons looks memorized.
The fix is to keep the order in a doubly linked list. Each entry is a node that holds its key, its value, and two links: one to the node before it and one to the node after it. The map no longer points at the value. It points at the node.
Now a get works like this. The map hands us the node right away. We take the node out of its place by joining its two neighbours to each other. Then we put it at the “most recent” end. That’s four pointer changes, whatever the size of the cache: O(1).
Each part has a job:
- The map finds a key’s node in O(1).
- The list keeps nodes in order of use. The front is the next to be evicted.
- Two links per node let a node remove itself. With only a “next” link, we’d need the node before it, and finding that means walking the list again.
- The key inside the node matters for eviction. We evict from the front of the list, and the key tells us which map entry to delete too.
- A sentinel is a dummy node that sits at both ends of the list. The list is never truly empty, so the code never has to check for “no node here”.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// One entry in the recency list. It knows its neighbours, so it can unlink itself in O(1)// without anyone searching for it. It also carries its key: on eviction we start from the// node and need the key to remove it from the map.class Node<K, V> { final K key; V value; Node<K, V> prev, next;
Node(K key, V value) { this.key = key; this.value = value; }}
class LruCache<K, V> { private final int capacity; private final Map<K, Node<K, V>> index = new HashMap<>(); // key -> its node, O(1) // Sentinels: a head and tail that are never removed, so linking and unlinking never have // to check for null or special-case an empty list. private final Node<K, V> head = new Node<>(null, null); // head.next = least recent private final Node<K, V> tail = new Node<>(null, null); // tail.prev = most recent
LruCache(int capacity) { if (capacity < 1) throw new IllegalArgumentException("capacity must be at least 1"); this.capacity = capacity; head.next = tail; tail.prev = head; }
Optional<V> get(K key) { Node<K, V> n = index.get(key); if (n == null) return Optional.empty(); moveToBack(n); // O(1): we already hold the node return Optional.of(n.value); }
void put(K key, V value) { Node<K, V> n = index.get(key); if (n != null) { // update in place; never evicts n.value = value; moveToBack(n); return; } if (index.size() == capacity) { Node<K, V> oldest = head.next; unlink(oldest); index.remove(oldest.key); // why the node carries its key } n = new Node<>(key, value); linkAtBack(n); index.put(key, n); }
int size() { return index.size(); }
private void moveToBack(Node<K, V> n) { unlink(n); linkAtBack(n); }
private void unlink(Node<K, V> n) { n.prev.next = n.next; n.next.prev = n.prev; }
private void linkAtBack(Node<K, V> n) { n.prev = tail.prev; n.next = tail; tail.prev.next = n; tail.prev = n; }}# One entry in the recency list. It knows its neighbours, so it can unlink itself in O(1)# without anyone searching for it. It also carries its key: on eviction we start from the# node and need the key to remove it from the dict. __slots__ keeps a million of them small.class Node(Generic[K, V]): __slots__ = ("key", "value", "prev", "next")
def __init__(self, key: K | None, value: V | None): self.key, self.value = key, value self.prev: Node[K, V] = self self.next: Node[K, V] = self
# Python's OrderedDict is this exact structure (move_to_end, popitem(last=False)).# It is written out here because building it is what the question asks.class LruCache(Generic[K, V]): def __init__(self, capacity: int): if capacity < 1: raise ValueError("capacity must be at least 1") self._capacity = capacity self._index: dict[K, Node[K, V]] = {} # key -> its node, O(1) # One sentinel that is both head and tail of a circular list, so linking and # unlinking never check for None. sentinel.next = least recent, .prev = most recent. self._sentinel: Node[K, V] = Node(None, None)
def get(self, key: K) -> V | None: n = self._index.get(key) if n is None: return None self._move_to_back(n) # O(1): we already hold the node return n.value
def put(self, key: K, value: V) -> None: n = self._index.get(key) if n is not None: # update in place; never evicts n.value = value self._move_to_back(n) return if len(self._index) == self._capacity: oldest = self._sentinel.next self._unlink(oldest) del self._index[oldest.key] # why the node carries its key n = Node(key, value) self._link_at_back(n) self._index[key] = n
def __len__(self) -> int: return len(self._index)
def _move_to_back(self, n: Node[K, V]) -> None: self._unlink(n) self._link_at_back(n)
@staticmethod def _unlink(n: Node[K, V]) -> None: n.prev.next = n.next n.next.prev = n.prev
def _link_at_back(self, n: Node[K, V]) -> None: last = self._sentinel.prev n.prev, n.next = last, self._sentinel last.next = n self._sentinel.prev = n// One entry in the recency list. It knows its neighbours, so it can unlink itself in O(1)// without anyone searching for it. It carries its key: on eviction we start from the node// and need the key to remove it from the dictionary.internal sealed class Node<TKey, TValue>(TKey key, TValue value){ public readonly TKey Key = key; public TValue Value = value; public Node<TKey, TValue> Prev = null!, Next = null!;}
// .NET's LinkedList<T> would do the list part; writing it out is what the question asks.public sealed class LruCache<TKey, TValue> where TKey : notnull{ private readonly int _capacity; private readonly Dictionary<TKey, Node<TKey, TValue>> _index = new(); // key -> its node, O(1) // One sentinel that is both head and tail of a circular list, so linking and unlinking // never check for null. _sentinel.Next = least recent, _sentinel.Prev = most recent. private readonly Node<TKey, TValue> _sentinel = new(default!, default!);
public LruCache(int capacity) { ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1); _capacity = capacity; _sentinel.Prev = _sentinel.Next = _sentinel; }
public bool TryGet(TKey key, out TValue value) { if (!_index.TryGetValue(key, out var n)) { value = default!; return false; } MoveToBack(n); // O(1): we already hold the node value = n.Value; return true; }
public void Put(TKey key, TValue value) { if (_index.TryGetValue(key, out var n)) // update in place; never evicts { n.Value = value; MoveToBack(n); return; } if (_index.Count == _capacity) { var oldest = _sentinel.Next; Unlink(oldest); _index.Remove(oldest.Key); // why the node carries its key } n = new Node<TKey, TValue>(key, value); LinkAtBack(n); _index[key] = n; }
public int Count => _index.Count;
private void MoveToBack(Node<TKey, TValue> n) { Unlink(n); LinkAtBack(n); }
private static void Unlink(Node<TKey, TValue> n) { n.Prev.Next = n.Next; n.Next.Prev = n.Prev; }
private void LinkAtBack(Node<TKey, TValue> n) { n.Prev = _sentinel.Prev; n.Next = _sentinel; _sentinel.Prev.Next = n; _sentinel.Prev = n; }}// node is one entry in the recency list. It knows its neighbours, so it can unlink itself in// O(1) without anyone searching for it. It carries its key: on eviction we start from the// node and need the key to delete it from the map. (container/list would do; writing it out// is what the question asks.)type node[K comparable, V any] struct { key K value V prev, next *node[K, V]}
type LRUCache[K comparable, V any] struct { capacity int index map[K]*node[K, V] // key -> its node, O(1) // One sentinel that is both head and tail of a circular list, so linking and unlinking // never check for nil. sentinel.next = least recent, sentinel.prev = most recent. sentinel node[K, V]}
func NewLRUCache[K comparable, V any](capacity int) *LRUCache[K, V] { if capacity < 1 { panic("capacity must be at least 1") } c := &LRUCache[K, V]{capacity: capacity, index: map[K]*node[K, V]{}} c.sentinel.prev, c.sentinel.next = &c.sentinel, &c.sentinel return c}
func (c *LRUCache[K, V]) Get(key K) (v V, ok bool) { n, ok := c.index[key] if !ok { return v, false } c.moveToBack(n) // O(1): we already hold the node return n.value, true}
func (c *LRUCache[K, V]) Put(key K, value V) { if n, ok := c.index[key]; ok { // update in place; never evicts n.value = value c.moveToBack(n) return } if len(c.index) == c.capacity { oldest := c.sentinel.next unlink(oldest) delete(c.index, oldest.key) // why the node carries its key } n := &node[K, V]{key: key, value: value} c.linkAtBack(n) c.index[key] = n}
func (c *LRUCache[K, V]) Len() int { return len(c.index) }
func (c *LRUCache[K, V]) moveToBack(n *node[K, V]) { unlink(n) c.linkAtBack(n)}
func unlink[K comparable, V any](n *node[K, V]) { n.prev.next = n.next n.next.prev = n.prev}
func (c *LRUCache[K, V]) linkAtBack(n *node[K, V]) { last := c.sentinel.prev n.prev, n.next = last, &c.sentinel last.next = n c.sentinel.prev = n}// One entry in the recency list. It knows its neighbours, so it can unlink itself in O(1)// without anyone searching for it. It carries its key: on eviction we start from the node// and need the key to delete it from the map.class ListNode<K, V> { prev: ListNode<K, V> = this; next: ListNode<K, V> = this; constructor( readonly key: K, public value: V, ) {}}
// A JS Map remembers insertion order, so delete-then-set plus map.keys().next() is a// working LRU in ten lines. The explicit list is what the question asks you to build.class LruCache<K, V> { private readonly index = new Map<K, ListNode<K, V>>(); // key -> its node, O(1) // One sentinel that is both head and tail of a circular list, so linking and unlinking // never check for null. sentinel.next = least recent, sentinel.prev = most recent. private readonly sentinel = new ListNode<K, V>(undefined as K, undefined as V);
constructor(private readonly capacity: number) { if (capacity < 1) throw new RangeError('capacity must be at least 1'); }
get(key: K): V | undefined { const n = this.index.get(key); if (!n) return undefined; this.moveToBack(n); // O(1): we already hold the node return n.value; }
put(key: K, value: V): void { const existing = this.index.get(key); if (existing) { // update in place; never evicts existing.value = value; this.moveToBack(existing); return; } if (this.index.size === this.capacity) { const oldest = this.sentinel.next; this.unlink(oldest); this.index.delete(oldest.key); // why the node carries its key } const n = new ListNode(key, value); this.linkAtBack(n); this.index.set(key, n); }
get size(): number { return this.index.size; }
private moveToBack(n: ListNode<K, V>): void { this.unlink(n); this.linkAtBack(n); }
private unlink(n: ListNode<K, V>): void { n.prev.next = n.next; n.next.prev = n.prev; }
private linkAtBack(n: ListNode<K, V>): void { const last = this.sentinel.prev; n.prev = last; n.next = this.sentinel; last.next = n; this.sentinel.prev = n; }}Here is a put into a full cache, step by step. The new key isn’t in the map, so we evict first: the node right after the sentinel is the oldest. We take it out of the list, and use its key to delete it from the map. Then the new node goes in at the back, and into the map.
- Color key
- Database
- Facade
Most languages already ship this structure. In Java, a LinkedHashMap in access order with
removeEldestEntry is an LRU in five lines. Python’s OrderedDict has move_to_end and
popitem(last=False). Say so in the interview, then build it anyway. The interviewer wants to see
that you can.
What does it cost? Each entry now has a node on top of the map’s own entry, around 70 extra bytes on a 64-bit JVM. And every get changes four pointers. That second cost will matter a lot when many threads arrive.
Go deeperWhy not a timestamp on each entry?1 min
A common idea is to store “last used at” on each entry and evict the oldest. But finding the oldest means checking every entry (O(n)), or keeping entries sorted by time, in a heap or a sorted map. Then every get changes a timestamp and has to move the entry to its new sorted place, which is O(log n). The linked list keeps the order without comparing anything. Moving a node to the end is all it takes, and that’s why it’s O(1).
Stage 3: The eviction rule as a policy
Section titled “Stage 3: The eviction rule as a policy”We might want LFU instead of LRU for some caches. How would you support both?
Whether the eviction rule comes out as a small interface that the cache calls on each event, and
whether you can name what the split costs. A subclass of the cache per rule, or a switch inside
put, is the answer they mark down.
LFU means “least frequently used”: the key used the fewest times leaves first. FIFO means “first in, first out”: the oldest key leaves, however often it was used. LRU, LFU and FIFO differ in just one thing: which key leaves.
So we split the job in two. The cache keeps the values and the size limit. An EvictionPolicy
keeps the order. The cache tells the policy about every access, insert and removal, and asks it one
question when it’s full: “who’s next?” The linked list moves out of the cache and into LruPolicy,
where it orders keys only. This is the Strategy pattern: one interface, and a class for each
rule.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// What callers depend on. They never learn which eviction rule is behind it.interface Cache<K, V> { Optional<V> get(K key); void put(K key, V value); boolean remove(K key); int size();}
// Decides who leaves when the cache is full. It sees keys and events, never values, so// LRU, LFU and FIFO are each one class and the cache doesn't change.interface EvictionPolicy<K> { void accessed(K key); // a get hit or an update void added(K key); void removed(K key); // removed by the caller, so the policy forgets it K victim(); // who to evict next; the cache then calls removed(victim)}
// Least recently used, as a policy. A hash map finds a key's node in O(1), and a doubly linked// list keeps the order: oldest at the front, newest at the back. The list holds keys only; the// cache keeps the values.class LruPolicy<K> implements EvictionPolicy<K> { private final Map<K, Node<K, Void>> nodes = new HashMap<>(); private final Node<K, Void> head = new Node<>(null, null), tail = new Node<>(null, null);
LruPolicy() { head.next = tail; tail.prev = head; }
// A hit or an update: move the key to the back, the most recent end. public void accessed(K key) { Node<K, Void> n = nodes.get(key); unlink(n); linkAtBack(n); }
// A new key starts at the back, as the most recent. public void added(K key) { Node<K, Void> n = new Node<>(key, null); nodes.put(key, n); linkAtBack(n); }
public void removed(K key) { unlink(nodes.remove(key)); }
public K victim() { return head.next.key; } // least recently used
private void unlink(Node<K, Void> n) { n.prev.next = n.next; n.next.prev = n.prev; }
private void linkAtBack(Node<K, Void> n) { n.prev = tail.prev; n.next = tail; tail.prev.next = n; tail.prev = n; }}
// Storage plus a policy. It owns the values and the capacity; the policy owns the order.class BoundedCache<K, V> implements Cache<K, V> { private final int capacity; private final Map<K, V> values = new HashMap<>(); private final EvictionPolicy<K> policy;
BoundedCache(int capacity, EvictionPolicy<K> policy) { if (capacity < 1) throw new IllegalArgumentException("capacity must be at least 1"); this.capacity = capacity; this.policy = policy; }
public Optional<V> get(K key) { V v = values.get(key); if (v == null) return Optional.empty(); // null values aren't allowed, so null means a miss policy.accessed(key); return Optional.of(v); }
public void put(K key, V value) { Objects.requireNonNull(value, "null values would be indistinguishable from a miss"); if (values.containsKey(key)) { policy.accessed(key); } else { if (values.size() == capacity) { K victim = policy.victim(); policy.removed(victim); values.remove(victim); } policy.added(key); } values.put(key, value); }
public boolean remove(K key) { if (values.remove(key) == null) return false; policy.removed(key); return true; }
public int size() { return values.size(); }}from typing import Protocol
# What callers depend on. They never learn which eviction rule is behind it.class Cache(Protocol[K, V]): def get(self, key: K) -> V | None: ...
def put(self, key: K, value: V) -> None: ...
def remove(self, key: K) -> bool: ...
def __len__(self) -> int: ...
# Decides who leaves when the cache is full. It sees keys and events, never values, so# LRU, LFU and FIFO are each one class and the cache doesn't change.class EvictionPolicy(Protocol[K]): def accessed(self, key: K) -> None: ... # a get hit or an update
def added(self, key: K) -> None: ...
def removed(self, key: K) -> None: ... # removed by the caller, so the policy forgets it
def victim(self) -> K: ... # who to evict next; the cache then calls removed(victim)
# Least recently used, as a policy. A hash map finds a key's node in O(1), and a doubly linked# list keeps the order: oldest at the front, newest at the back. The list holds keys only; the# cache keeps the values.class LruPolicy(Generic[K]): def __init__(self): self._nodes: dict[K, Node[K, None]] = {} self._sentinel: Node[K, None] = Node(None, None)
# A hit or an update: move the key to the back, the most recent end. def accessed(self, key: K) -> None: n = self._nodes[key] self._unlink(n) self._link_at_back(n)
# A new key starts at the back, as the most recent. def added(self, key: K) -> None: n = Node(key, None) self._nodes[key] = n self._link_at_back(n)
def removed(self, key: K) -> None: self._unlink(self._nodes.pop(key))
def victim(self) -> K: return self._sentinel.next.key # least recently used
@staticmethod def _unlink(n: Node) -> None: n.prev.next = n.next n.next.prev = n.prev
def _link_at_back(self, n: Node) -> None: last = self._sentinel.prev n.prev, n.next = last, self._sentinel last.next = n self._sentinel.prev = n
_MISSING = object() # tells "absent" apart from a stored None
# Storage plus a policy. It owns the values and the capacity; the policy owns the order.class BoundedCache(Generic[K, V]): def __init__(self, capacity: int, policy: EvictionPolicy[K]): if capacity < 1: raise ValueError("capacity must be at least 1") self._capacity = capacity self._values: dict[K, V] = {} self._policy = policy
def get(self, key: K) -> V | None: v = self._values.get(key, _MISSING) if v is _MISSING: return None self._policy.accessed(key) return v
def put(self, key: K, value: V) -> None: if key in self._values: self._policy.accessed(key) else: if len(self._values) == self._capacity: victim = self._policy.victim() self._policy.removed(victim) del self._values[victim] self._policy.added(key) self._values[key] = value
def remove(self, key: K) -> bool: if self._values.pop(key, _MISSING) is _MISSING: return False self._policy.removed(key) return True
def __len__(self) -> int: return len(self._values)// What callers depend on. They never learn which eviction rule is behind it.public interface ICache<TKey, TValue>{ bool TryGet(TKey key, out TValue value); void Put(TKey key, TValue value); bool Remove(TKey key); int Count { get; }}
// Decides who leaves when the cache is full. It sees keys and events, never values, so// LRU, LFU and FIFO are each one class and the cache doesn't change.public interface IEvictionPolicy<TKey>{ void Accessed(TKey key); // a get hit or an update void Added(TKey key); void Removed(TKey key); // removed by the caller, so the policy forgets it TKey Victim(); // who to evict next; the cache then calls Removed(victim)}
// Least recently used, as a policy. A hash map finds a key's node in O(1), and a doubly linked// list keeps the order: oldest at the front, newest at the back. The list holds keys only; the// cache keeps the values.public sealed class LruPolicy<TKey> : IEvictionPolicy<TKey> where TKey : notnull{ private readonly Dictionary<TKey, Node<TKey, byte>> _nodes = new(); private readonly Node<TKey, byte> _sentinel = new(default!, 0);
public LruPolicy() => _sentinel.Prev = _sentinel.Next = _sentinel;
// A hit or an update: move the key to the back, the most recent end. public void Accessed(TKey key) { var n = _nodes[key]; Unlink(n); LinkAtBack(n); }
// A new key starts at the back, as the most recent. public void Added(TKey key) { var n = new Node<TKey, byte>(key, 0); _nodes[key] = n; LinkAtBack(n); }
public void Removed(TKey key) { _nodes.Remove(key, out var n); Unlink(n!); }
public TKey Victim() => _sentinel.Next.Key; // least recently used
private static void Unlink(Node<TKey, byte> n) { n.Prev.Next = n.Next; n.Next.Prev = n.Prev; }
private void LinkAtBack(Node<TKey, byte> n) { n.Prev = _sentinel.Prev; n.Next = _sentinel; _sentinel.Prev.Next = n; _sentinel.Prev = n; }}
// Storage plus a policy. It owns the values and the capacity; the policy owns the order.public sealed class BoundedCache<TKey, TValue> : ICache<TKey, TValue> where TKey : notnull{ private readonly int _capacity; private readonly Dictionary<TKey, TValue> _values = new(); private readonly IEvictionPolicy<TKey> _policy;
public BoundedCache(int capacity, IEvictionPolicy<TKey> policy) { ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1); _capacity = capacity; _policy = policy; }
public bool TryGet(TKey key, out TValue value) { if (!_values.TryGetValue(key, out value!)) return false; _policy.Accessed(key); return true; }
public void Put(TKey key, TValue value) { if (_values.ContainsKey(key)) { _policy.Accessed(key); } else { if (_values.Count == _capacity) { var victim = _policy.Victim(); _policy.Removed(victim); _values.Remove(victim); } _policy.Added(key); } _values[key] = value; }
public bool Remove(TKey key) { if (!_values.Remove(key)) return false; _policy.Removed(key); return true; }
public int Count => _values.Count;}// Cache is what callers depend on. They never learn which eviction rule is behind it.type Cache[K comparable, V any] interface { Get(key K) (V, bool) Put(key K, value V) Remove(key K) bool Len() int}
// EvictionPolicy decides who leaves when the cache is full. It sees keys and events, never// values, so LRU, LFU and FIFO are each one type and the cache doesn't change.type EvictionPolicy[K comparable] interface { Accessed(key K) // a get hit or an update Added(key K) Removed(key K) // removed by the caller, so the policy forgets it Victim() K // who to evict next; the cache then calls Removed(victim)}
// LRUPolicy is least recently used, as a policy. A hash map finds a key's node in O(1), and a// doubly linked list keeps the order: oldest at the front, newest at the back. The list holds// keys only; the cache keeps the values.type LRUPolicy[K comparable] struct { nodes map[K]*node[K, struct{}] sentinel node[K, struct{}]}
func NewLRUPolicy[K comparable]() *LRUPolicy[K] { p := &LRUPolicy[K]{nodes: map[K]*node[K, struct{}]{}} p.sentinel.prev, p.sentinel.next = &p.sentinel, &p.sentinel return p}
// Accessed is a hit or an update: move the key to the back, the most recent end.func (p *LRUPolicy[K]) Accessed(key K) { n := p.nodes[key] unlink(n) p.linkAtBack(n)}
// Added puts a new key at the back, as the most recent.func (p *LRUPolicy[K]) Added(key K) { n := &node[K, struct{}]{key: key} p.nodes[key] = n p.linkAtBack(n)}
func (p *LRUPolicy[K]) Removed(key K) { unlink(p.nodes[key]) delete(p.nodes, key)}
func (p *LRUPolicy[K]) Victim() K { return p.sentinel.next.key } // least recently used
func (p *LRUPolicy[K]) linkAtBack(n *node[K, struct{}]) { last := p.sentinel.prev n.prev, n.next = last, &p.sentinel last.next = n p.sentinel.prev = n}
// BoundedCache is storage plus a policy. It owns the values and the capacity; the policy// owns the order.type BoundedCache[K comparable, V any] struct { capacity int values map[K]V policy EvictionPolicy[K]}
func NewBoundedCache[K comparable, V any](capacity int, policy EvictionPolicy[K]) *BoundedCache[K, V] { if capacity < 1 { panic("capacity must be at least 1") } return &BoundedCache[K, V]{capacity: capacity, values: map[K]V{}, policy: policy}}
func (c *BoundedCache[K, V]) Get(key K) (V, bool) { v, ok := c.values[key] if ok { c.policy.Accessed(key) } return v, ok}
func (c *BoundedCache[K, V]) Put(key K, value V) { if _, ok := c.values[key]; ok { c.policy.Accessed(key) } else { if len(c.values) == c.capacity { victim := c.policy.Victim() c.policy.Removed(victim) delete(c.values, victim) } c.policy.Added(key) } c.values[key] = value}
func (c *BoundedCache[K, V]) Remove(key K) bool { if _, ok := c.values[key]; !ok { return false } delete(c.values, key) c.policy.Removed(key) return true}
func (c *BoundedCache[K, V]) Len() int { return len(c.values) }// What callers depend on. They never learn which eviction rule is behind it.interface Cache<K, V> { get(key: K): V | undefined; put(key: K, value: V): void; remove(key: K): boolean; readonly size: number;}
// Decides who leaves when the cache is full. It sees keys and events, never values, so// LRU, LFU and FIFO are each one class and the cache doesn't change.interface EvictionPolicy<K> { accessed(key: K): void; // a get hit or an update added(key: K): void; removed(key: K): void; // removed by the caller, so the policy forgets it victim(): K; // who to evict next; the cache then calls removed(victim)}
// Least recently used, as a policy. A hash map finds a key's node in O(1), and a doubly linked// list keeps the order: oldest at the front, newest at the back. The list holds keys only; the// cache keeps the values.class LruPolicy<K> implements EvictionPolicy<K> { private readonly nodes = new Map<K, ListNode<K, null>>(); private readonly sentinel = new ListNode<K, null>(undefined as K, null);
// A hit or an update: move the key to the back, the most recent end. accessed(key: K): void { const n = this.nodes.get(key)!; this.unlink(n); this.linkAtBack(n); } // A new key starts at the back, as the most recent. added(key: K): void { const n = new ListNode<K, null>(key, null); this.nodes.set(key, n); this.linkAtBack(n); } removed(key: K): void { this.unlink(this.nodes.get(key)!); this.nodes.delete(key); } victim(): K { return this.sentinel.next.key; // least recently used }
private unlink(n: ListNode<K, null>): void { n.prev.next = n.next; n.next.prev = n.prev; } private linkAtBack(n: ListNode<K, null>): void { const last = this.sentinel.prev; n.prev = last; n.next = this.sentinel; last.next = n; this.sentinel.prev = n; }}
// Storage plus a policy. It owns the values and the capacity; the policy owns the order.class BoundedCache<K, V> implements Cache<K, V> { private readonly values = new Map<K, V>();
constructor( private readonly capacity: number, private readonly policy: EvictionPolicy<K>, ) { if (capacity < 1) throw new RangeError('capacity must be at least 1'); }
get(key: K): V | undefined { if (!this.values.has(key)) return undefined; this.policy.accessed(key); return this.values.get(key); }
put(key: K, value: V): void { if (this.values.has(key)) { this.policy.accessed(key); } else { if (this.values.size === this.capacity) { const victim = this.policy.victim(); this.policy.removed(victim); this.values.delete(victim); } this.policy.added(key); } this.values.set(key, value); }
remove(key: K): boolean { if (!this.values.delete(key)) return false; this.policy.removed(key); return true; }
get size(): number { return this.values.size; }}FIFO is LruPolicy with an empty accessed method. LFU is the same four methods over a different
structure, and still O(1) (we’ll see how in the follow-up questions).
Notice that put evicts before adding the new key. For LRU it makes no difference. For LFU it
does: a brand new key has the lowest count, so evicting after adding would throw out the key we
just put in. Evicting first is safe for every rule.
What does the split cost? Two maps now hold the same keys: values in the cache, and nodes in the policy. That roughly doubles the map’s memory. Production caches like Caffeine merge them into one shared node. For an interview, the split is clearer. Just say what it costs.
Stage 4: Expiry and removal listeners
Section titled “Stage 4: Expiry and removal listeners”Entries should expire after five minutes. And the team wants to save changed entries back to the database when they leave the cache.
Whether expiry is checked lazily (and you can say what that costs), whether the clock is passed in, and whether listeners learn why an entry left. A background thread that checks every entry every second is the answer that doesn’t scale.
Each value is now stored in an Entry, together with the time it expires. That time comes from a
clock that we pass into the cache. In tests, we pass a fake clock and move time forward by hand.
When do we check whether an entry has expired? The simplest place is when someone reads it. If it has expired, we remove it and report a miss. This is called lazy expiry. There’s no background thread and no timer per entry.
The second requirement is about values that leave the cache. A removal listener is told every time an entry leaves, together with the reason: evicted, expired, replaced by a new value, or removed by the caller. The reason matters. A listener that saves changed values to the database should save evicted and expired entries. But it must skip replaced ones, because the new value is already in the cache. This is the Observer pattern: the cache doesn’t know who is listening.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// Why an entry left. Listeners care: a write-back cache saves EVICTED and EXPIRED entries,// but not REPLACED ones, because the new value is already in the cache.enum RemovalCause { EVICTED, EXPIRED, REPLACED, EXPLICIT }
// Told about every removal, with the reason.interface RemovalListener<K, V> { void removed(K key, V value, RemovalCause cause);}
// A stored value and when it stops being valid.record Entry<V>(V value, Instant expiresAt) {}
// The cache itself. It owns the values, the capacity and the expiry times; the policy only// decides the order. Every way an entry can leave goes through evict, so the policy and the// listeners are always told.class BoundedCache<K, V> implements Cache<K, V> { private final int capacity; private final Duration ttl; private final Clock clock; // injected, so a test can move time without sleeping private final Map<K, Entry<V>> entries = new HashMap<>(); private final EvictionPolicy<K> policy; private final List<RemovalListener<K, V>> listeners = new ArrayList<>();
BoundedCache(int capacity, Duration ttl, EvictionPolicy<K> policy, Clock clock) { if (capacity < 1) throw new IllegalArgumentException("capacity must be at least 1"); this.capacity = capacity; this.ttl = ttl; this.policy = policy; this.clock = clock; }
void subscribe(RemovalListener<K, V> l) { listeners.add(l); }
public Optional<V> get(K key) { Entry<V> e = entries.get(key); if (e == null) return Optional.empty(); // Expiry is lazy: checked when the key is read. An expired entry nobody reads still // takes a slot until LRU pushes it out, which is fine for a bounded cache. if (!clock.instant().isBefore(e.expiresAt())) { evict(key, RemovalCause.EXPIRED); return Optional.empty(); } policy.accessed(key); return Optional.of(e.value()); }
public void put(K key, V value) { Objects.requireNonNull(value, "null values would be indistinguishable from a miss"); Entry<V> old = entries.get(key); // Make room before adding, so a policy can never pick the newcomer as its own victim. if (old == null && entries.size() == capacity) evict(policy.victim(), RemovalCause.EVICTED); entries.put(key, new Entry<>(value, clock.instant().plus(ttl))); if (old != null) { policy.accessed(key); notify(key, old.value(), RemovalCause.REPLACED); } else { policy.added(key); } }
public boolean remove(K key) { if (!entries.containsKey(key)) return false; evict(key, RemovalCause.EXPLICIT); return true; }
public int size() { return entries.size(); }
// The one exit: the entry goes, the policy forgets the key, and the listeners hear why. private void evict(K key, RemovalCause cause) { Entry<V> e = entries.remove(key); policy.removed(key); notify(key, e.value(), cause); }
// A listener that throws must not break the cache: the entry is already gone. private void notify(K key, V value, RemovalCause cause) { for (RemovalListener<K, V> l : listeners) { try { l.removed(key, value, cause); } catch (RuntimeException ex) { System.err.println("removal listener failed: " + ex); } } }}import timefrom dataclasses import dataclassfrom enum import Enumfrom typing import Callable
# Why an entry left. Listeners care: a write-back cache saves EVICTED and EXPIRED entries,# but not REPLACED ones, because the new value is already in the cache.class RemovalCause(Enum): EVICTED = "evicted" EXPIRED = "expired" REPLACED = "replaced" EXPLICIT = "explicit"
# Told about every removal, with the reason.RemovalListener = Callable[[K, V, RemovalCause], None]
# A stored value and when it stops being valid.@dataclass(frozen=True)class Entry(Generic[V]): value: V expires_at: float
# The cache itself. It owns the values, the capacity and the expiry times; the policy only# decides the order. Every way an entry can leave goes through _evict, so the policy and the# listeners are always told.class BoundedCache(Generic[K, V]): def __init__(self, capacity: int, ttl_seconds: float, policy: EvictionPolicy[K], clock: Callable[[], float] = time.monotonic): if capacity < 1: raise ValueError("capacity must be at least 1") self._capacity = capacity self._ttl = ttl_seconds self._policy = policy self._clock = clock # injected, so a test can move time without sleeping self._entries: dict[K, Entry[V]] = {} self._listeners: list[RemovalListener] = []
def subscribe(self, listener: RemovalListener) -> None: self._listeners.append(listener)
def get(self, key: K) -> V | None: e = self._entries.get(key) if e is None: return None # Expiry is lazy: checked when the key is read. An expired entry nobody reads still # takes a slot until LRU pushes it out, which is fine for a bounded cache. if self._clock() >= e.expires_at: self._evict(key, RemovalCause.EXPIRED) return None self._policy.accessed(key) return e.value
def put(self, key: K, value: V) -> None: old = self._entries.get(key) # Make room before adding, so a policy can never pick the newcomer as its own victim. if old is None and len(self._entries) == self._capacity: self._evict(self._policy.victim(), RemovalCause.EVICTED) self._entries[key] = Entry(value, self._clock() + self._ttl) if old is not None: self._policy.accessed(key) self._notify(key, old.value, RemovalCause.REPLACED) else: self._policy.added(key)
def remove(self, key: K) -> bool: if key not in self._entries: return False self._evict(key, RemovalCause.EXPLICIT) return True
def __len__(self) -> int: return len(self._entries)
# The one exit: the entry goes, the policy forgets the key, and the listeners hear why. def _evict(self, key: K, cause: RemovalCause) -> None: e = self._entries.pop(key) self._policy.removed(key) self._notify(key, e.value, cause)
# A listener that raises must not break the cache: the entry is already gone. def _notify(self, key: K, value: V, cause: RemovalCause) -> None: for listener in self._listeners: try: listener(key, value, cause) except Exception as ex: print(f"removal listener failed: {ex!r}")// Why an entry left. Listeners care: a write-back cache saves Evicted and Expired entries,// but not Replaced ones, because the new value is already in the cache.public enum RemovalCause { Evicted, Expired, Replaced, Explicit }
// Told about every removal, with the reason.public delegate void RemovalListener<TKey, TValue>(TKey key, TValue value, RemovalCause cause);
// A stored value and when it stops being valid.internal readonly record struct Entry<TValue>(TValue Value, DateTimeOffset ExpiresAt);
// The cache itself. It owns the values, the capacity and the expiry times; the policy only// decides the order. Every way an entry can leave goes through Evict, so the policy and the// listeners are always told.public sealed class BoundedCache<TKey, TValue> : ICache<TKey, TValue> where TKey : notnull{ private readonly int _capacity; private readonly TimeSpan _ttl; private readonly TimeProvider _time; // injected, so a test can move time without sleeping private readonly Dictionary<TKey, Entry<TValue>> _entries = new(); private readonly IEvictionPolicy<TKey> _policy; private readonly List<RemovalListener<TKey, TValue>> _listeners = new();
public BoundedCache(int capacity, TimeSpan ttl, IEvictionPolicy<TKey> policy, TimeProvider time) { ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1); (_capacity, _ttl, _policy, _time) = (capacity, ttl, policy, time); }
public void Subscribe(RemovalListener<TKey, TValue> l) => _listeners.Add(l);
public bool TryGet(TKey key, out TValue value) { value = default!; if (!_entries.TryGetValue(key, out var e)) return false; // Expiry is lazy: checked when the key is read. An expired entry nobody reads still // takes a slot until LRU pushes it out, which is fine for a bounded cache. if (_time.GetUtcNow() >= e.ExpiresAt) { Evict(key, RemovalCause.Expired); return false; } _policy.Accessed(key); value = e.Value; return true; }
public void Put(TKey key, TValue value) { bool existed = _entries.TryGetValue(key, out var old); // Make room before adding, so a policy can never pick the newcomer as its own victim. if (!existed && _entries.Count == _capacity) Evict(_policy.Victim(), RemovalCause.Evicted); _entries[key] = new Entry<TValue>(value, _time.GetUtcNow() + _ttl); if (existed) { _policy.Accessed(key); Notify(key, old.Value, RemovalCause.Replaced); } else { _policy.Added(key); } }
public bool Remove(TKey key) { if (!_entries.ContainsKey(key)) return false; Evict(key, RemovalCause.Explicit); return true; }
public int Count => _entries.Count;
// The one exit: the entry goes, the policy forgets the key, and the listeners hear why. private void Evict(TKey key, RemovalCause cause) { _entries.Remove(key, out var e); _policy.Removed(key); Notify(key, e.Value, cause); }
// A listener that throws must not break the cache: the entry is already gone. private void Notify(TKey key, TValue value, RemovalCause cause) { foreach (var l in _listeners) { try { l(key, value, cause); } catch (Exception ex) { Console.Error.WriteLine($"removal listener failed: {ex.Message}"); } } }}// RemovalCause says why an entry left. Listeners care: a write-back cache saves Evicted and// Expired entries, but not Replaced ones, because the new value is already in the cache.type RemovalCause int
const ( Evicted RemovalCause = iota Expired Replaced Explicit)
// RemovalListener is told about every removal, with the reason.type RemovalListener[K comparable, V any] func(key K, value V, cause RemovalCause)
// entry is a stored value and when it stops being valid.type entry[V any] struct { value V expiresAt time.Time}
// BoundedCache is the cache itself. It owns the values, the capacity and the expiry times; the// policy only decides the order. Every way an entry can leave goes through evict, so the policy// and the listeners are always told.type BoundedCache[K comparable, V any] struct { capacity int ttl time.Duration now func() time.Time // injected, so a test can move time without sleeping entries map[K]entry[V] policy EvictionPolicy[K] listeners []RemovalListener[K, V]}
func NewBoundedCache[K comparable, V any](capacity int, ttl time.Duration, policy EvictionPolicy[K], now func() time.Time) *BoundedCache[K, V] { if capacity < 1 { panic("capacity must be at least 1") } return &BoundedCache[K, V]{capacity: capacity, ttl: ttl, now: now, entries: map[K]entry[V]{}, policy: policy}}
func (c *BoundedCache[K, V]) Subscribe(l RemovalListener[K, V]) { c.listeners = append(c.listeners, l) }
func (c *BoundedCache[K, V]) Get(key K) (v V, ok bool) { e, ok := c.entries[key] if !ok { return v, false } // Expiry is lazy: checked when the key is read. An expired entry nobody reads still // takes a slot until LRU pushes it out, which is fine for a bounded cache. if !c.now().Before(e.expiresAt) { c.evict(key, Expired) return v, false } c.policy.Accessed(key) return e.value, true}
func (c *BoundedCache[K, V]) Put(key K, value V) { old, existed := c.entries[key] // Make room before adding, so a policy can never pick the newcomer as its own victim. if !existed && len(c.entries) == c.capacity { c.evict(c.policy.Victim(), Evicted) } c.entries[key] = entry[V]{value, c.now().Add(c.ttl)} if existed { c.policy.Accessed(key) c.notify(key, old.value, Replaced) } else { c.policy.Added(key) }}
func (c *BoundedCache[K, V]) Remove(key K) bool { if _, ok := c.entries[key]; !ok { return false } c.evict(key, Explicit) return true}
func (c *BoundedCache[K, V]) Len() int { return len(c.entries) }
// evict is the one exit: the entry goes, the policy forgets the key, and the listeners hear// why.func (c *BoundedCache[K, V]) evict(key K, cause RemovalCause) { e := c.entries[key] delete(c.entries, key) c.policy.Removed(key) c.notify(key, e.value, cause)}
// notify keeps a panicking listener from breaking the cache: the entry is already gone.func (c *BoundedCache[K, V]) notify(key K, value V, cause RemovalCause) { for _, l := range c.listeners { func() { defer func() { if r := recover(); r != nil { log.Printf("removal listener failed: %v", r) } }() l(key, value, cause) }() }}// Why an entry left. Listeners care: a write-back cache saves 'evicted' and 'expired'// entries, but not 'replaced' ones, because the new value is already in the cache.type RemovalCause = 'evicted' | 'expired' | 'replaced' | 'explicit';
// Told about every removal, with the reason.type RemovalListener<K, V> = (key: K, value: V, cause: RemovalCause) => void;
// A stored value and when it stops being valid (milliseconds on the injected clock).interface Entry<V> { readonly value: V; readonly expiresAt: number;}
// The cache itself. It owns the values, the capacity and the expiry times; the policy only// decides the order. Every way an entry can leave goes through evict, so the policy and the// listeners are always told.class BoundedCache<K, V> implements Cache<K, V> { private readonly entries = new Map<K, Entry<V>>(); private readonly listeners: RemovalListener<K, V>[] = [];
constructor( private readonly capacity: number, private readonly ttlMs: number, private readonly policy: EvictionPolicy<K>, private readonly now: () => number = Date.now, // injected, so a test can move time ) { if (capacity < 1) throw new RangeError('capacity must be at least 1'); }
subscribe(l: RemovalListener<K, V>): void { this.listeners.push(l); }
get(key: K): V | undefined { const e = this.entries.get(key); if (!e) return undefined; // Expiry is lazy: checked when the key is read. An expired entry nobody reads still // takes a slot until LRU pushes it out, which is fine for a bounded cache. if (this.now() >= e.expiresAt) { this.evict(key, 'expired'); return undefined; } this.policy.accessed(key); return e.value; }
put(key: K, value: V): void { const old = this.entries.get(key); // Make room before adding, so a policy can never pick the newcomer as its own victim. if (!old && this.entries.size === this.capacity) this.evict(this.policy.victim(), 'evicted'); this.entries.set(key, { value, expiresAt: this.now() + this.ttlMs }); if (old) { this.policy.accessed(key); this.notify(key, old.value, 'replaced'); } else { this.policy.added(key); } }
remove(key: K): boolean { if (!this.entries.has(key)) return false; this.evict(key, 'explicit'); return true; }
get size(): number { return this.entries.size; }
// The one exit: the entry goes, the policy forgets the key, and the listeners hear why. private evict(key: K, cause: RemovalCause): void { const e = this.entries.get(key)!; this.entries.delete(key); this.policy.removed(key); this.notify(key, e.value, cause); }
// A listener that throws must not break the cache: the entry is already gone. private notify(key: K, value: V, cause: RemovalCause): void { for (const l of this.listeners) { try { l(key, value, cause); } catch (err) { console.error('removal listener failed', err); } } }}What does lazy expiry cost? An expired entry that nobody reads still takes memory and a place in the cache. It does leave eventually, since nobody touches it and LRU pushes it out. But its listener is told late. When that matters, add a small sweep: on each put, remove a few expired entries from the old end of the list. Redis does something similar, checking a random sample of keys.
This is enough for a mid-level answer. The map and list are correct, get, put and eviction are O(1), and updating a key doesn’t evict. The rest of the page is what makes it a senior answer.
The full design
Section titled “The full design”Here is everything together. Each class is here because a requirement needed it.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
// The complete design, built from every stage on this page.
import java.util.*;import java.util.function.*;import java.time.*;
// One entry in the recency list. It knows its neighbours, so it can unlink itself in O(1)// without anyone searching for it. It also carries its key: on eviction we start from the// node and need the key to remove it from the map.class Node<K, V> { final K key; V value; Node<K, V> prev, next;
Node(K key, V value) { this.key = key; this.value = value; }}
// What callers depend on. They never learn which eviction rule is behind it.interface Cache<K, V> { Optional<V> get(K key); void put(K key, V value); boolean remove(K key); int size();}
// Decides who leaves when the cache is full. It sees keys and events, never values, so// LRU, LFU and FIFO are each one class and the cache doesn't change.interface EvictionPolicy<K> { void accessed(K key); // a get hit or an update void added(K key); void removed(K key); // removed by the caller, so the policy forgets it K victim(); // who to evict next; the cache then calls removed(victim)}
// Least recently used, as a policy. A hash map finds a key's node in O(1), and a doubly linked// list keeps the order: oldest at the front, newest at the back. The list holds keys only; the// cache keeps the values.class LruPolicy<K> implements EvictionPolicy<K> { private final Map<K, Node<K, Void>> nodes = new HashMap<>(); private final Node<K, Void> head = new Node<>(null, null), tail = new Node<>(null, null);
LruPolicy() { head.next = tail; tail.prev = head; }
// A hit or an update: move the key to the back, the most recent end. public void accessed(K key) { Node<K, Void> n = nodes.get(key); unlink(n); linkAtBack(n); }
// A new key starts at the back, as the most recent. public void added(K key) { Node<K, Void> n = new Node<>(key, null); nodes.put(key, n); linkAtBack(n); }
public void removed(K key) { unlink(nodes.remove(key)); }
public K victim() { return head.next.key; } // least recently used
private void unlink(Node<K, Void> n) { n.prev.next = n.next; n.next.prev = n.prev; }
private void linkAtBack(Node<K, Void> n) { n.prev = tail.prev; n.next = tail; tail.prev.next = n; tail.prev = n; }}
// The cache itself. It owns the values, the capacity and the expiry times; the policy only// decides the order. Every way an entry can leave goes through evict, so the policy and the// listeners are always told.class BoundedCache<K, V> implements Cache<K, V> { private final int capacity; private final Duration ttl; private final Clock clock; // injected, so a test can move time without sleeping private final Map<K, Entry<V>> entries = new HashMap<>(); private final EvictionPolicy<K> policy; private final List<RemovalListener<K, V>> listeners = new ArrayList<>();
BoundedCache(int capacity, Duration ttl, EvictionPolicy<K> policy, Clock clock) { if (capacity < 1) throw new IllegalArgumentException("capacity must be at least 1"); this.capacity = capacity; this.ttl = ttl; this.policy = policy; this.clock = clock; }
void subscribe(RemovalListener<K, V> l) { listeners.add(l); }
public Optional<V> get(K key) { Entry<V> e = entries.get(key); if (e == null) return Optional.empty(); // Expiry is lazy: checked when the key is read. An expired entry nobody reads still // takes a slot until LRU pushes it out, which is fine for a bounded cache. if (!clock.instant().isBefore(e.expiresAt())) { evict(key, RemovalCause.EXPIRED); return Optional.empty(); } policy.accessed(key); return Optional.of(e.value()); }
public void put(K key, V value) { Objects.requireNonNull(value, "null values would be indistinguishable from a miss"); Entry<V> old = entries.get(key); // Make room before adding, so a policy can never pick the newcomer as its own victim. if (old == null && entries.size() == capacity) evict(policy.victim(), RemovalCause.EVICTED); entries.put(key, new Entry<>(value, clock.instant().plus(ttl))); if (old != null) { policy.accessed(key); notify(key, old.value(), RemovalCause.REPLACED); } else { policy.added(key); } }
public boolean remove(K key) { if (!entries.containsKey(key)) return false; evict(key, RemovalCause.EXPLICIT); return true; }
public int size() { return entries.size(); }
// The one exit: the entry goes, the policy forgets the key, and the listeners hear why. private void evict(K key, RemovalCause cause) { Entry<V> e = entries.remove(key); policy.removed(key); notify(key, e.value(), cause); }
// A listener that throws must not break the cache: the entry is already gone. private void notify(K key, V value, RemovalCause cause) { for (RemovalListener<K, V> l : listeners) { try { l.removed(key, value, cause); } catch (RuntimeException ex) { System.err.println("removal listener failed: " + ex); } } }}
// Why an entry left. Listeners care: a write-back cache saves EVICTED and EXPIRED entries,// but not REPLACED ones, because the new value is already in the cache.enum RemovalCause { EVICTED, EXPIRED, REPLACED, EXPLICIT }
// Told about every removal, with the reason.interface RemovalListener<K, V> { void removed(K key, V value, RemovalCause cause);}
// A stored value and when it stops being valid.record Entry<V>(V value, Instant expiresAt) {}
// Thread safe by one lock. Note that get() needs the write side too: a hit moves the key in// the recency list, so a read-write lock would let two "readers" corrupt the list.class SynchronizedCache<K, V> implements Cache<K, V> { private final Cache<K, V> inner; private final Object lock = new Object();
SynchronizedCache(Cache<K, V> inner) { this.inner = inner; }
public Optional<V> get(K key) { synchronized (lock) { return inner.get(key); } } public void put(K key, V value) { synchronized (lock) { inner.put(key, value); } } public boolean remove(K key) { synchronized (lock) { return inner.remove(key); } } public int size() { synchronized (lock) { return inner.size(); } }}
// N independent caches, each with its own lock. Threads only wait for each other when their// keys hash to the same shard. The price: eviction is LRU within a shard, not across the// whole cache, so the key evicted is "old in its shard", which is close enough for most uses.class ShardedCache<K, V> implements Cache<K, V> { private final List<Cache<K, V>> shards = new ArrayList<>();
ShardedCache(int capacity, int shardCount, Function<Integer, Cache<K, V>> newShard) { int perShard = (capacity + shardCount - 1) / shardCount; // round up so total >= capacity for (int i = 0; i < shardCount; i++) shards.add(new SynchronizedCache<>(newShard.apply(perShard))); }
// The same key always lands in the same shard. floorMod keeps negative hashes in range. private Cache<K, V> shard(K key) { return shards.get(Math.floorMod(key.hashCode(), shards.size())); }
public Optional<V> get(K key) { return shard(key).get(key); } public void put(K key, V value) { shard(key).put(key, value); } public boolean remove(K key) { return shard(key).remove(key); } public int size() { return shards.stream().mapToInt(Cache::size).sum(); } // a moment-in-time estimate}# The complete design, built from every stage on this page.
from __future__ import annotations
import threadingimport timefrom dataclasses import dataclassfrom enum import Enumfrom typing import Callable, Generic, Hashable, Protocol, TypeVar
K = TypeVar("K", bound=Hashable)
V = TypeVar("V")
# One entry in the recency list. It knows its neighbours, so it can unlink itself in O(1)# without anyone searching for it. It also carries its key: on eviction we start from the# node and need the key to remove it from the dict. __slots__ keeps a million of them small.class Node(Generic[K, V]): __slots__ = ("key", "value", "prev", "next")
def __init__(self, key: K | None, value: V | None): self.key, self.value = key, value self.prev: Node[K, V] = self self.next: Node[K, V] = self
# What callers depend on. They never learn which eviction rule is behind it.class Cache(Protocol[K, V]): def get(self, key: K) -> V | None: ...
def put(self, key: K, value: V) -> None: ...
def remove(self, key: K) -> bool: ...
def __len__(self) -> int: ...
# Decides who leaves when the cache is full. It sees keys and events, never values, so# LRU, LFU and FIFO are each one class and the cache doesn't change.class EvictionPolicy(Protocol[K]): def accessed(self, key: K) -> None: ... # a get hit or an update
def added(self, key: K) -> None: ...
def removed(self, key: K) -> None: ... # removed by the caller, so the policy forgets it
def victim(self) -> K: ... # who to evict next; the cache then calls removed(victim)
# Least recently used, as a policy. A hash map finds a key's node in O(1), and a doubly linked# list keeps the order: oldest at the front, newest at the back. The list holds keys only; the# cache keeps the values.class LruPolicy(Generic[K]): def __init__(self): self._nodes: dict[K, Node[K, None]] = {} self._sentinel: Node[K, None] = Node(None, None)
# A hit or an update: move the key to the back, the most recent end. def accessed(self, key: K) -> None: n = self._nodes[key] self._unlink(n) self._link_at_back(n)
# A new key starts at the back, as the most recent. def added(self, key: K) -> None: n = Node(key, None) self._nodes[key] = n self._link_at_back(n)
def removed(self, key: K) -> None: self._unlink(self._nodes.pop(key))
def victim(self) -> K: return self._sentinel.next.key # least recently used
@staticmethod def _unlink(n: Node) -> None: n.prev.next = n.next n.next.prev = n.prev
def _link_at_back(self, n: Node) -> None: last = self._sentinel.prev n.prev, n.next = last, self._sentinel last.next = n self._sentinel.prev = n
# The cache itself. It owns the values, the capacity and the expiry times; the policy only# decides the order. Every way an entry can leave goes through _evict, so the policy and the# listeners are always told.class BoundedCache(Generic[K, V]): def __init__(self, capacity: int, ttl_seconds: float, policy: EvictionPolicy[K], clock: Callable[[], float] = time.monotonic): if capacity < 1: raise ValueError("capacity must be at least 1") self._capacity = capacity self._ttl = ttl_seconds self._policy = policy self._clock = clock # injected, so a test can move time without sleeping self._entries: dict[K, Entry[V]] = {} self._listeners: list[RemovalListener] = []
def subscribe(self, listener: RemovalListener) -> None: self._listeners.append(listener)
def get(self, key: K) -> V | None: e = self._entries.get(key) if e is None: return None # Expiry is lazy: checked when the key is read. An expired entry nobody reads still # takes a slot until LRU pushes it out, which is fine for a bounded cache. if self._clock() >= e.expires_at: self._evict(key, RemovalCause.EXPIRED) return None self._policy.accessed(key) return e.value
def put(self, key: K, value: V) -> None: old = self._entries.get(key) # Make room before adding, so a policy can never pick the newcomer as its own victim. if old is None and len(self._entries) == self._capacity: self._evict(self._policy.victim(), RemovalCause.EVICTED) self._entries[key] = Entry(value, self._clock() + self._ttl) if old is not None: self._policy.accessed(key) self._notify(key, old.value, RemovalCause.REPLACED) else: self._policy.added(key)
def remove(self, key: K) -> bool: if key not in self._entries: return False self._evict(key, RemovalCause.EXPLICIT) return True
def __len__(self) -> int: return len(self._entries)
# The one exit: the entry goes, the policy forgets the key, and the listeners hear why. def _evict(self, key: K, cause: RemovalCause) -> None: e = self._entries.pop(key) self._policy.removed(key) self._notify(key, e.value, cause)
# A listener that raises must not break the cache: the entry is already gone. def _notify(self, key: K, value: V, cause: RemovalCause) -> None: for listener in self._listeners: try: listener(key, value, cause) except Exception as ex: print(f"removal listener failed: {ex!r}")
# Why an entry left. Listeners care: a write-back cache saves EVICTED and EXPIRED entries,# but not REPLACED ones, because the new value is already in the cache.class RemovalCause(Enum): EVICTED = "evicted" EXPIRED = "expired" REPLACED = "replaced" EXPLICIT = "explicit"
# Told about every removal, with the reason.RemovalListener = Callable[[K, V, RemovalCause], None]
# A stored value and when it stops being valid.@dataclass(frozen=True)class Entry(Generic[V]): value: V expires_at: float
# Thread safe by one lock. get() needs it too: a hit moves the key in the recency list, so# letting two "readers" in at once would corrupt the list. The GIL doesn't help: it makes# single bytecodes atomic, not the four pointer writes of an unlink and relink.class SynchronizedCache(Generic[K, V]): def __init__(self, inner: Cache[K, V]): self._inner = inner self._lock = threading.Lock()
def get(self, key: K) -> V | None: with self._lock: return self._inner.get(key)
def put(self, key: K, value: V) -> None: with self._lock: self._inner.put(key, value)
def remove(self, key: K) -> bool: with self._lock: return self._inner.remove(key)
def __len__(self) -> int: with self._lock: return len(self._inner)
# N independent caches, each with its own lock. Threads only wait for each other when their# keys hash to the same shard. The price: eviction is LRU within a shard, not across the# whole cache, so the key evicted is "old in its shard", which is close enough for most uses.class ShardedCache(Generic[K, V]): def __init__(self, capacity: int, shard_count: int, new_shard: Callable[[int], Cache[K, V]]): per_shard = -(-capacity // shard_count) # round up so the total is at least capacity self._shards = [SynchronizedCache(new_shard(per_shard)) for _ in range(shard_count)]
# The same key always lands in the same shard. def _shard(self, key: K) -> SynchronizedCache[K, V]: return self._shards[hash(key) % len(self._shards)]
def get(self, key: K) -> V | None: return self._shard(key).get(key)
def put(self, key: K, value: V) -> None: self._shard(key).put(key, value)
def remove(self, key: K) -> bool: return self._shard(key).remove(key)
def __len__(self) -> int: return sum(len(s) for s in self._shards) # a moment-in-time estimate// The complete design, built from every stage on this page.
// One entry in the recency list. It knows its neighbours, so it can unlink itself in O(1)// without anyone searching for it. It carries its key: on eviction we start from the node// and need the key to remove it from the dictionary.internal sealed class Node<TKey, TValue>(TKey key, TValue value){ public readonly TKey Key = key; public TValue Value = value; public Node<TKey, TValue> Prev = null!, Next = null!;}
// What callers depend on. They never learn which eviction rule is behind it.public interface ICache<TKey, TValue>{ bool TryGet(TKey key, out TValue value); void Put(TKey key, TValue value); bool Remove(TKey key); int Count { get; }}
// Decides who leaves when the cache is full. It sees keys and events, never values, so// LRU, LFU and FIFO are each one class and the cache doesn't change.public interface IEvictionPolicy<TKey>{ void Accessed(TKey key); // a get hit or an update void Added(TKey key); void Removed(TKey key); // removed by the caller, so the policy forgets it TKey Victim(); // who to evict next; the cache then calls Removed(victim)}
// Least recently used, as a policy. A hash map finds a key's node in O(1), and a doubly linked// list keeps the order: oldest at the front, newest at the back. The list holds keys only; the// cache keeps the values.public sealed class LruPolicy<TKey> : IEvictionPolicy<TKey> where TKey : notnull{ private readonly Dictionary<TKey, Node<TKey, byte>> _nodes = new(); private readonly Node<TKey, byte> _sentinel = new(default!, 0);
public LruPolicy() => _sentinel.Prev = _sentinel.Next = _sentinel;
// A hit or an update: move the key to the back, the most recent end. public void Accessed(TKey key) { var n = _nodes[key]; Unlink(n); LinkAtBack(n); }
// A new key starts at the back, as the most recent. public void Added(TKey key) { var n = new Node<TKey, byte>(key, 0); _nodes[key] = n; LinkAtBack(n); }
public void Removed(TKey key) { _nodes.Remove(key, out var n); Unlink(n!); }
public TKey Victim() => _sentinel.Next.Key; // least recently used
private static void Unlink(Node<TKey, byte> n) { n.Prev.Next = n.Next; n.Next.Prev = n.Prev; }
private void LinkAtBack(Node<TKey, byte> n) { n.Prev = _sentinel.Prev; n.Next = _sentinel; _sentinel.Prev.Next = n; _sentinel.Prev = n; }}
// The cache itself. It owns the values, the capacity and the expiry times; the policy only// decides the order. Every way an entry can leave goes through Evict, so the policy and the// listeners are always told.public sealed class BoundedCache<TKey, TValue> : ICache<TKey, TValue> where TKey : notnull{ private readonly int _capacity; private readonly TimeSpan _ttl; private readonly TimeProvider _time; // injected, so a test can move time without sleeping private readonly Dictionary<TKey, Entry<TValue>> _entries = new(); private readonly IEvictionPolicy<TKey> _policy; private readonly List<RemovalListener<TKey, TValue>> _listeners = new();
public BoundedCache(int capacity, TimeSpan ttl, IEvictionPolicy<TKey> policy, TimeProvider time) { ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1); (_capacity, _ttl, _policy, _time) = (capacity, ttl, policy, time); }
public void Subscribe(RemovalListener<TKey, TValue> l) => _listeners.Add(l);
public bool TryGet(TKey key, out TValue value) { value = default!; if (!_entries.TryGetValue(key, out var e)) return false; // Expiry is lazy: checked when the key is read. An expired entry nobody reads still // takes a slot until LRU pushes it out, which is fine for a bounded cache. if (_time.GetUtcNow() >= e.ExpiresAt) { Evict(key, RemovalCause.Expired); return false; } _policy.Accessed(key); value = e.Value; return true; }
public void Put(TKey key, TValue value) { bool existed = _entries.TryGetValue(key, out var old); // Make room before adding, so a policy can never pick the newcomer as its own victim. if (!existed && _entries.Count == _capacity) Evict(_policy.Victim(), RemovalCause.Evicted); _entries[key] = new Entry<TValue>(value, _time.GetUtcNow() + _ttl); if (existed) { _policy.Accessed(key); Notify(key, old.Value, RemovalCause.Replaced); } else { _policy.Added(key); } }
public bool Remove(TKey key) { if (!_entries.ContainsKey(key)) return false; Evict(key, RemovalCause.Explicit); return true; }
public int Count => _entries.Count;
// The one exit: the entry goes, the policy forgets the key, and the listeners hear why. private void Evict(TKey key, RemovalCause cause) { _entries.Remove(key, out var e); _policy.Removed(key); Notify(key, e.Value, cause); }
// A listener that throws must not break the cache: the entry is already gone. private void Notify(TKey key, TValue value, RemovalCause cause) { foreach (var l in _listeners) { try { l(key, value, cause); } catch (Exception ex) { Console.Error.WriteLine($"removal listener failed: {ex.Message}"); } } }}
// Why an entry left. Listeners care: a write-back cache saves Evicted and Expired entries,// but not Replaced ones, because the new value is already in the cache.public enum RemovalCause { Evicted, Expired, Replaced, Explicit }
// Told about every removal, with the reason.public delegate void RemovalListener<TKey, TValue>(TKey key, TValue value, RemovalCause cause);
// A stored value and when it stops being valid.internal readonly record struct Entry<TValue>(TValue Value, DateTimeOffset ExpiresAt);
// Thread safe by one lock. TryGet needs it too: a hit moves the key in the recency list, so// a ReaderWriterLockSlim would let two "readers" corrupt the list.public sealed class SynchronizedCache<TKey, TValue>(ICache<TKey, TValue> inner) : ICache<TKey, TValue>{ private readonly Lock _lock = new();
public bool TryGet(TKey key, out TValue value) { lock (_lock) return inner.TryGet(key, out value); } public void Put(TKey key, TValue value) { lock (_lock) inner.Put(key, value); } public bool Remove(TKey key) { lock (_lock) return inner.Remove(key); } public int Count { get { lock (_lock) return inner.Count; } }}
// N independent caches, each with its own lock. Threads only wait for each other when their// keys hash to the same shard. The price: eviction is LRU within a shard, not across the// whole cache, so the key evicted is "old in its shard", which is close enough for most uses.public sealed class ShardedCache<TKey, TValue> : ICache<TKey, TValue> where TKey : notnull{ private readonly ICache<TKey, TValue>[] _shards;
public ShardedCache(int capacity, int shardCount, Func<int, ICache<TKey, TValue>> newShard) { int perShard = (capacity + shardCount - 1) / shardCount; // round up so total >= capacity _shards = Enumerable.Range(0, shardCount) .Select(_ => (ICache<TKey, TValue>)new SynchronizedCache<TKey, TValue>(newShard(perShard))) .ToArray(); }
// The same key always lands in the same shard. The cast to uint keeps negative hashes in range. private ICache<TKey, TValue> Shard(TKey key) => _shards[(uint)key.GetHashCode() % (uint)_shards.Length];
public bool TryGet(TKey key, out TValue value) => Shard(key).TryGet(key, out value); public void Put(TKey key, TValue value) => Shard(key).Put(key, value); public bool Remove(TKey key) => Shard(key).Remove(key); public int Count => _shards.Sum(s => s.Count); // a moment-in-time estimate}// The complete design, built from every stage on this page.
package lrucache
import ( "hash/maphash" "log" "sync" "time")
// node is one entry in the recency list. It knows its neighbours, so it can unlink itself in// O(1) without anyone searching for it. It carries its key: on eviction we start from the// node and need the key to delete it from the map. (container/list would do; writing it out// is what the question asks.)type node[K comparable, V any] struct { key K value V prev, next *node[K, V]}
func unlink[K comparable, V any](n *node[K, V]) { n.prev.next = n.next n.next.prev = n.prev}
// Cache is what callers depend on. They never learn which eviction rule is behind it.type Cache[K comparable, V any] interface { Get(key K) (V, bool) Put(key K, value V) Remove(key K) bool Len() int}
// EvictionPolicy decides who leaves when the cache is full. It sees keys and events, never// values, so LRU, LFU and FIFO are each one type and the cache doesn't change.type EvictionPolicy[K comparable] interface { Accessed(key K) // a get hit or an update Added(key K) Removed(key K) // removed by the caller, so the policy forgets it Victim() K // who to evict next; the cache then calls Removed(victim)}
// LRUPolicy is least recently used, as a policy. A hash map finds a key's node in O(1), and a// doubly linked list keeps the order: oldest at the front, newest at the back. The list holds// keys only; the cache keeps the values.type LRUPolicy[K comparable] struct { nodes map[K]*node[K, struct{}] sentinel node[K, struct{}]}
func NewLRUPolicy[K comparable]() *LRUPolicy[K] { p := &LRUPolicy[K]{nodes: map[K]*node[K, struct{}]{}} p.sentinel.prev, p.sentinel.next = &p.sentinel, &p.sentinel return p}
// Accessed is a hit or an update: move the key to the back, the most recent end.func (p *LRUPolicy[K]) Accessed(key K) { n := p.nodes[key] unlink(n) p.linkAtBack(n)}
// Added puts a new key at the back, as the most recent.func (p *LRUPolicy[K]) Added(key K) { n := &node[K, struct{}]{key: key} p.nodes[key] = n p.linkAtBack(n)}
func (p *LRUPolicy[K]) Removed(key K) { unlink(p.nodes[key]) delete(p.nodes, key)}
func (p *LRUPolicy[K]) Victim() K { return p.sentinel.next.key } // least recently used
func (p *LRUPolicy[K]) linkAtBack(n *node[K, struct{}]) { last := p.sentinel.prev n.prev, n.next = last, &p.sentinel last.next = n p.sentinel.prev = n}
// RemovalCause says why an entry left. Listeners care: a write-back cache saves Evicted and// Expired entries, but not Replaced ones, because the new value is already in the cache.type RemovalCause int
const ( Evicted RemovalCause = iota Expired Replaced Explicit)
// RemovalListener is told about every removal, with the reason.type RemovalListener[K comparable, V any] func(key K, value V, cause RemovalCause)
// entry is a stored value and when it stops being valid.type entry[V any] struct { value V expiresAt time.Time}
// BoundedCache is the cache itself. It owns the values, the capacity and the expiry times; the// policy only decides the order. Every way an entry can leave goes through evict, so the policy// and the listeners are always told.type BoundedCache[K comparable, V any] struct { capacity int ttl time.Duration now func() time.Time // injected, so a test can move time without sleeping entries map[K]entry[V] policy EvictionPolicy[K] listeners []RemovalListener[K, V]}
func NewBoundedCache[K comparable, V any](capacity int, ttl time.Duration, policy EvictionPolicy[K], now func() time.Time) *BoundedCache[K, V] { if capacity < 1 { panic("capacity must be at least 1") } return &BoundedCache[K, V]{capacity: capacity, ttl: ttl, now: now, entries: map[K]entry[V]{}, policy: policy}}
func (c *BoundedCache[K, V]) Subscribe(l RemovalListener[K, V]) { c.listeners = append(c.listeners, l) }
func (c *BoundedCache[K, V]) Get(key K) (v V, ok bool) { e, ok := c.entries[key] if !ok { return v, false } // Expiry is lazy: checked when the key is read. An expired entry nobody reads still // takes a slot until LRU pushes it out, which is fine for a bounded cache. if !c.now().Before(e.expiresAt) { c.evict(key, Expired) return v, false } c.policy.Accessed(key) return e.value, true}
func (c *BoundedCache[K, V]) Put(key K, value V) { old, existed := c.entries[key] // Make room before adding, so a policy can never pick the newcomer as its own victim. if !existed && len(c.entries) == c.capacity { c.evict(c.policy.Victim(), Evicted) } c.entries[key] = entry[V]{value, c.now().Add(c.ttl)} if existed { c.policy.Accessed(key) c.notify(key, old.value, Replaced) } else { c.policy.Added(key) }}
func (c *BoundedCache[K, V]) Remove(key K) bool { if _, ok := c.entries[key]; !ok { return false } c.evict(key, Explicit) return true}
func (c *BoundedCache[K, V]) Len() int { return len(c.entries) }
// evict is the one exit: the entry goes, the policy forgets the key, and the listeners hear// why.func (c *BoundedCache[K, V]) evict(key K, cause RemovalCause) { e := c.entries[key] delete(c.entries, key) c.policy.Removed(key) c.notify(key, e.value, cause)}
// notify keeps a panicking listener from breaking the cache: the entry is already gone.func (c *BoundedCache[K, V]) notify(key K, value V, cause RemovalCause) { for _, l := range c.listeners { func() { defer func() { if r := recover(); r != nil { log.Printf("removal listener failed: %v", r) } }() l(key, value, cause) }() }}
// SynchronizedCache is thread safe by one mutex. Get needs it too: a hit moves the key in the// recency list, so a sync.RWMutex would let two "readers" corrupt the list.type SynchronizedCache[K comparable, V any] struct { mu sync.Mutex inner Cache[K, V]}
func (s *SynchronizedCache[K, V]) Get(key K) (V, bool) { s.mu.Lock() defer s.mu.Unlock() return s.inner.Get(key)}
func (s *SynchronizedCache[K, V]) Put(key K, value V) { s.mu.Lock() defer s.mu.Unlock() s.inner.Put(key, value)}
func (s *SynchronizedCache[K, V]) Remove(key K) bool { s.mu.Lock() defer s.mu.Unlock() return s.inner.Remove(key)}
func (s *SynchronizedCache[K, V]) Len() int { s.mu.Lock() defer s.mu.Unlock() return s.inner.Len()}
// ShardedCache is N independent caches, each with its own mutex. Goroutines only wait for// each other when their keys hash to the same shard. The price: eviction is LRU within a// shard, not across the whole cache, which is close enough for most uses.type ShardedCache[K comparable, V any] struct { shards []*SynchronizedCache[K, V] seed maphash.Seed}
func NewShardedCache[K comparable, V any](capacity, shardCount int, newShard func(int) Cache[K, V]) *ShardedCache[K, V] { perShard := (capacity + shardCount - 1) / shardCount // round up so the total is at least capacity c := &ShardedCache[K, V]{seed: maphash.MakeSeed()} for range shardCount { c.shards = append(c.shards, &SynchronizedCache[K, V]{inner: newShard(perShard)}) } return c}
// shard sends the same key to the same shard every time.func (c *ShardedCache[K, V]) shard(key K) *SynchronizedCache[K, V] { return c.shards[maphash.Comparable(c.seed, key)%uint64(len(c.shards))]}
func (c *ShardedCache[K, V]) Get(key K) (V, bool) { return c.shard(key).Get(key) }
func (c *ShardedCache[K, V]) Put(key K, value V) { c.shard(key).Put(key, value) }
func (c *ShardedCache[K, V]) Remove(key K) bool { return c.shard(key).Remove(key) }
// Len is a moment-in-time estimate: shards keep changing while it adds them up.func (c *ShardedCache[K, V]) Len() int { n := 0 for _, s := range c.shards { n += s.Len() } return n}// The complete design, built from every stage on this page.
// One entry in the recency list. It knows its neighbours, so it can unlink itself in O(1)// without anyone searching for it. It carries its key: on eviction we start from the node// and need the key to delete it from the map.class ListNode<K, V> { prev: ListNode<K, V> = this; next: ListNode<K, V> = this; constructor( readonly key: K, public value: V, ) {}}
// What callers depend on. They never learn which eviction rule is behind it.interface Cache<K, V> { get(key: K): V | undefined; put(key: K, value: V): void; remove(key: K): boolean; readonly size: number;}
// Decides who leaves when the cache is full. It sees keys and events, never values, so// LRU, LFU and FIFO are each one class and the cache doesn't change.interface EvictionPolicy<K> { accessed(key: K): void; // a get hit or an update added(key: K): void; removed(key: K): void; // removed by the caller, so the policy forgets it victim(): K; // who to evict next; the cache then calls removed(victim)}
// Least recently used, as a policy. A hash map finds a key's node in O(1), and a doubly linked// list keeps the order: oldest at the front, newest at the back. The list holds keys only; the// cache keeps the values.class LruPolicy<K> implements EvictionPolicy<K> { private readonly nodes = new Map<K, ListNode<K, null>>(); private readonly sentinel = new ListNode<K, null>(undefined as K, null);
// A hit or an update: move the key to the back, the most recent end. accessed(key: K): void { const n = this.nodes.get(key)!; this.unlink(n); this.linkAtBack(n); } // A new key starts at the back, as the most recent. added(key: K): void { const n = new ListNode<K, null>(key, null); this.nodes.set(key, n); this.linkAtBack(n); } removed(key: K): void { this.unlink(this.nodes.get(key)!); this.nodes.delete(key); } victim(): K { return this.sentinel.next.key; // least recently used }
private unlink(n: ListNode<K, null>): void { n.prev.next = n.next; n.next.prev = n.prev; } private linkAtBack(n: ListNode<K, null>): void { const last = this.sentinel.prev; n.prev = last; n.next = this.sentinel; last.next = n; this.sentinel.prev = n; }}
// The cache itself. It owns the values, the capacity and the expiry times; the policy only// decides the order. Every way an entry can leave goes through evict, so the policy and the// listeners are always told.class BoundedCache<K, V> implements Cache<K, V> { private readonly entries = new Map<K, Entry<V>>(); private readonly listeners: RemovalListener<K, V>[] = [];
constructor( private readonly capacity: number, private readonly ttlMs: number, private readonly policy: EvictionPolicy<K>, private readonly now: () => number = Date.now, // injected, so a test can move time ) { if (capacity < 1) throw new RangeError('capacity must be at least 1'); }
subscribe(l: RemovalListener<K, V>): void { this.listeners.push(l); }
get(key: K): V | undefined { const e = this.entries.get(key); if (!e) return undefined; // Expiry is lazy: checked when the key is read. An expired entry nobody reads still // takes a slot until LRU pushes it out, which is fine for a bounded cache. if (this.now() >= e.expiresAt) { this.evict(key, 'expired'); return undefined; } this.policy.accessed(key); return e.value; }
put(key: K, value: V): void { const old = this.entries.get(key); // Make room before adding, so a policy can never pick the newcomer as its own victim. if (!old && this.entries.size === this.capacity) this.evict(this.policy.victim(), 'evicted'); this.entries.set(key, { value, expiresAt: this.now() + this.ttlMs }); if (old) { this.policy.accessed(key); this.notify(key, old.value, 'replaced'); } else { this.policy.added(key); } }
remove(key: K): boolean { if (!this.entries.has(key)) return false; this.evict(key, 'explicit'); return true; }
get size(): number { return this.entries.size; }
// The one exit: the entry goes, the policy forgets the key, and the listeners hear why. private evict(key: K, cause: RemovalCause): void { const e = this.entries.get(key)!; this.entries.delete(key); this.policy.removed(key); this.notify(key, e.value, cause); }
// A listener that throws must not break the cache: the entry is already gone. private notify(key: K, value: V, cause: RemovalCause): void { for (const l of this.listeners) { try { l(key, value, cause); } catch (err) { console.error('removal listener failed', err); } } }}
// Why an entry left. Listeners care: a write-back cache saves 'evicted' and 'expired'// entries, but not 'replaced' ones, because the new value is already in the cache.type RemovalCause = 'evicted' | 'expired' | 'replaced' | 'explicit';
// Told about every removal, with the reason.type RemovalListener<K, V> = (key: K, value: V, cause: RemovalCause) => void;
// A stored value and when it stops being valid (milliseconds on the injected clock).interface Entry<V> { readonly value: V; readonly expiresAt: number;}
// In one Node process there is nothing to lock: get() and put() run to completion before// anything else runs. The concurrency problem moves to the loader. Two requests that miss// the same key at once both call the slow backend; "single flight" makes the second one// wait for the first one's answer instead.class LoadingCache<K, V> { private readonly inFlight = new Map<K, Promise<V>>();
constructor( private readonly cache: Cache<K, V>, private readonly load: (key: K) => Promise<V>, ) {}
async get(key: K): Promise<V> { const hit = this.cache.get(key); if (hit !== undefined) return hit; // Check and register in the same synchronous step, so no other request can slip between. let pending = this.inFlight.get(key); if (!pending) { pending = this.load(key) .then((v) => { this.cache.put(key, v); return v; }) .finally(() => this.inFlight.delete(key)); // a failed load is retried by the next caller this.inFlight.set(key, pending); } return pending; }}Three ideas hold it together:
- Storing values and keeping order are separate jobs.
BoundedCachenever touches a list, andLruPolicynever sees a value. Each can be tested on its own. The policy is the Strategy pattern. - Thread safety is a wrapper. The single-threaded cache stays simple.
SynchronizedCacheadds the lock around anyCachewithout changing it. That’s the Decorator pattern. Removal listeners are the Observer pattern. - Callers only see the
Cacheinterface. Sharded, locked or plain, the calling code is the same. To callers, the cache is a simple front door with four methods (a facade).
Stress it
Section titled “Stress it”The design works for one thread. But a cache usually lives inside a server, where many threads read and write it at once.
Many threads, and a read that writes
Section titled “Many threads, and a read that writes”Many threads share this cache. Reads are 95% of the traffic. What do you do?
The classic trap: whether you notice that a get writes (it moves the node), so a read-write lock is wrong. Then whether you can trade exact LRU for speed, and say so.
The tempting answer is a read-write lock. It lets many readers in at once, but only one writer. Reads are 95% of the traffic, so this sounds perfect.
It’s wrong here, because a get is a write. A hit takes its node out of the list and puts it at the back: four pointer changes. Picture two threads doing that at the same moment, on neighbouring nodes. Each one reads the links, then changes them, and each overwrites part of the other’s work. A node ends up linked twice, or lost. The next eviction follows a broken link.
The clearly correct fix is one lock. Every method, including get, takes the same lock. This is safe, because only one thread at a time touches the map and the list. The cost: threads wait in line, even when they want different keys.
The better fix is sharding. We make, say, 16 independent caches, called shards, each with its own lock. A key’s hash picks its shard, so the same key always goes to the same shard. Two threads only wait for each other when their keys land in the same shard.
| Option | How | Exact LRU? | Threads wait when |
|---|---|---|---|
| 1. One lock | Every method holds the same lock | Yes | Always: one operation at a time |
| 2. Read-write lock | Gets share a read lock | Breaks | Never, which is the problem |
| 3. Sharding (our choice) | N caches, each with its own lock; the key’s hash picks one | Per shard | Only when keys share a shard |
| 4. Buffered reads (Caffeine) | Gets write “accessed” into a buffer; one thread applies it later | Nearly | Rarely; reads don’t touch the list |
// Thread safe by one lock. Note that get() needs the write side too: a hit moves the key in// the recency list, so a read-write lock would let two "readers" corrupt the list.class SynchronizedCache<K, V> implements Cache<K, V> { private final Cache<K, V> inner; private final Object lock = new Object();
SynchronizedCache(Cache<K, V> inner) { this.inner = inner; }
public Optional<V> get(K key) { synchronized (lock) { return inner.get(key); } } public void put(K key, V value) { synchronized (lock) { inner.put(key, value); } } public boolean remove(K key) { synchronized (lock) { return inner.remove(key); } } public int size() { synchronized (lock) { return inner.size(); } }}
// N independent caches, each with its own lock. Threads only wait for each other when their// keys hash to the same shard. The price: eviction is LRU within a shard, not across the// whole cache, so the key evicted is "old in its shard", which is close enough for most uses.class ShardedCache<K, V> implements Cache<K, V> { private final List<Cache<K, V>> shards = new ArrayList<>();
ShardedCache(int capacity, int shardCount, Function<Integer, Cache<K, V>> newShard) { int perShard = (capacity + shardCount - 1) / shardCount; // round up so total >= capacity for (int i = 0; i < shardCount; i++) shards.add(new SynchronizedCache<>(newShard.apply(perShard))); }
// The same key always lands in the same shard. floorMod keeps negative hashes in range. private Cache<K, V> shard(K key) { return shards.get(Math.floorMod(key.hashCode(), shards.size())); }
public Optional<V> get(K key) { return shard(key).get(key); } public void put(K key, V value) { shard(key).put(key, value); } public boolean remove(K key) { return shard(key).remove(key); } public int size() { return shards.stream().mapToInt(Cache::size).sum(); } // a moment-in-time estimate}import threading
# Thread safe by one lock. get() needs it too: a hit moves the key in the recency list, so# letting two "readers" in at once would corrupt the list. The GIL doesn't help: it makes# single bytecodes atomic, not the four pointer writes of an unlink and relink.class SynchronizedCache(Generic[K, V]): def __init__(self, inner: Cache[K, V]): self._inner = inner self._lock = threading.Lock()
def get(self, key: K) -> V | None: with self._lock: return self._inner.get(key)
def put(self, key: K, value: V) -> None: with self._lock: self._inner.put(key, value)
def remove(self, key: K) -> bool: with self._lock: return self._inner.remove(key)
def __len__(self) -> int: with self._lock: return len(self._inner)
# N independent caches, each with its own lock. Threads only wait for each other when their# keys hash to the same shard. The price: eviction is LRU within a shard, not across the# whole cache, so the key evicted is "old in its shard", which is close enough for most uses.class ShardedCache(Generic[K, V]): def __init__(self, capacity: int, shard_count: int, new_shard: Callable[[int], Cache[K, V]]): per_shard = -(-capacity // shard_count) # round up so the total is at least capacity self._shards = [SynchronizedCache(new_shard(per_shard)) for _ in range(shard_count)]
# The same key always lands in the same shard. def _shard(self, key: K) -> SynchronizedCache[K, V]: return self._shards[hash(key) % len(self._shards)]
def get(self, key: K) -> V | None: return self._shard(key).get(key)
def put(self, key: K, value: V) -> None: self._shard(key).put(key, value)
def remove(self, key: K) -> bool: return self._shard(key).remove(key)
def __len__(self) -> int: return sum(len(s) for s in self._shards) # a moment-in-time estimate// Thread safe by one lock. TryGet needs it too: a hit moves the key in the recency list, so// a ReaderWriterLockSlim would let two "readers" corrupt the list.public sealed class SynchronizedCache<TKey, TValue>(ICache<TKey, TValue> inner) : ICache<TKey, TValue>{ private readonly Lock _lock = new();
public bool TryGet(TKey key, out TValue value) { lock (_lock) return inner.TryGet(key, out value); } public void Put(TKey key, TValue value) { lock (_lock) inner.Put(key, value); } public bool Remove(TKey key) { lock (_lock) return inner.Remove(key); } public int Count { get { lock (_lock) return inner.Count; } }}
// N independent caches, each with its own lock. Threads only wait for each other when their// keys hash to the same shard. The price: eviction is LRU within a shard, not across the// whole cache, so the key evicted is "old in its shard", which is close enough for most uses.public sealed class ShardedCache<TKey, TValue> : ICache<TKey, TValue> where TKey : notnull{ private readonly ICache<TKey, TValue>[] _shards;
public ShardedCache(int capacity, int shardCount, Func<int, ICache<TKey, TValue>> newShard) { int perShard = (capacity + shardCount - 1) / shardCount; // round up so total >= capacity _shards = Enumerable.Range(0, shardCount) .Select(_ => (ICache<TKey, TValue>)new SynchronizedCache<TKey, TValue>(newShard(perShard))) .ToArray(); }
// The same key always lands in the same shard. The cast to uint keeps negative hashes in range. private ICache<TKey, TValue> Shard(TKey key) => _shards[(uint)key.GetHashCode() % (uint)_shards.Length];
public bool TryGet(TKey key, out TValue value) => Shard(key).TryGet(key, out value); public void Put(TKey key, TValue value) => Shard(key).Put(key, value); public bool Remove(TKey key) => Shard(key).Remove(key); public int Count => _shards.Sum(s => s.Count); // a moment-in-time estimate}// SynchronizedCache is thread safe by one mutex. Get needs it too: a hit moves the key in the// recency list, so a sync.RWMutex would let two "readers" corrupt the list.type SynchronizedCache[K comparable, V any] struct { mu sync.Mutex inner Cache[K, V]}
func (s *SynchronizedCache[K, V]) Get(key K) (V, bool) { s.mu.Lock() defer s.mu.Unlock() return s.inner.Get(key)}
func (s *SynchronizedCache[K, V]) Put(key K, value V) { s.mu.Lock() defer s.mu.Unlock() s.inner.Put(key, value)}
func (s *SynchronizedCache[K, V]) Remove(key K) bool { s.mu.Lock() defer s.mu.Unlock() return s.inner.Remove(key)}
func (s *SynchronizedCache[K, V]) Len() int { s.mu.Lock() defer s.mu.Unlock() return s.inner.Len()}
// ShardedCache is N independent caches, each with its own mutex. Goroutines only wait for// each other when their keys hash to the same shard. The price: eviction is LRU within a// shard, not across the whole cache, which is close enough for most uses.type ShardedCache[K comparable, V any] struct { shards []*SynchronizedCache[K, V] seed maphash.Seed}
func NewShardedCache[K comparable, V any](capacity, shardCount int, newShard func(int) Cache[K, V]) *ShardedCache[K, V] { perShard := (capacity + shardCount - 1) / shardCount // round up so the total is at least capacity c := &ShardedCache[K, V]{seed: maphash.MakeSeed()} for range shardCount { c.shards = append(c.shards, &SynchronizedCache[K, V]{inner: newShard(perShard)}) } return c}
// shard sends the same key to the same shard every time.func (c *ShardedCache[K, V]) shard(key K) *SynchronizedCache[K, V] { return c.shards[maphash.Comparable(c.seed, key)%uint64(len(c.shards))]}
func (c *ShardedCache[K, V]) Get(key K) (V, bool) { return c.shard(key).Get(key) }func (c *ShardedCache[K, V]) Put(key K, value V) { c.shard(key).Put(key, value) }func (c *ShardedCache[K, V]) Remove(key K) bool { return c.shard(key).Remove(key) }
// Len is a moment-in-time estimate: shards keep changing while it adds them up.func (c *ShardedCache[K, V]) Len() int { n := 0 for _, s := range c.shards { n += s.Len() } return n}// In one Node process there is nothing to lock: get() and put() run to completion before// anything else runs. The concurrency problem moves to the loader. Two requests that miss// the same key at once both call the slow backend; "single flight" makes the second one// wait for the first one's answer instead.class LoadingCache<K, V> { private readonly inFlight = new Map<K, Promise<V>>();
constructor( private readonly cache: Cache<K, V>, private readonly load: (key: K) => Promise<V>, ) {}
async get(key: K): Promise<V> { const hit = this.cache.get(key); if (hit !== undefined) return hit; // Check and register in the same synchronous step, so no other request can slip between. let pending = this.inFlight.get(key); if (!pending) { pending = this.load(key) .then((v) => { this.cache.put(key, v); return v; }) .finally(() => this.inFlight.delete(key)); // a failed load is retried by the next caller this.inFlight.set(key, pending); } return pending; }}Why is this correct? Each shard is the stage 4 cache behind one lock. So every operation on a shard, including the node moves inside a get, runs alone. A key always goes to the same shard, so all operations on one key meet the same lock. Shards share nothing, so there’s nothing to keep in step between them.
What does it cost? We lose the order across the whole cache. The evicted key is the least
recently used in its shard, not overall. With a good hash and many keys per shard, that’s close
to the overall answer. The size limit is also split: if many popular keys land in one shard, it
evicts while other shards sit half empty. And size() across shards is only an estimate, since
shards change while we add them up.
Go deeperHow each language does the lock1 min
Java uses a synchronized block on one lock object per shard. Python needs a lock too. The GIL makes
single bytecode steps safe, not a four-pointer unlink. Go uses a sync.Mutex per shard (not
RWMutex, for the same reason as above). C# 13’s Lock type does the same.
TypeScript in one Node process has no threads, so there’s no race on the list. Its stage 5 solves the problem Node does have: ten requests missing the same key at once would each call the slow backend. A single flight map makes the nine latecomers wait for the first load.
How many shards? A common start is a small multiple of the number of CPU cores, such as 16 or 64. More shards mean less waiting, but each shard gets a smaller share of the size limit, so the LRU order inside each one gets rougher. Use a power of two, so picking a shard is a quick bit mask of the hash. And spread the hash bits first: some hash codes differ only in their high bits, which would send every key to the same few shards.
Go deeperTen threads miss the same key at once1 min
Each thread calls the backend for the same key, so one slow value causes ten slow calls. This is
called a thundering herd. The fix is single flight: the first miss registers “I’m loading
this key”, and the other nine wait for that load instead of starting their own. Java’s
ConcurrentHashMap.computeIfAbsent with a future as the value, Go’s singleflight package, and the
TypeScript stage 5 above all do this.
One rule: never hold the cache’s lock while loading. One slow load would block every other key.
Go deeperHow Caffeine avoids a lock on every read1 min
In Caffeine, a popular Java cache, reads don’t touch the list at all. A get looks up a concurrent hash map (no lock) and writes “this key was used” into a small buffer. When the buffer fills, one thread takes the lock and applies all those uses to the list at once. If a buffer is full, the note is simply dropped. So the order is slightly approximate, and reads almost never wait.
Caffeine also uses a smarter rule than LRU, called Window TinyLFU. It keeps a compact count of how often keys are used, which protects popular keys from one-off reads.
How you’d prove it works
Section titled “How you’d prove it works”Each test protects one promise:
- The least recently used key is evicted, and a get changes which key that is.
- Updating an existing key never evicts, and makes it the most recent.
- A cache of size 1 works (the sentinel’s edge cases).
- An expired entry reads as a miss, and the listener hears “expired”. This uses a test clock.
- Many threads using a sharded cache never cause errors and never go over the size limit.
The thread test runs 16 threads over 5,000 keys with a 1,000-entry cache, so evictions happen all the time. It checks rules, not exact values: nothing threw, and the size never went over the limit. When we took the lock out, the symptom wasn’t an error but a hang: the broken links sent a thread around the list forever. So run the test with a time limit, and a hang shows up as a failure.
Go deeperThe tests in five languages2 min
// A clock the test moves by hand, so expiry is tested without sleeping.static class TestClock extends Clock { Instant now = Instant.parse("2026-01-01T00:00:00Z"); public Instant instant() { return now; } public ZoneId getZone() { return ZoneOffset.UTC; } public Clock withZone(ZoneId zone) { return this; }}
TestClock clock = new TestClock();
BoundedCache<String, Integer> lru(int capacity) { return new BoundedCache<>(capacity, Duration.ofMinutes(5), new LruPolicy<>(), clock);}
@Testvoid evictsTheLeastRecentlyUsedKey() { var c = lru(2); c.put("a", 1); c.put("b", 2); c.get("a"); // a is now more recent than b c.put("c", 3); // full: b goes, not a assertTrue(c.get("b").isEmpty()); assertEquals(Optional.of(1), c.get("a"));}
@Testvoid updatingAKeyNeverEvicts() { var c = lru(2); c.put("a", 1); c.put("b", 2); c.put("a", 10); // same key: replace, and a becomes most recent assertEquals(2, c.size()); assertEquals(Optional.of(10), c.get("a")); assertEquals(Optional.of(2), c.get("b"));}
@Testvoid expiredEntriesReadAsMissesAndSayWhy() { var c = lru(2); List<RemovalCause> causes = new ArrayList<>(); c.subscribe((k, v, cause) -> causes.add(cause)); c.put("a", 1); clock.now = clock.now.plus(Duration.ofMinutes(5)); // exactly the TTL: expired assertTrue(c.get("a").isEmpty()); assertEquals(List.of(RemovalCause.EXPIRED), causes);}
@Testvoid concurrentUseNeverBreaksTheListOrOverfills() throws Exception { // 8 shards of 125: total capacity 1,000. 16 threads hammer 5,000 keys. Cache<Integer, Integer> c = new ShardedCache<>(1_000, 8, n -> new BoundedCache<>(n, Duration.ofHours(1), new LruPolicy<>(), clock)); ExecutorService pool = Executors.newFixedThreadPool(16); List<Future<?>> work = new ArrayList<>(); for (int t = 0; t < 16; t++) { int seed = t; work.add(pool.submit(() -> { Random r = new Random(seed); for (int i = 0; i < 50_000; i++) { int k = r.nextInt(5_000); if (c.get(k).isEmpty()) c.put(k, k); } })); } for (Future<?> f : work) f.get(); // rethrows if a corrupted list threw anywhere pool.shutdown(); assertTrue(c.size() <= 1_000);}import randomfrom concurrent.futures import ThreadPoolExecutor
# A clock the test moves by hand, so expiry is tested without sleeping.class TestClock: def __init__(self): self.now = 0.0
def __call__(self) -> float: return self.now
def lru(capacity: int, clock: TestClock) -> BoundedCache[str, int]: return BoundedCache(capacity, ttl_seconds=300, policy=LruPolicy(), clock=clock)
def test_evicts_the_least_recently_used_key(): c = lru(2, TestClock()) c.put("a", 1) c.put("b", 2) c.get("a") # a is now more recent than b c.put("c", 3) # full: b goes, not a assert c.get("b") is None assert c.get("a") == 1
def test_updating_a_key_never_evicts(): c = lru(2, TestClock()) c.put("a", 1) c.put("b", 2) c.put("a", 10) # same key: replace, and a becomes most recent assert len(c) == 2 and c.get("a") == 10 and c.get("b") == 2
def test_expired_entries_read_as_misses_and_say_why(): clock = TestClock() c = lru(2, clock) causes: list[RemovalCause] = [] c.subscribe(lambda k, v, cause: causes.append(cause)) c.put("a", 1) clock.now += 300 # exactly the TTL: expired assert c.get("a") is None assert causes == [RemovalCause.EXPIRED]
def test_concurrent_use_never_breaks_the_list_or_overfills(): # 8 shards of 125: total capacity 1,000. 16 threads hammer 5,000 keys. c = ShardedCache(1_000, 8, lambda n: BoundedCache(n, 3600, LruPolicy(), TestClock()))
def hammer(seed: int) -> None: r = random.Random(seed) for _ in range(20_000): k = r.randrange(5_000) if c.get(k) is None: c.put(k, k)
with ThreadPoolExecutor(max_workers=16) as pool: list(pool.map(hammer, range(16))) # list() re-raises any exception from a worker assert len(c) <= 1_000// FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) is moved by hand,// so expiry is tested without sleeping.private readonly FakeTimeProvider _time = new();
private BoundedCache<string, int> Lru(int capacity) => new(capacity, TimeSpan.FromMinutes(5), new LruPolicy<string>(), _time);
[Fact]public void EvictsTheLeastRecentlyUsedKey(){ var c = Lru(2); c.Put("a", 1); c.Put("b", 2); c.TryGet("a", out _); // a is now more recent than b c.Put("c", 3); // full: b goes, not a Assert.False(c.TryGet("b", out _)); Assert.True(c.TryGet("a", out var a) && a == 1);}
[Fact]public void ExpiredEntriesReadAsMissesAndSayWhy(){ var c = Lru(2); var causes = new List<RemovalCause>(); c.Subscribe((_, _, cause) => causes.Add(cause)); c.Put("a", 1); _time.Advance(TimeSpan.FromMinutes(5)); // exactly the TTL: expired Assert.False(c.TryGet("a", out _)); Assert.Equal(new[] { RemovalCause.Expired }, causes);}
[Fact]public async Task ConcurrentUseNeverBreaksTheListOrOverfills(){ // 8 shards of 125: total capacity 1,000. 16 tasks hammer 5,000 keys. var c = new ShardedCache<int, int>(1_000, 8, n => new BoundedCache<int, int>(n, TimeSpan.FromHours(1), new LruPolicy<int>(), _time)); await Task.WhenAll(Enumerable.Range(0, 16).Select(seed => Task.Run(() => { var r = new Random(seed); for (int i = 0; i < 50_000; i++) { int k = r.Next(5_000); if (!c.TryGet(k, out _)) c.Put(k, k); } }))); // WhenAll rethrows if a corrupted list threw anywhere Assert.True(c.Count <= 1_000);}// testClock is moved by hand, so expiry is tested without sleeping.type testClock struct{ t time.Time }
func (c *testClock) now() time.Time { return c.t }
func lru(capacity int, clock *testClock) *BoundedCache[string, int] { return NewBoundedCache[string, int](capacity, 5*time.Minute, NewLRUPolicy[string](), clock.now)}
func TestEvictsTheLeastRecentlyUsedKey(t *testing.T) { c := lru(2, &testClock{}) c.Put("a", 1) c.Put("b", 2) c.Get("a") // a is now more recent than b c.Put("c", 3) // full: b goes, not a if _, ok := c.Get("b"); ok { t.Fatal("b should have been evicted") } if v, _ := c.Get("a"); v != 1 { t.Fatalf("a = %d, want 1", v) }}
func TestExpiredEntriesReadAsMissesAndSayWhy(t *testing.T) { clock := &testClock{} c := lru(2, clock) var causes []RemovalCause c.Subscribe(func(_ string, _ int, cause RemovalCause) { causes = append(causes, cause) }) c.Put("a", 1) clock.t = clock.t.Add(5 * time.Minute) // exactly the TTL: expired if _, ok := c.Get("a"); ok || len(causes) != 1 || causes[0] != Expired { t.Fatalf("ok=%v causes=%v", ok, causes) }}
// Run with: go test -race ./... The race detector flags any unguarded access.func TestConcurrentUseNeverBreaksTheListOrOverfills(t *testing.T) { // 8 shards of 125: total capacity 1,000. 16 goroutines hammer 5,000 keys. clock := &testClock{} c := NewShardedCache[int, int](1000, 8, func(n int) Cache[int, int] { return NewBoundedCache[int, int](n, time.Hour, NewLRUPolicy[int](), clock.now) }) var wg sync.WaitGroup for g := range 16 { wg.Add(1) go func() { defer wg.Done() r := rand.New(rand.NewPCG(uint64(g), 0)) for range 20_000 { k := r.IntN(5000) if _, ok := c.Get(k); !ok { c.Put(k, k) } } }() } wg.Wait() if c.Len() > 1000 { t.Fatalf("len %d exceeds capacity", c.Len()) }}import { test } from 'node:test';import assert from 'node:assert/strict';
// A clock the test moves by hand, so expiry is tested without sleeping.let now = 0;const lru = (capacity: number) => new BoundedCache<string, number>(capacity, 300_000, new LruPolicy(), () => now);
test('evicts the least recently used key', () => { const c = lru(2); c.put('a', 1); c.put('b', 2); c.get('a'); // a is now more recent than b c.put('c', 3); // full: b goes, not a assert.equal(c.get('b'), undefined); assert.equal(c.get('a'), 1);});
test('expired entries read as misses and say why', () => { const c = lru(2); const causes: RemovalCause[] = []; c.subscribe((_k, _v, cause) => causes.push(cause)); c.put('a', 1); now += 300_000; // exactly the TTL: expired assert.equal(c.get('a'), undefined); assert.deepEqual(causes, ['expired']);});
test('ten concurrent misses for one key load it once', async () => { let loads = 0; const c = new LoadingCache<string, number>(lru(10), async (k) => { loads++; await new Promise((r) => setTimeout(r, 10)); // a slow backend return k.length; }); const results = await Promise.all(Array.from({ length: 10 }, () => c.get('hello'))); assert.deepEqual(new Set(results), new Set([5])); assert.equal(loads, 1);});Go deeperEvery edge case, and where it is handled2 min
| Case | What happens | Where |
|---|---|---|
| Size limit 0 or negative | Rejected when the cache is created | constructors |
| Size limit 1 | Every new key evicts the previous one | eviction before insert |
| Put an existing key | Value replaced, key becomes most recent, no eviction | put, REPLACED |
| Get a key that expired | Miss; entry removed; listener told EXPIRED |
get |
| Expired entry never read | Holds its place until evicted (lazy expiry) | by design |
| Null value | Rejected (Java, C#); a miss and a null would look the same | put |
| Listener throws | Logged; the cache is already consistent | notify |
| Listener calls back into the cache | Runs under the shard lock; must be quick and must not block | the listener’s job |
| Negative hash code | floorMod or an unsigned cast keeps the shard number in range |
ShardedCache.shard |
| Clock moves backwards | Monotonic clock in production; a fake clock in tests | Clock |
Wrap up
Section titled “Wrap up”The decisions, and what each one costs
Section titled “The decisions, and what each one costs”| Decision | We chose | We gave up |
|---|---|---|
| Structure | Hash map to nodes of a doubly linked list, one sentinel | Around 70 bytes extra per entry |
| Eviction rule | An EvictionPolicy strategy |
A second map of the same keys |
| When to evict | Before inserting the new key | Nothing; safe for every rule |
| Expiry | Lazy, checked on read, with a monotonic clock passed in | Expired entries stay until read or evicted |
| Removal events | Listeners told the reason | Listeners run under the lock and must be quick |
| Thread safety | A lock per shard, as a wrapper | Exact LRU across the whole cache |
| Read-write lock | Rejected | Nothing: a get writes |
Go deeperGenuinely different designs, and when to pick them2 min
CLOCK (second chance). Entries sit in a circle, each with a “used recently” flag. A get only sets the flag, with no list changes, so reads are cheap and easy to share between threads. To evict, a pointer moves around the circle, clearing flags, until it finds one already clear. It’s close to LRU, and operating systems use it to decide which memory pages to drop.
Window TinyLFU (Caffeine). A small LRU area for new entries, a main area split into “on trial” and “protected” parts, and a compact frequency count that decides whether a newcomer deserves to replace a victim. It resists one-off scans that would empty a plain LRU, and gets clearly higher hit rates on real traffic.
Just use the library. LinkedHashMap for single-threaded Java, Caffeine for Java with threads,
functools.lru_cache for Python functions. In production, building your own LRU is rarely right.
The interview asks so it can see how you think about the structure.
How to spend 45 minutes
Section titled “How to spend 45 minutes”- 0 to 3. Entries or bytes, threads, expiry, other rules.
- 3 to 5. The promises. Name “O(1) for get, put and eviction together” as the hard one.
- 5 to 25. The queue version and its O(n). Then the map and list (code it fully). Then the policy split, then expiry and listeners.
- 25 to 27. Walk through the full diagram: storing versus ordering, thread safety as a wrapper.
- 27 to 36. Why a get is a write, one lock, then sharding, and single flight for misses.
- 36 to 45. One extension, usually size in bytes or LFU, then your own questions.
If time is short, describe the listener in a sentence. Always code stage 2 completely. It’s the part that gets checked line by line.
Follow-up questions
Section titled “Follow-up questions”Some of our values are 1 KB and some are 5 MB. A limit by count doesn’t work.
Whether the change lands in one place (a weight per entry, and a loop in eviction), and whether you spot the new edge case: one entry bigger than the whole cache.
Most extensions fit into one place:
| Extension | What changes | Touches BoundedCache? |
|---|---|---|
| LFU or FIFO | A new EvictionPolicy |
No |
| Size in bytes | A weigher function; evict in a loop until the new entry fits; refuse oversize entries | Yes, eviction only |
| Loading on a miss | A LoadingCache wrapper with single flight |
No |
| TTL per entry | put(key, value, ttl); Entry already stores its own expiry |
One overload |
| Metrics | Counters in a wrapper; evictions from a removal listener | No |
| Across machines | A different system: consistent hashing across nodes (see the caching building block) | Not this class |
Go deeperAdding a size limit in bytes, step by step2 min
Start with what changes. Today, the cache counts entries, and “full” means “count equals the limit”. With values from 1 KB to 5 MB, ten entries could be 10 KB or 50 MB. So we count bytes instead.
- A weigher. The cache is given a small function that returns the size of a value, for
example
value.length. That’s the only new class, and the caller writes it, because only the caller knows how big their values are. - A running total. The cache keeps
totalWeight. A put adds the new entry’s weight, and every removal (evicted, expired, replaced, removed) subtracts the old one. Replacing a value counts both: take away the old weight, add the new one. - Evict in a loop. One new 5 MB value may need several small values to leave. So instead of “evict one if full”, put says “while the total plus the new weight is over the limit, evict the policy’s victim”.
- Refuse what can never fit. If one value is bigger than the whole cache, the loop would evict everything and still fail. So we check that first and refuse the value, leaving the cache as it was.
Notice what stayed the same. The policy still answers “who’s next?”, and doesn’t care about bytes.
The listeners still hear “evicted” for each value the loop removes. Only the eviction step in
BoundedCache changed.
Go deeperAdding loading on a miss, step by step2 min
Today, the caller handles a miss: it asks the cache, gets nothing, loads the value from the
database, and puts it in. Every caller repeats those three steps, and every caller can get them
slightly wrong. A loading cache does it for them: get(key) loads the value itself on a miss.
We don’t change BoundedCache for this. We wrap it, the same way SynchronizedCache wraps it for
locking.
- A loader. The wrapper is given one function,
load(key), that fetches a value from the slow place. The caller writes it once, when the cache is built. - Get or load.
get(key)first asks the inner cache. On a hit, it returns the value. On a miss, it calls the loader, puts the result in the inner cache, and returns it. - One load per key. This is where the thundering herd from earlier comes back. If ten threads
miss the same key at once, they must not start ten loads. The wrapper keeps a map of loads in
progress. The first thread adds “loading
k” and starts the load. The other nine find that entry and wait for its result. When the load finishes, the entry is removed. - Failures. If the load fails, all ten waiting threads get the same error, and nothing goes into the cache. The next request tries again. Caching a failure by accident would hide the database for the whole TTL.
The rule from the concurrency section still holds: the wrapper never holds a shard’s lock while the loader runs. The map of loads in progress has its own small lock, held only for a moment.
Go deeperBuilding LFU in O(1), step by step3 min
LFU evicts the key used the fewest times. The obvious version sorts keys by count, which costs O(log n) per access. Here’s how to keep it O(1).
The idea is to group keys by their count. We keep three things:
- A count for each key. For example,
ahas been used 3 times. - A list for each count. All keys used exactly once are in list 1, keys used twice are in list 2, and so on. Each list is a doubly linked list in order of use, just like our LRU list.
- The smallest count in use. We call it
minCount.
Now each policy method is a few pointer changes:
accessed(key). The key’s count goes from c to c + 1. We take its node out of list c (O(1), since we have the node) and put it at the back of list c + 1. If list c is now empty and c wasminCount, thenminCountbecomes c + 1.added(key). A new key has count 1. It goes to the back of list 1, andminCountbecomes 1.victim(). The front of listminCount: among the keys used the fewest times, the one used longest ago.
Why does minCount only ever need to go up by one, or reset to 1? Because an access moves exactly
one key up by exactly one count. And a new key always starts at 1. So we never search for the
smallest count.
This is also why stage 3 evicts before adding. The newcomer would sit in list 1, as the most recent key there. If eviction ran after adding, and list 1 held only the newcomer, the victim would be the key we just added.
Go deeperTwo more follow-up questions1 min
Interviewer asksA batch job reads every key once and flushes the whole cache. Fix it.
That’s LRU’s known weakness: one pass over many keys evicts everything that was popular. Options: segmented LRU (new entries start in an “on trial” part and only move to the “protected” part on a second hit), or an admission filter like TinyLFU that refuses newcomers used less often than the victim. Or give the batch job its own cache.
Interviewer asksHow would you measure whether the cache is worth it?
Hit rate, but weighted by cost: a hit that saves a 200 ms database call matters more than one that saves 1 ms. Track hits, misses, evictions by reason, and load time. If most removals are evictions and the hit rate is low, the cache is too small for the keys people actually use.
Further reading
Section titled “Further reading”- Strategy pattern, Decorator pattern, Observer pattern
- Thread safety basics, Caching
- Design a rate limiter, which also keeps per-key state that must be evicted
- Caffeine: Design, buffered reads and Window TinyLFU
- Einziger, Friedman, Manes: TinyLFU, the admission policy behind Caffeine
- Redis: Key eviction, approximated LRU by sampling
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.