Practice · Low Level Design · Shared State and Contention
Design an LRU Cache
A cache that forgets the least recently used item, with O(1) get and put: a hash map plus a doubly linked list, then making it thread-safe.
Learn mode: the full solution is shown below.
Problem introduction
| Difficulty | Intermediate |
| Time | 1–2 hours to design and code; about 30–45 minutes in an interview |
| Prerequisites | Hash maps, pointers or references, a little about locks |
| Concepts | A hash map plus a doubly linked list, sentinel nodes, O(1) get and put, an eviction callback, a thread-safe wrapper, why a "read" here needs a write lock |
| Real world | Redis with maxmemory-policy allkeys-lru, database buffer pools, the OS page cache, CDN edge caches, the image cache in a mobile app |
An LRU cache keeps the most recently used items and throws away the one nobody has touched for the longest time. It is asked in interviews all the time because the obvious solutions are slow, and the fast one needs two data structures working together. The LLD side adds two more questions: how do you keep the cache's own code clean of locking, and why can't Get share a read lock.
Problem statement
A food delivery app shows the customer's profile (name, address) on every screen. Loading it from the database every time is slow, so the server keeps recently used profiles in memory. Memory is limited: the cache can hold only a fixed number of profiles. When it's full and a new profile comes in, the one that was used least recently goes.
What we are building: a fixed-size, generic LRU cache with Get, Put and Delete, all O(1), plus a version many goroutines can share.
Why it matters: a cache that evicts the wrong item turns hits into database calls; a cache that's O(n) per call becomes the slow part; a cache with a data race corrupts its own list and starts returning wrong values or crashing.
In one line: find any key in O(1), know which key is the oldest in O(1), and move a key to "newest" in O(1).
Requirements
Functional requirements
New(capacity): capacity must be at least 1.Get(key): return the value and mark the key as just used; a missing key is a miss.Put(key, value): add or update; updating also marks the key as just used.- If
Putadds a new key to a full cache, the least recently used key is removed first. Delete(key): remove a key if it's there.- An optional callback is told about every eviction (for metrics or for closing resources).
- Count hits and misses.
Non-functional requirements
| Requirement | What it means here |
|---|---|
| Speed | Get, Put and Delete are O(1), whatever the size |
| Memory | At most capacity entries, ever |
| Correctness under concurrency | Many goroutines can share one cache without breaking the list |
| Reuse | Works for any key and value type |
Assumptions and out of scope
- One process, capacity counted in entries (not bytes). No expiry by time (TTL), no persistence.
What to hand in
- The two data structures and how they point to each other.
- Code for get, put, delete and eviction, plus a thread-safe version.
- Tests for the eviction order, updates, edge cases and concurrency.
Hint 1: why not just a map?
A map finds a key in O(1), but it can't tell you which key is the oldest without scanning everything.
Hint 2: why not just a list?
A list kept in "most recent first" order knows the oldest (the last one), but finding a key in it is O(n).
Hint 3: both
Keep the list for the order and a map from key to list node. The map finds the node; a doubly linked list can unlink that node in O(1) because it knows both neighbours.
Hint 4: is Get a read?
Look at what Get does to the list. Then decide which lock it needs.
Core entities
| Entity | Job in one sentence | Holds | Does |
|---|---|---|---|
node | One entry in the recency list | key, value, prev, next | nothing: data |
Cache[K, V] | The LRU logic, single-threaded | capacity, map key → node, head and tail sentinels, eviction callback, hit and miss counts | Get, Put, Delete, Len, Keys, Stats |
Safe[K, V] | The same cache, shareable between goroutines | a mutex and a Cache | the same methods, each under the lock |
Why the node keeps its key: when we evict the last node, we must also delete it from the map, and the node is the only place that knows which key it belongs to.
Relationships: Cache owns the map and the list; the map's values point into the list. Safe wraps a Cache and adds nothing but the lock.
Diagrams
Class diagram
How to read it: Safe owns one Cache and adds only a mutex. Cache owns its nodes; the map and the list point at the same node objects, so there is one copy of each entry. The arrow from node back to itself is the doubly linked list: every node points to the one before and the one after.
Notation: + is public, - is private. A filled diamond ◆ means owns (the part lives and dies with its owner), a plain arrow means holds a reference to, a dashed arrow means uses, and a dashed arrow with a hollow triangle means implements this interface.
Sequence: a Get through Safe
How to read it: Each vertical dashed line is one object, and time runs downwards. A solid arrow is a call, a dashed arrow is the answer coming back, and the thin bars show when an object is busy. The move to the front happens inside the lock, between mu.Lock() and mu.Unlock(). That is the whole reason Get can't use a read lock: it writes, right there in the middle of the diagram.
Get, then put on a full cache
How to read it: the front of the list is the most recently used, the back is the next to go. Get never searches: the map hands over the node, and moving it is a few pointer changes. Eviction never searches either: the oldest is always tail.prev.
Why Get can't use a read lock
How to read it: a read lock lets many readers in at once, which is only safe if they really just read. Get moves a node, so it writes. Two of them at once corrupt the list.
Design principles
- Pick structures by the operations you need: O(1) lookup → hash map; O(1) "oldest" and O(1) "move to newest" → doubly linked list. Neither alone is enough.
- Sentinels remove special cases: with a dummy head and tail, the list is never empty and every real node has two neighbours, so there are no
nilchecks inunlinkandpushFront. - Separate logic from locking:
Cacheis simple single-threaded code you can test on its own.Safeadds the mutex. Code that only uses a cache from one goroutine doesn't pay for a lock. - Hooks, not hard-coding: the eviction callback lets callers count evictions or close a file without the cache knowing about either.
Design patterns
Decorator (Safe around Cache)
- Why: thread safety is a separate concern from "which key goes".
- What it solves:
Safehas the same methods asCache, adds a lock around each, and passes the call on. - Without it:
mu.Lock()is mixed into every line of the LRU logic, and single-threaded users pay for it. - Trade-off: one more small type, and every new
Cachemethod needs a matchingSafemethod. - Where:
safe.go.
Observer, in its smallest form (the eviction callback)
onEvict is a function the cache calls when an entry leaves. The caller decides what that means (a metric, closing a connection). One listener is enough here, so a plain function is better than a full listener list.
Why not Strategy for the eviction policy?
It's tempting to make LRU one EvictionPolicy among LFU and FIFO. But the policy is the data structure: LRU needs a recency list, LFU needs frequency buckets. A shared interface would hide that and slow the common case. If you need several policies, write several caches behind one small Cache interface.
Complete solution in Go
Project structure
lru/
├── go.mod
├── cache.go node, Cache: the map, the list and the LRU logic
├── safe.go Safe: the same cache behind a mutex
├── cache_test.go tests, including a 10,000-step check against a simple model
└── cmd/demo/main.go a profile cache for a delivery app
Step 1: the cache
Read Put slowly: an existing key is updated and moved; a new key on a full cache first evicts tail.prev, then goes to the front.
// cache.go
package lru
import "fmt"
// node is one entry in the recency list.
type node[K comparable, V any] struct {
key K
val V
prev, next *node[K, V]
}
// Cache is a fixed-size LRU cache: when it's full, the entry nobody has touched
// for the longest time goes. Get and Put are O(1). Not safe for many goroutines: see Safe.
type Cache[K comparable, V any] struct {
cap int
items map[K]*node[K, V] // key -> its node, so we find it in O(1)
head *node[K, V] // sentinel: head.next is the most recently used
tail *node[K, V] // sentinel: tail.prev is the least recently used
onEvict func(K, V)
hits int
misses int
}
// New makes a cache that holds at most capacity entries. onEvict may be nil.
func New[K comparable, V any](capacity int, onEvict func(K, V)) (*Cache[K, V], error) {
if capacity <= 0 {
return nil, fmt.Errorf("capacity must be positive, got %d", capacity)
}
c := &Cache[K, V]{cap: capacity, items: make(map[K]*node[K, V], capacity),
head: &node[K, V]{}, tail: &node[K, V]{}, onEvict: onEvict}
c.head.next, c.tail.prev = c.tail, c.head
return c, nil
}
// Get returns the value and marks it as just used.
func (c *Cache[K, V]) Get(key K) (V, bool) {
n, ok := c.items[key]
if !ok {
c.misses++
var zero V
return zero, false
}
c.hits++
c.moveToFront(n)
return n.val, true
}
// Put adds or updates a value. If the cache is full, the least recently used entry goes first.
func (c *Cache[K, V]) Put(key K, val V) {
if n, ok := c.items[key]; ok {
n.val = val
c.moveToFront(n)
return
}
if len(c.items) == c.cap {
lru := c.tail.prev
c.unlink(lru)
delete(c.items, lru.key)
if c.onEvict != nil {
c.onEvict(lru.key, lru.val)
}
}
n := &node[K, V]{key: key, val: val}
c.items[key] = n
c.pushFront(n)
}
// Delete removes a key; it reports whether the key was there.
func (c *Cache[K, V]) Delete(key K) bool {
n, ok := c.items[key]
if ok {
c.unlink(n)
delete(c.items, key)
}
return ok
}
func (c *Cache[K, V]) Len() int { return len(c.items) }
// Keys lists the keys from most to least recently used. O(n), for tests and debugging.
func (c *Cache[K, V]) Keys() []K {
keys := make([]K, 0, len(c.items))
for n := c.head.next; n != c.tail; n = n.next {
keys = append(keys, n.key)
}
return keys
}
func (c *Cache[K, V]) Stats() (hits, misses int) { return c.hits, c.misses }
// The three list moves. Thanks to the sentinels there are no nil checks.
func (c *Cache[K, V]) unlink(n *node[K, V]) {
n.prev.next, n.next.prev = n.next, n.prev
n.prev, n.next = nil, nil
}
func (c *Cache[K, V]) pushFront(n *node[K, V]) {
n.prev, n.next = c.head, c.head.next
c.head.next.prev = n
c.head.next = n
}
func (c *Cache[K, V]) moveToFront(n *node[K, V]) {
c.unlink(n)
c.pushFront(n)
}
Step 2: the thread-safe wrapper
// safe.go
package lru
import "sync"
// Safe wraps a Cache with a mutex so many goroutines can share it.
// Get moves an entry to the front, so even a "read" writes: it needs the full lock, not RLock.
type Safe[K comparable, V any] struct {
mu sync.Mutex
c *Cache[K, V]
}
func NewSafe[K comparable, V any](capacity int, onEvict func(K, V)) (*Safe[K, V], error) {
c, err := New(capacity, onEvict)
if err != nil {
return nil, err
}
return &Safe[K, V]{c: c}, nil
}
func (s *Safe[K, V]) Get(key K) (V, bool) {
s.mu.Lock()
defer s.mu.Unlock()
return s.c.Get(key)
}
func (s *Safe[K, V]) Put(key K, val V) {
s.mu.Lock()
defer s.mu.Unlock()
s.c.Put(key, val)
}
func (s *Safe[K, V]) Delete(key K) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.c.Delete(key)
}
func (s *Safe[K, V]) Len() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.c.Len()
}
func (s *Safe[K, V]) Keys() []K {
s.mu.Lock()
defer s.mu.Unlock()
return s.c.Keys()
}
func (s *Safe[K, V]) Stats() (hits, misses int) {
s.mu.Lock()
defer s.mu.Unlock()
return s.c.Stats()
}
Step 3: run it
// cmd/demo/main.go
package main
import (
"fmt"
"strings"
"lru"
)
func main() {
evicted := ""
c, _ := lru.New(3, func(k, _ string) { evicted = k })
fmt.Println("profile cache, capacity 3, keys from most to least recent")
show := func(action string) {
note := ""
if evicted != "" {
note = " evicted " + evicted
evicted = ""
}
fmt.Printf("%-26s [%s]%s\n", action, strings.Join(c.Keys(), " "), note)
}
put := func(k, v string) { c.Put(k, v); show("put " + k + " = " + v) }
get := func(k string) {
if v, ok := c.Get(k); ok {
show("get " + k + " -> " + v)
} else {
show("get " + k + " -> miss")
}
}
put("rahim", "Mirpur")
put("nadia", "Dhanmondi")
put("tania", "Banani")
get("rahim")
put("sabbir", "Uttara")
get("nadia")
get("tania")
put("karim", "Gulshan")
put("sabbir", "Bashundhara")
hits, misses := c.Stats()
fmt.Printf("hits %d, misses %d, size %d\n", hits, misses, c.Len())
}
$ go run ./cmd/demo
profile cache, capacity 3, keys from most to least recent
put rahim = Mirpur [rahim]
put nadia = Dhanmondi [nadia rahim]
put tania = Banani [tania nadia rahim]
get rahim -> Mirpur [rahim tania nadia]
put sabbir = Uttara [sabbir rahim tania] evicted nadia
get nadia -> miss [sabbir rahim tania]
get tania -> Banani [tania sabbir rahim]
put karim = Gulshan [karim tania sabbir] evicted rahim
put sabbir = Bashundhara [sabbir karim tania]
hits 2, misses 1, size 3
Reading the output:
- First three puts: each new key goes to the front; rahim, the first one in, is at the back.
- get rahim: a hit, and rahim jumps to the front. Now nadia is the oldest.
- put sabbir: the cache is full, so nadia (the back) is evicted, not rahim. Without that
get, rahim would have gone. - get nadia: a miss; the order doesn't change on a miss.
- put karim: after
get tania, rahim is at the back again, so rahim is evicted. - put sabbir = Bashundhara: an update, not a new key: no eviction, sabbir moves to the front with the new address.
- Stats: two hits (rahim, tania), one miss (nadia).
The other five languages
from __future__ import annotations
import threading
from collections.abc import Callable
class Node[K, V]:
"""One entry in the recency list."""
__slots__ = ("key", "val", "prev", "next")
def __init__(self, key: K | None = None, val: V | None = None):
self.key, self.val = key, val
self.prev: Node[K, V] | None = None
self.next: Node[K, V] | None = None
class Cache[K, V]:
"""Fixed-size LRU cache. get and put are O(1). Not thread-safe: see Safe.
(collections.OrderedDict can do this in a few lines; we build the list by hand to see how.)"""
def __init__(self, capacity: int, on_evict: Callable[[K, V], None] | None = None):
if capacity <= 0:
raise ValueError(f"capacity must be positive, got {capacity}")
self.cap, self.on_evict = capacity, on_evict
self.items: dict[K, Node[K, V]] = {}
self.head: Node[K, V] = Node() # sentinel: head.next is the most recently used
self.tail: Node[K, V] = Node() # sentinel: tail.prev is the least recently used
self.head.next, self.tail.prev = self.tail, self.head
self.hits = self.misses = 0
def get(self, key: K) -> V | None:
n = self.items.get(key)
if n is None:
self.misses += 1
return None
self.hits += 1
self._move_to_front(n)
return n.val
def put(self, key: K, val: V) -> None:
n = self.items.get(key)
if n is not None:
n.val = val
self._move_to_front(n)
return
if len(self.items) == self.cap:
lru = self.tail.prev
assert lru is not None
self._unlink(lru)
del self.items[lru.key] # type: ignore[arg-type]
if self.on_evict:
self.on_evict(lru.key, lru.val) # type: ignore[arg-type]
n = Node(key, val)
self.items[key] = n
self._push_front(n)
def delete(self, key: K) -> bool:
n = self.items.pop(key, None)
if n is not None:
self._unlink(n)
return n is not None
def __len__(self) -> int:
return len(self.items)
def keys(self) -> list[K]:
"""Most to least recently used. O(n), for tests and debugging."""
out, n = [], self.head.next
while n is not self.tail:
out.append(n.key)
n = n.next
return out
# The three list moves. Thanks to the sentinels there are no None checks.
def _unlink(self, n: Node[K, V]) -> None:
n.prev.next, n.next.prev = n.next, n.prev # type: ignore[union-attr]
n.prev = n.next = None
def _push_front(self, n: Node[K, V]) -> None:
n.prev, n.next = self.head, self.head.next
self.head.next.prev = n # type: ignore[union-attr]
self.head.next = n
def _move_to_front(self, n: Node[K, V]) -> None:
self._unlink(n)
self._push_front(n)
class Safe[K, V]:
"""Wraps a Cache with a lock. get moves an entry, so even a read needs the lock."""
def __init__(self, capacity: int, on_evict: Callable[[K, V], None] | None = None):
self._c: Cache[K, V] = Cache(capacity, on_evict)
self._lock = threading.Lock()
def get(self, key: K) -> V | None:
with self._lock:
return self._c.get(key)
def put(self, key: K, val: V) -> None:
with self._lock:
self._c.put(key, val)
def delete(self, key: K) -> bool:
with self._lock:
return self._c.delete(key)
def keys(self) -> list[K]:
with self._lock:
return self._c.keys()
def main() -> None:
evicted = [""]
def remember(k: str, _: str) -> None:
evicted[0] = k
c: Cache[str, str] = Cache(3, remember)
print("profile cache, capacity 3, keys from most to least recent")
def show(action: str) -> None:
note = f" evicted {evicted[0]}" if evicted[0] else ""
evicted[0] = ""
print(f"{action:<26} [{' '.join(c.keys())}]{note}")
def put(k: str, v: str) -> None:
c.put(k, v)
show(f"put {k} = {v}")
def get(k: str) -> None:
v = c.get(k)
show(f"get {k} -> {v if v is not None else 'miss'}")
put("rahim", "Mirpur")
put("nadia", "Dhanmondi")
put("tania", "Banani")
get("rahim")
put("sabbir", "Uttara")
get("nadia")
get("tania")
put("karim", "Gulshan")
put("sabbir", "Bashundhara")
print(f"hits {c.hits}, misses {c.misses}, size {len(c)}")
if __name__ == "__main__":
main()// A JS Map remembers insertion order, so delete + set can fake LRU in a few lines.
// We build the list by hand to see how it works. (The names are LruCache and Entry
// because browsers already have global Cache and Node types.)
class Entry<K, V> {
key: K; val: V;
prev: Entry<K, V> | null = null;
next: Entry<K, V> | null = null;
constructor(key: K, val: V) { this.key = key; this.val = val; }
}
/** Fixed-size LRU cache. get and put are O(1). */
class LruCache<K, V> {
private items = new Map<K, Entry<K, V>>();
private head: Entry<K, V>; // sentinel: head.next is the most recently used
private tail: Entry<K, V>; // sentinel: tail.prev is the least recently used
private cap: number;
private onEvict?: (k: K, v: V) => void;
hits = 0;
misses = 0;
constructor(capacity: number, onEvict?: (k: K, v: V) => void) {
if (capacity <= 0) throw new Error(`capacity must be positive, got ${capacity}`);
this.cap = capacity;
this.onEvict = onEvict;
this.head = new Entry<K, V>(undefined as K, undefined as V);
this.tail = new Entry<K, V>(undefined as K, undefined as V);
this.head.next = this.tail;
this.tail.prev = this.head;
}
get(key: K): V | undefined {
const n = this.items.get(key);
if (!n) { this.misses++; return undefined; }
this.hits++;
this.moveToFront(n);
return n.val;
}
put(key: K, val: V): void {
const found = this.items.get(key);
if (found) { found.val = val; this.moveToFront(found); return; }
if (this.items.size === this.cap) {
const lru = this.tail.prev!;
this.unlink(lru);
this.items.delete(lru.key);
this.onEvict?.(lru.key, lru.val);
}
const n = new Entry(key, val);
this.items.set(key, n);
this.pushFront(n);
}
delete(key: K): boolean {
const n = this.items.get(key);
if (!n) return false;
this.unlink(n);
this.items.delete(key);
return true;
}
get size(): number { return this.items.size; }
/** Most to least recently used. O(n), for tests and debugging. */
keys(): K[] {
const out: K[] = [];
for (let n = this.head.next!; n !== this.tail; n = n.next!) out.push(n.key);
return out;
}
// The three list moves. Thanks to the sentinels there are no null checks.
private unlink(n: Entry<K, V>): void {
n.prev!.next = n.next;
n.next!.prev = n.prev;
n.prev = n.next = null;
}
private pushFront(n: Entry<K, V>): void {
n.prev = this.head;
n.next = this.head.next;
this.head.next!.prev = n;
this.head.next = n;
}
private moveToFront(n: Entry<K, V>): void { this.unlink(n); this.pushFront(n); }
}
// One thread runs JavaScript, so there's no Safe wrapper: a method can't be interrupted halfway.
function main(): void {
let evicted = "";
const c = new LruCache<string, string>(3, (k) => { evicted = k; });
console.log("profile cache, capacity 3, keys from most to least recent");
const show = (action: string) => {
const note = evicted ? ` evicted ${evicted}` : "";
evicted = "";
console.log(`${action.padEnd(26)} [${c.keys().join(" ")}]${note}`);
};
const put = (k: string, v: string) => { c.put(k, v); show(`put ${k} = ${v}`); };
const get = (k: string) => { const v = c.get(k); show(`get ${k} -> ${v ?? "miss"}`); };
put("rahim", "Mirpur");
put("nadia", "Dhanmondi");
put("tania", "Banani");
get("rahim");
put("sabbir", "Uttara");
get("nadia");
get("tania");
put("karim", "Gulshan");
put("sabbir", "Bashundhara");
console.log(`hits ${c.hits}, misses ${c.misses}, size ${c.size}`);
}
main();use std::collections::HashMap;
use std::hash::Hash;
use std::sync::Mutex;
// A doubly linked list with pointers both ways fights Rust's ownership rules. So the nodes
// live in a Vec and point to each other by index. Slot 0 is the head sentinel, slot 1 the tail.
const HEAD: usize = 0;
const TAIL: usize = 1;
struct Node<K, V> {
key: Option<K>, // None only for the two sentinels
val: Option<V>,
prev: usize,
next: usize,
}
/// Fixed-size LRU cache. get and put are O(1). Not thread-safe on its own: see Safe.
struct Cache<K, V> {
cap: usize,
items: HashMap<K, usize>, // key -> slot in `nodes`
nodes: Vec<Node<K, V>>,
free: Vec<usize>, // slots freed by delete, reused by put
hits: u32,
misses: u32,
}
impl<K: Hash + Eq + Clone, V: Clone> Cache<K, V> {
fn new(cap: usize) -> Result<Self, String> {
if cap == 0 {
return Err("capacity must be positive, got 0".into());
}
let nodes = vec![
Node { key: None, val: None, prev: HEAD, next: TAIL },
Node { key: None, val: None, prev: HEAD, next: TAIL },
];
Ok(Cache { cap, items: HashMap::new(), nodes, free: vec![], hits: 0, misses: 0 })
}
fn get(&mut self, key: &K) -> Option<V> {
let Some(&i) = self.items.get(key) else {
self.misses += 1;
return None;
};
self.hits += 1;
self.unlink(i);
self.push_front(i);
self.nodes[i].val.clone()
}
/// Adds or updates. Returns the evicted entry, if the cache was full.
fn put(&mut self, key: K, val: V) -> Option<(K, V)> {
if let Some(&i) = self.items.get(&key) {
self.nodes[i].val = Some(val);
self.unlink(i);
self.push_front(i);
return None;
}
let mut evicted = None;
if self.items.len() == self.cap {
let lru = self.nodes[TAIL].prev;
self.unlink(lru);
let (k, v) = (self.nodes[lru].key.take().unwrap(), self.nodes[lru].val.take().unwrap());
self.items.remove(&k);
self.free.push(lru);
evicted = Some((k, v));
}
let node = Node { key: Some(key.clone()), val: Some(val), prev: HEAD, next: TAIL };
let i = match self.free.pop() {
Some(i) => {
self.nodes[i] = node;
i
}
None => {
self.nodes.push(node);
self.nodes.len() - 1
}
};
self.items.insert(key, i);
self.push_front(i);
evicted
}
#[allow(dead_code)]
fn delete(&mut self, key: &K) -> bool {
let Some(i) = self.items.remove(key) else { return false };
self.unlink(i);
self.nodes[i].key = None;
self.nodes[i].val = None;
self.free.push(i);
true
}
fn len(&self) -> usize {
self.items.len()
}
/// Most to least recently used. O(n), for tests and debugging.
fn keys(&self) -> Vec<K> {
let mut out = vec![];
let mut i = self.nodes[HEAD].next;
while i != TAIL {
out.push(self.nodes[i].key.clone().unwrap());
i = self.nodes[i].next;
}
out
}
// The two list moves, with indices instead of pointers.
fn unlink(&mut self, i: usize) {
let (p, n) = (self.nodes[i].prev, self.nodes[i].next);
self.nodes[p].next = n;
self.nodes[n].prev = p;
}
fn push_front(&mut self, i: usize) {
let first = self.nodes[HEAD].next;
self.nodes[i].prev = HEAD;
self.nodes[i].next = first;
self.nodes[first].prev = i;
self.nodes[HEAD].next = i;
}
}
/// Shares a Cache between threads. get moves an entry, so even a read takes the lock
/// (and Rust won't let you call get through a shared reference without one).
#[allow(dead_code)]
struct Safe<K, V>(Mutex<Cache<K, V>>);
#[allow(dead_code)]
impl<K: Hash + Eq + Clone, V: Clone> Safe<K, V> {
fn get(&self, key: &K) -> Option<V> {
self.0.lock().unwrap().get(key)
}
fn put(&self, key: K, val: V) -> Option<(K, V)> {
self.0.lock().unwrap().put(key, val)
}
}
fn main() {
let mut c: Cache<String, String> = Cache::new(3).unwrap();
println!("profile cache, capacity 3, keys from most to least recent");
fn show(c: &Cache<String, String>, action: String, evicted: Option<(String, String)>) {
let note = evicted.map(|(k, _)| format!(" evicted {k}")).unwrap_or_default();
println!("{action:<26} [{}]{note}", c.keys().join(" "));
}
let put = |c: &mut Cache<String, String>, k: &str, v: &str| {
let ev = c.put(k.to_string(), v.to_string());
show(c, format!("put {k} = {v}"), ev);
};
let get = |c: &mut Cache<String, String>, k: &str| {
let v = c.get(&k.to_string()).unwrap_or("miss".into());
show(c, format!("get {k} -> {v}"), None);
};
put(&mut c, "rahim", "Mirpur");
put(&mut c, "nadia", "Dhanmondi");
put(&mut c, "tania", "Banani");
get(&mut c, "rahim");
put(&mut c, "sabbir", "Uttara");
get(&mut c, "nadia");
get(&mut c, "tania");
put(&mut c, "karim", "Gulshan");
put(&mut c, "sabbir", "Bashundhara");
println!("hits {}, misses {}, size {}", c.hits, c.misses, c.len());
}import java.util.*;
import java.util.function.BiConsumer;
// java.util.LinkedHashMap(cap, 0.75f, true) with removeEldestEntry is a ready-made LRU.
// We build the list by hand to see how it works.
public class Main {
static final class Node<K, V> {
K key;
V val;
Node<K, V> prev, next;
Node(K key, V val) { this.key = key; this.val = val; }
}
/** Fixed-size LRU cache. get and put are O(1). Not thread-safe: see Safe. */
static class Cache<K, V> {
private final int cap;
private final Map<K, Node<K, V>> items = new HashMap<>();
private final Node<K, V> head = new Node<>(null, null); // head.next = most recently used
private final Node<K, V> tail = new Node<>(null, null); // tail.prev = least recently used
private final BiConsumer<K, V> onEvict;
int hits, misses;
Cache(int capacity, BiConsumer<K, V> onEvict) {
if (capacity <= 0) throw new IllegalArgumentException("capacity must be positive, got " + capacity);
this.cap = capacity;
this.onEvict = onEvict;
head.next = tail;
tail.prev = head;
}
Optional<V> get(K key) {
Node<K, V> n = items.get(key);
if (n == null) { misses++; return Optional.empty(); }
hits++;
moveToFront(n);
return Optional.of(n.val);
}
void put(K key, V val) {
Node<K, V> n = items.get(key);
if (n != null) { n.val = val; moveToFront(n); return; }
if (items.size() == cap) {
Node<K, V> lru = tail.prev;
unlink(lru);
items.remove(lru.key);
if (onEvict != null) onEvict.accept(lru.key, lru.val);
}
n = new Node<>(key, val);
items.put(key, n);
pushFront(n);
}
boolean delete(K key) {
Node<K, V> n = items.remove(key);
if (n != null) unlink(n);
return n != null;
}
int size() { return items.size(); }
/** Most to least recently used. O(n), for tests and debugging. */
List<K> keys() {
List<K> out = new ArrayList<>();
for (Node<K, V> n = head.next; n != tail; n = n.next) out.add(n.key);
return out;
}
// The three list moves. Thanks to the sentinels there are no null checks.
private void unlink(Node<K, V> n) {
n.prev.next = n.next;
n.next.prev = n.prev;
n.prev = n.next = null;
}
private void pushFront(Node<K, V> n) {
n.prev = head;
n.next = head.next;
head.next.prev = n;
head.next = n;
}
private void moveToFront(Node<K, V> n) { unlink(n); pushFront(n); }
}
/** Wraps a Cache with a lock. get moves an entry, so even a read is synchronized. */
static final class Safe<K, V> {
private final Cache<K, V> c;
Safe(int capacity, BiConsumer<K, V> onEvict) { c = new Cache<>(capacity, onEvict); }
synchronized Optional<V> get(K key) { return c.get(key); }
synchronized void put(K key, V val) { c.put(key, val); }
synchronized boolean delete(K key) { return c.delete(key); }
synchronized List<K> keys() { return c.keys(); }
}
static String evicted = "";
static Cache<String, String> c;
static void show(String action) {
String note = evicted.isEmpty() ? "" : " evicted " + evicted;
evicted = "";
System.out.printf("%-26s [%s]%s%n", action, String.join(" ", c.keys()), note);
}
static void put(String k, String v) { c.put(k, v); show("put " + k + " = " + v); }
static void get(String k) { show("get " + k + " -> " + c.get(k).orElse("miss")); }
public static void main(String[] args) {
c = new Cache<>(3, (k, v) -> evicted = k);
System.out.println("profile cache, capacity 3, keys from most to least recent");
put("rahim", "Mirpur");
put("nadia", "Dhanmondi");
put("tania", "Banani");
get("rahim");
put("sabbir", "Uttara");
get("nadia");
get("tania");
put("karim", "Gulshan");
put("sabbir", "Bashundhara");
System.out.printf("hits %d, misses %d, size %d%n", c.hits, c.misses, c.size());
}
}#include <cstdio>
#include <functional>
#include <list>
#include <mutex>
#include <optional>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
// Fixed-size LRU cache. get and put are O(1). Not thread-safe: see Safe.
// std::list is already a doubly linked list, and splice moves a node to the front in O(1)
// without copying it, so the map can keep iterators into the list.
template <typename K, typename V>
class Cache {
public:
using OnEvict = std::function<void(const K&, const V&)>;
explicit Cache(int capacity, OnEvict onEvict = nullptr) : cap_(capacity), onEvict_(std::move(onEvict)) {
if (capacity <= 0) throw std::invalid_argument("capacity must be positive, got " + std::to_string(capacity));
}
std::optional<V> get(const K& key) {
auto it = items_.find(key);
if (it == items_.end()) {
misses++;
return std::nullopt;
}
hits++;
order_.splice(order_.begin(), order_, it->second); // move to front
return it->second->second;
}
void put(const K& key, V val) {
if (auto it = items_.find(key); it != items_.end()) {
it->second->second = std::move(val);
order_.splice(order_.begin(), order_, it->second);
return;
}
if (static_cast<int>(items_.size()) == cap_) {
auto& [k, v] = order_.back(); // least recently used
if (onEvict_) onEvict_(k, v);
items_.erase(k);
order_.pop_back();
}
order_.emplace_front(key, std::move(val));
items_[key] = order_.begin();
}
bool remove(const K& key) {
auto it = items_.find(key);
if (it == items_.end()) return false;
order_.erase(it->second);
items_.erase(it);
return true;
}
size_t size() const { return items_.size(); }
// Most to least recently used. O(n), for tests and debugging.
std::vector<K> keys() const {
std::vector<K> out;
for (auto& [k, _] : order_) out.push_back(k);
return out;
}
int hits = 0, misses = 0;
private:
int cap_;
OnEvict onEvict_;
std::list<std::pair<K, V>> order_; // front = most recently used
std::unordered_map<K, typename std::list<std::pair<K, V>>::iterator> items_;
};
// Wraps a Cache with a mutex. get moves an entry, so even a read takes the lock
// (a shared_mutex with shared_lock in get would be a data race).
template <typename K, typename V>
class Safe {
public:
explicit Safe(int capacity) : c_(capacity) {}
std::optional<V> get(const K& key) {
std::lock_guard lock(mu_);
return c_.get(key);
}
void put(const K& key, V val) {
std::lock_guard lock(mu_);
c_.put(key, std::move(val));
}
private:
std::mutex mu_;
Cache<K, V> c_;
};
std::string join(const std::vector<std::string>& v) {
std::string out;
for (size_t i = 0; i < v.size(); i++) out += (i ? " " : "") + v[i];
return out;
}
int main() {
std::string evicted;
Cache<std::string, std::string> c(3, [&](const std::string& k, const std::string&) { evicted = k; });
std::printf("profile cache, capacity 3, keys from most to least recent\n");
auto show = [&](const std::string& action) {
std::string note = evicted.empty() ? "" : " evicted " + evicted;
evicted.clear();
std::printf("%-26s [%s]%s\n", action.c_str(), join(c.keys()).c_str(), note.c_str());
};
auto put = [&](const std::string& k, const std::string& v) {
c.put(k, v);
show("put " + k + " = " + v);
};
auto get = [&](const std::string& k) {
auto v = c.get(k);
show("get " + k + " -> " + v.value_or("miss"));
};
put("rahim", "Mirpur");
put("nadia", "Dhanmondi");
put("tania", "Banani");
get("rahim");
put("sabbir", "Uttara");
get("nadia");
get("tania");
put("karim", "Gulshan");
put("sabbir", "Bashundhara");
std::printf("hits %d, misses %d, size %zu\n", c.hits, c.misses, c.size());
}What changes from language to language: Python, TypeScript and Java build the same list by hand, but each has a shortcut for real code: OrderedDict.move_to_end, a Map (which keeps insertion order, so delete and set again), and LinkedHashMap with access order. C++ uses std::list, whose splice moves a node to the front without copying, so the map can store list iterators. Rust is the interesting one: a list where nodes point both ways fights the borrow checker, so the nodes live in a Vec and point to each other by index, and put returns the evicted entry instead of calling a callback. TypeScript names its classes LruCache and Entry because browsers already have global Cache and Node types.
Testing
// cache_test.go
package lru
import (
"fmt"
"math/rand"
"slices"
"sync"
"testing"
)
func newCache(t *testing.T, capacity int) (*Cache[string, int], *[]string) {
var evicted []string
c, err := New(capacity, func(k string, _ int) { evicted = append(evicted, k) })
if err != nil {
t.Fatal(err)
}
return c, &evicted
}
func TestEvictsLeastRecentlyUsed(t *testing.T) {
c, evicted := newCache(t, 2)
c.Put("a", 1)
c.Put("b", 2)
c.Put("c", 3) // a is the oldest
if _, ok := c.Get("a"); ok || !slices.Equal(*evicted, []string{"a"}) {
t.Fatalf("a should be gone, evicted %v", *evicted)
}
}
func TestGetMakesAnEntryRecent(t *testing.T) {
c, evicted := newCache(t, 2)
c.Put("a", 1)
c.Put("b", 2)
c.Get("a") // now b is the oldest
c.Put("c", 3) // so b goes, not a
if !slices.Equal(*evicted, []string{"b"}) || !slices.Equal(c.Keys(), []string{"c", "a"}) {
t.Fatalf("evicted %v, keys %v", *evicted, c.Keys())
}
}
func TestUpdateDoesNotGrow(t *testing.T) {
c, evicted := newCache(t, 2)
c.Put("a", 1)
c.Put("b", 2)
c.Put("a", 10)
if v, _ := c.Get("a"); v != 10 || c.Len() != 2 || len(*evicted) != 0 {
t.Fatalf("v %d, len %d, evicted %v", v, c.Len(), *evicted)
}
}
func TestCapacityOne(t *testing.T) {
c, _ := newCache(t, 1)
c.Put("a", 1)
c.Put("b", 2)
if !slices.Equal(c.Keys(), []string{"b"}) {
t.Fatalf("keys %v", c.Keys())
}
}
func TestBadCapacityRefused(t *testing.T) {
for _, n := range []int{0, -1} {
if _, err := New[string, int](n, nil); err == nil {
t.Fatalf("capacity %d should be refused", n)
}
}
}
func TestDeleteKeepsListConsistent(t *testing.T) {
c, _ := newCache(t, 3)
c.Put("a", 1)
c.Put("b", 2)
c.Put("c", 3)
if !c.Delete("b") || c.Delete("b") {
t.Fatal("first delete true, second false")
}
c.Put("d", 4)
if !slices.Equal(c.Keys(), []string{"d", "c", "a"}) {
t.Fatalf("keys %v", c.Keys())
}
}
// 10,000 random operations, checked against a slow but obviously correct model:
// a slice with the most recent key first.
func TestMatchesSimpleModel(t *testing.T) {
c, _ := newCache(t, 4)
var model []string
r := rand.New(rand.NewSource(1))
for i := range 10_000 {
k := fmt.Sprint(r.Intn(8))
at := slices.Index(model, k)
switch r.Intn(3) {
case 0: // get
_, ok := c.Get(k)
if ok != (at >= 0) {
t.Fatalf("op %d: get %s ok=%v", i, k, ok)
}
if ok {
model = append([]string{k}, slices.Delete(model, at, at+1)...)
}
case 1: // put
c.Put(k, i)
if at >= 0 {
model = slices.Delete(model, at, at+1)
} else if len(model) == 4 {
model = model[:3]
}
model = append([]string{k}, model...)
case 2: // delete
c.Delete(k)
if at >= 0 {
model = slices.Delete(model, at, at+1)
}
}
if !slices.Equal(c.Keys(), model) {
t.Fatalf("op %d: cache %v, model %v", i, c.Keys(), model)
}
}
}
// Many goroutines share one Safe cache. Run with -race.
func TestSafeUnderConcurrency(t *testing.T) {
s, _ := NewSafe[int, int](50, nil)
var wg sync.WaitGroup
for g := range 8 {
wg.Add(1)
go func() {
defer wg.Done()
for i := range 2000 {
k := (g*7 + i) % 100
if i%3 == 0 {
s.Put(k, i)
} else {
s.Get(k)
}
}
}()
}
wg.Wait()
keys := s.Keys()
slices.Sort(keys)
if s.Len() > 50 || len(keys) != s.Len() || len(slices.Compact(keys)) != len(keys) {
t.Fatalf("len %d, keys %d", s.Len(), len(keys))
}
if hits, misses := s.Stats(); hits+misses != 8*1333 { // 1333 gets per goroutine
t.Fatalf("counted %d gets, want %d", hits+misses, 8*1333)
}
}
$ go test -race -v ./...
=== RUN TestEvictsLeastRecentlyUsed
--- PASS: TestEvictsLeastRecentlyUsed (0.00s)
=== RUN TestGetMakesAnEntryRecent
--- PASS: TestGetMakesAnEntryRecent (0.00s)
=== RUN TestUpdateDoesNotGrow
--- PASS: TestUpdateDoesNotGrow (0.00s)
=== RUN TestCapacityOne
--- PASS: TestCapacityOne (0.00s)
=== RUN TestBadCapacityRefused
--- PASS: TestBadCapacityRefused (0.00s)
=== RUN TestDeleteKeepsListConsistent
--- PASS: TestDeleteKeepsListConsistent (0.00s)
=== RUN TestMatchesSimpleModel
--- PASS: TestMatchesSimpleModel (0.03s)
=== RUN TestSafeUnderConcurrency
--- PASS: TestSafeUnderConcurrency (0.02s)
PASS
ok lru 1.067s
? lru/cmd/demo [no test files]
| Test | Checks |
|---|---|
TestEvictsLeastRecentlyUsed | With capacity 2, adding a third key evicts the first |
TestGetMakesAnEntryRecent | A Get saves a key from eviction |
TestUpdateDoesNotGrow | Putting an existing key updates it without evicting |
TestCapacityOne | The smallest cache still works |
TestBadCapacityRefused | Capacity 0 and -1 are refused |
TestDeleteKeepsListConsistent | After a delete, the list order and later puts are still right |
TestMatchesSimpleModel | 10,000 random gets, puts and deletes give the same order as a plain slice |
TestSafeUnderConcurrency | 8 goroutines share a Safe cache: size stays within capacity, no duplicate keys, every get is counted |
The model test is the strongest one: the slice version is slow but obviously correct, so if the fast version ever disagrees, the fast version has a bug.
We also changed Safe to use a sync.RWMutex with RLock in Get, the mistake from the second diagram. The race detector reported data races on every run (12 to 19 in our runs), in Get, unlink and pushFront, and the test failed.
Design review
What's good: every operation is O(1); sentinels keep the list code short; the LRU logic is single-threaded and fully tested on its own; the lock lives in one thin wrapper.
| "What if…" | What to change |
|---|---|
| One lock is too slow (many cores) | Shard: N caches, each with its own lock, and pick the shard by hashing the key. Eviction becomes "LRU per shard", which is close enough in practice. |
| Entries should expire | Store an expiry time in each node; treat an expired entry as a miss in Get and remove it. |
| Limit by bytes, not count | Each entry reports its size; Put evicts from the back until the new entry fits. |
| The callback calls the cache | Under Safe that deadlocks, because the lock is already held. Collect evicted entries and call the callback after unlocking. |
| Scans flush the cache (one big report touches everything once) | Use a policy that resists scans, like 2Q or segmented LRU: new keys go to a probation list first. |
| Many servers | Use a shared cache (Redis) or accept that each server has its own; this cache stays the per-process layer. |
Interview questions
Practice questions
Beginner
- Why can't a hash map alone do LRU in O(1)?
- What does "least recently used" mean, in terms of the list?
Intermediate
- Why a doubly linked list and not a singly linked one?
- What are sentinel nodes, and what do they save you?
Advanced
- Why does
Getneed a full lock rather than a read lock? - Your cache's lock is the bottleneck on a 32-core machine. What do you do?
Common design mistakes
- Keeping a timestamp per key and scanning for the oldest: O(n) eviction.
- A singly linked list: unlinking a node needs its previous node, which means a search.
- Forgetting to update the order on
Get, which turns LRU into FIFO. - Not storing the key in the node, so eviction can't delete it from the map.
RLockinGet, because "it's just a read".
Answers
1. Why not just a map?
A map has no order. To find the least recently used key you'd keep a timestamp per key and scan them all: O(n) per eviction.
2. LRU in list terms?
Every use moves a key to the front, so the back of the list is the key that has gone longest without being used. That's the one evicted.
3. Why doubly linked?
To unlink a node you must change its previous node's next. With a prev pointer that's O(1); in a singly linked list you'd have to walk from the head to find it.
4. Sentinels?
A dummy head and tail that are always there. Every real node then has both neighbours, so insert and unlink have no "is the list empty?" or "is this the first node?" branches.
5. Full lock for Get?
Get moves the node to the front, which rewrites several pointers. A read lock lets two Gets do that at once, and their pointer writes interleave and break the list. Our test showed 12 to 19 data races with RLock.
6. Lock is the bottleneck?
Split the cache into shards (say 16), each with its own lock, chosen by the key's hash. Threads touching different shards don't wait for each other. You lose exact global LRU order, which rarely matters.
What interviewers look for: the map + doubly linked list combination explained before coding, O(1) everything, the key stored in the node, clean pointer code (sentinels help), and the read-lock trap.
Designed it yourself and understood the solution? Mark it to track your progress.