Build a Concurrent LRU Cache
Build a Concurrent LRU Cache (+ a CAS Counter)
You already know how to build an LRU cache: hash map for O(1) lookup, doubly-linked list for O(1) move-to-front and evict-tail. That's the easy 80%. The interview — and the production incident — lives in the other 20%: an LRU cache is almost always shared by many threads, and its signature move, "touching an entry makes it most-recently-used," is a write to the recency list even when the caller only asked to read a value. Get that one fact wrong and your "thread-safe" cache serializes every reader in your fleet behind a single lock, and your throughput graph goes flat no matter how many cores you throw at it. This lab builds a coarse-lock LRU, breaks it under read-heavy load, then fixes it two different ways — sharding and a lock-free CLOCK/second-chance design — in both Java and Go, plus a hand-rolled CAS counter as the atomic-primitives companion piece. See also: Caching — LRU Lock Contention & Redis Ops for the production/Redis-scale version of exactly this problem, and Build an LRU + TTL Cache if you haven't built the single-threaded version yet — do that one first if the O(1) mechanics themselves are new.
1. The trap
Say you ship an in-process LRU cache for a hot, read-heavy lookup — feature flags, a routing table, a small config blob — read by 8+ threads per instance, written rarely. You do the responsible thing: wrap the whole cache in one lock so it's provably thread-safe. Code review approves it. Then someone benchmarks it under load:
threads=1 ~32,000,000 gets/sec
threads=2 ~12,000,000 gets/sec <- adding a thread made it WORSE
threads=4 ~ 9,000,000 gets/sec
threads=8 ~ 9,800,000 gets/sec <- flat. 8x the threads, ~0.3x the per-thread throughput.
(Real numbers from Movement 5's benchmark, not illustrative — you'll reproduce them yourself.) This is not a GC pause, not a bad benchmark, not "Java is slow." It's structural: a coarse lock around an LRU cache makes every get() take the exact same exclusive lock as every put(), because get() silently mutates the recency list (move-to-front). You didn't write a read-write lock by mistake — you wrote a cache where there is no such thing as a pure read. Adding reader threads doesn't add parallelism, it adds a longer line at the same door. This lab is about seeing that door, and then removing it.
2. Scope it like a senior
Before touching a lock, pin down what you're actually building. A candidate who reaches for synchronized immediately is the one who solves the wrong problem correctly. Ask:
- What's the read:write ratio? A cache that's 95%+ reads (routing tables, feature flags, hot rows) has a completely different optimal design than one that's 50/50 (a session cache under steady churn). This lab assumes read-heavy, because that's where the coarse-lock trap actually bites — and where the fix pays off.
- Does eviction need to be EXACTLY least-recently-used, globally? Or is "approximately least-recently-used, good enough to keep the hit rate high" acceptable? This is the single biggest fork in the design space (Movement 3 and 6 both hinge on it).
- Single process or distributed? This lab is one JVM/one Go binary, in-memory. The distributed version (consistent hashing across cache nodes, Redis's own single-threaded-per-shard model) is a "what breaks at 100x" follow-up (Movement 7), not this build.
- What's the latency budget per op? A cache that occasionally blocks a thread for microseconds under contention is fine for most services; it is not fine on a p99.9-sensitive hot path, which is exactly when you reach for the lock-free design even at some accuracy cost.
- How skewed is the key distribution? A handful of super-hot keys (a "celebrity" row, a global config flag) breaks sharding's assumption of uniform load — worth naming even if you don't solve it here.
Answering these first is what separates "I locked it" from "I understood what needed protecting, and chose the cheapest correct mechanism for this workload."
3. Reason to the design
Attempt 0 — exact LRU, single-threaded. HashMap for O(1) key→node lookup, doubly-linked list for O(1) move-to-front and evict-tail. get(k) looks up the node and splices it to the front; put(k,v) inserts at the front and evicts the tail if over capacity. Correct, fast, and shares zero state safely with anyone — because there's only one thread. This is M1 below.
Attempt 1 — wrap it in one lock (the coarse lock). The obvious next step: put a ReentrantLock/Mutex around the whole cache, take it in both get() and put(). This is correct — no data race, no corrupted list. But look at what you actually locked: get() calls unlink() + pushFront() just like put() does. There is no read-only code path through this cache. So the lock isn't "protecting writes from reads" — every caller, reader or writer, takes the SAME exclusive lock, and they all queue behind each other. This is Movement 1's trap, named precisely: the eviction policy's own bookkeeping turns every read into a write.
Attempt 2 — shard the lock, not the data structure's ordering. If one lock over N keys is the bottleneck, cut it into K locks over N/K keys each: hash the key to a shard, take only that shard's lock. A get() on shard 3 no longer waits behind a put() on shard 7. You give something up: there is no longer one global recency order, only K independent local ones — "least recently used within this shard" instead of "least recently used, period." For a cache (not a strict FIFO/ordering primitive), that's usually a trade worth making, because the thing you actually care about — hit rate — barely moves when you go from one global LRU list to K local ones, as long as key traffic isn't wildly skewed to one shard. This is M3, and it's exactly what ConcurrentHashMap did internally in Java 7 (segment locking) before Java 8 moved to finer-grained bucket locking.
Attempt 3 — stop mutating on read, period. Sharding reduces contention but doesn't remove the root cause: reads still take a lock. The more radical fix asks: what's the minimum information a read needs to leave behind to make eviction reasonable, without touching a shared list? Answer: one bit per entry, "was this used recently?" — set with a single atomic store, no lock, no list surgery, no serialization between readers. Eviction is done lazily by a "clock hand" that sweeps entries: bit set → clear it and give the entry a second chance (it was used since the hand last passed, so don't evict yet); bit clear → evict it (it's been a full sweep since anyone touched it, that's the closest you can get to "least recently used" without an actual list). This is CLOCK / second-chance — the same mechanism behind Linux page replacement (`CLOCK-Pro`), and the eviction core of production caches like Caffeine. It is approximately LRU (a full sweep, not perfect recency order), and that inaccuracy is the price of removing every reader-side lock. This is M4, and it is the answer to "how do I make reads truly not block."
The CAS-counter tie-in. Both the sharded lock (implicitly) and the CLOCK cache (explicitly, for the reference bit) lean on one primitive: compare-and-swap — "update this memory location to next only if it still equals expected; otherwise tell me, so I can retry." That's the same mechanism that makes a lock-free counter possible, which is why this lab includes one: a CasCounter built from a hand-rolled CAS retry loop, next to a deliberately BrokenCounter that uses a plain count++ and reliably loses updates. See also Atomics & Compare-And-Swap (CAS) and CAS & the ABA Problem for the underlying theory this lab exercises.
4. Build it — milestones
Attempt each milestone yourself before reading the reference implementation — the reveal is positioned after the tests on purpose.
- M1 — exact LRU, single-threaded. HashMap + doubly-linked list.
get(k)andput(k,v)both O(1). No thread-safety yet — get this mechanism rock solid first. - M2 — coarse lock (the baseline everyone ships first). One lock around the whole cache. Correct. Prove to yourself it's correct, then go measure it under concurrent read load in Movement 5 and watch it flatline.
- M3 — sharded / striped locks. Partition into K independent shards, each with its own small LRU + its own lock. Contention drops roughly K× for uniformly distributed keys; global LRU order is traded for K local orders.
- M4 — CLOCK / second-chance, lock-free reads. Replace the linked list with a fixed slot array + a per-slot atomic "referenced" bit + a sweeping clock hand.
get()takes no lock at all.put()/eviction takes a lock, but that's the rare path in a read-heavy workload. - Bonus milestone — the CAS counter. A monotonic counter built from a compare-and-swap retry loop instead of a lock or a naive increment. Small, but it's the same primitive M4's reference bit relies on, made visible in isolation.
Reference implementation — Java
M1 — exact LRU (not yet thread-safe):
import java.util.HashMap;
import java.util.Map;
/** M1: exact single-threaded LRU. HashMap for O(1) lookup + a doubly linked
* list for O(1) move-to-front / evict-tail. NOT thread-safe on its own --
* every get() mutates the list (move-to-front), which is the whole trap. */
public final class LRUCache<K, V> {
private static final class Node<K, V> {
K key; V value; Node<K, V> prev, next;
Node(K k, V v) { key = k; value = v; }
}
private final int capacity;
private final Map<K, Node<K, V>> map = new HashMap<K, Node<K, V>>();
private final Node<K, V> head = new Node<K, V>(null, null); // MRU sentinel side
private final Node<K, V> tail = new Node<K, V>(null, null); // LRU sentinel side
public LRUCache(int capacity) {
if (capacity <= 0) throw new IllegalArgumentException("capacity must be > 0");
this.capacity = capacity;
head.next = tail;
tail.prev = head;
}
/** Returns the value, or null on miss. MUTATES the list: moves the node
* to the front. This single fact is Movement 1's trap. */
public V get(K key) {
Node<K, V> n = map.get(key);
if (n == null) return null;
unlink(n);
pushFront(n);
return n.value;
}
public void put(K key, V value) {
Node<K, V> n = map.get(key);
if (n != null) {
n.value = value;
unlink(n);
pushFront(n);
return;
}
if (map.size() == capacity) {
Node<K, V> lru = tail.prev;
unlink(lru);
map.remove(lru.key);
}
Node<K, V> fresh = new Node<K, V>(key, value);
map.put(key, fresh);
pushFront(fresh);
}
public int size() { return map.size(); }
private void unlink(Node<K, V> n) {
n.prev.next = n.next;
n.next.prev = n.prev;
}
private void pushFront(Node<K, V> n) {
n.next = head.next;
n.prev = head;
head.next.prev = n;
head.next = n;
}
}
M2 — coarse lock:
import java.util.concurrent.locks.ReentrantLock;
/** M2: baseline thread-safety. ONE lock guards the whole cache -- correct,
* but every get() takes the SAME exclusive lock as put(), because get()
* mutates the recency list. Reads serialize behind writes AND behind each
* other. This is the "coarse lock" row in the trade-off table. */
public final class CoarseLockLRUCache<K, V> {
private final LRUCache<K, V> inner;
private final ReentrantLock lock = new ReentrantLock();
public CoarseLockLRUCache(int capacity) {
inner = new LRUCache<K, V>(capacity);
}
public V get(K key) {
lock.lock();
try {
return inner.get(key);
} finally {
lock.unlock();
}
}
public void put(K key, V value) {
lock.lock();
try {
inner.put(key, value);
} finally {
lock.unlock();
}
}
public int size() {
lock.lock();
try { return inner.size(); } finally { lock.unlock(); }
}
}
M3 — sharded / striped locks:
import java.util.concurrent.locks.ReentrantLock;
/** M3: sharded / striped locks. Partition the keyspace into N independent
* shards, each its own small LRUCache + its own lock. A get() on shard 3
* no longer blocks a get() on shard 7 -- contention drops ~N x. The price:
* there is no longer ONE global recency order, only N local ones, so
* eviction is "least recently used within this shard," not globally. For
* a cache (not a strict ordering structure) that trade is usually free. */
public final class StripedLRUCache<K, V> {
private final LRUCache<K, V>[] shards;
private final ReentrantLock[] locks;
private final int shardCount;
@SuppressWarnings("unchecked")
public StripedLRUCache(int totalCapacity, int shardCount) {
this.shardCount = shardCount;
shards = new LRUCache[shardCount];
locks = new ReentrantLock[shardCount];
int perShard = Math.max(1, totalCapacity / shardCount);
for (int i = 0; i < shardCount; i++) {
shards[i] = new LRUCache<K, V>(perShard);
locks[i] = new ReentrantLock();
}
}
private int shardFor(K key) {
int h = key.hashCode();
h ^= (h >>> 16);
return Math.abs(h) % shardCount;
}
public V get(K key) {
int s = shardFor(key);
locks[s].lock();
try {
return shards[s].get(key);
} finally {
locks[s].unlock();
}
}
public void put(K key, V value) {
int s = shardFor(key);
locks[s].lock();
try {
shards[s].put(key, value);
} finally {
locks[s].unlock();
}
}
}
M4 — CLOCK / second-chance, lock-free reads (fixed, after an adversarial verify pass caught a real visibility bug — see the code comment):
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicIntegerArray;
import java.util.concurrent.atomic.AtomicReferenceArray;
import java.util.concurrent.locks.ReentrantLock;
/** M4: approximate LRU via CLOCK / second-chance. Avoids the M1-M3 trap
* entirely by never mutating a list on a read. Every slot has one
* "referenced" bit; get() sets it (no lock, no list surgery -- readers
* never block each other or a writer). Eviction is done by a "clock hand"
* that sweeps slots: bit set -> CAS it back to 0 and give the slot a
* second chance; bit clear -> evict this slot. This is the mechanism
* behind Linux page replacement and many production caches (e.g.
* Caffeine's Window-TinyLFU admission + a clock-like eviction). */
public final class ClockCache<K, V> {
private final int capacity;
private final Object[] keys;
// NOT a plain Object[] -- see the note on get()/put() below: a plain
// array here has no happens-before edge to an unsynchronized reader,
// which is a real, verified bug this design hit (Go's -race caught the
// equivalent; Java's memory model makes the same access a silent
// visibility bug instead of a detector hit -- arguably worse, since
// nothing tells you). AtomicReferenceArray gives volatile-style
// get/set per slot with no lock.
private final AtomicReferenceArray<Object> values;
private final AtomicBoolean[] referenced;
private final ConcurrentHashMap<K, Integer> slotOf = new ConcurrentHashMap<K, Integer>();
private final AtomicInteger clockHand = new AtomicInteger(0);
// Only the (rare) eviction/insert path takes this lock; get() never does.
private final ReentrantLock writeLock = new ReentrantLock();
private final AtomicIntegerArray occupied; // 0 = empty, 1 = occupied
public ClockCache(int capacity) {
this.capacity = capacity;
keys = new Object[capacity];
values = new AtomicReferenceArray<Object>(capacity);
referenced = new AtomicBoolean[capacity];
for (int i = 0; i < capacity; i++) referenced[i] = new AtomicBoolean(false);
occupied = new AtomicIntegerArray(capacity);
}
/** Lock-free read path: map lookup + a single "referenced" set + a
* volatile-semantics array get. No list mutation, so concurrent
* readers never contend with each other. */
@SuppressWarnings("unchecked")
public V get(K key) {
Integer slot = slotOf.get(key);
if (slot == null) return null;
referenced[slot].set(true); // "I was used" -- give it a second chance later
return (V) values.get(slot);
}
public void put(K key, V value) {
writeLock.lock();
try {
Integer existing = slotOf.get(key);
if (existing != null) {
values.set(existing, value);
referenced[existing].set(true);
return;
}
int slot = findSlotToUse();
if (occupied.get(slot) == 1) {
slotOf.remove(keys[slot]);
}
keys[slot] = key;
values.set(slot, value);
occupied.set(slot, 1);
referenced[slot].set(true);
slotOf.put(key, slot);
} finally {
writeLock.unlock();
}
}
/** The clock sweep: advance the hand; a set bit gets one CAS'd-off
* second chance, a clear bit is the victim. compareAndSet is what
* makes "clear the bit" safe even though a concurrent get() might be
* racing to set it back to true at the same instant. */
private int findSlotToUse() {
for (int i = 0; i < capacity; i++) {
if (occupied.get(i) == 0) return i;
}
while (true) {
int hand = clockHand.getAndUpdate(new java.util.function.IntUnaryOperator() {
public int applyAsInt(int operand) { return (operand + 1) % capacity; }
});
if (referenced[hand].compareAndSet(true, false)) {
continue; // gave it a second chance, move on
}
return hand; // bit was already false -> evict this one
}
}
public int size() { return slotOf.size(); }
}
The CAS counter (the mechanism behind M4's reference bit, isolated). Save as CasCounter.java:
import java.util.concurrent.atomic.AtomicLong;
/** The CAS-counter mini-lesson. increment() below is written out by hand as
* a compare-and-swap RETRY LOOP -- the same pattern AtomicLong.incrementAndGet
* uses internally -- so you can see the mechanism, not just call a library
* method. Contrast with BrokenCounter, which loses updates. */
public final class CasCounter {
private final AtomicLong value = new AtomicLong(0);
/** Hand-rolled CAS increment: read, compute, compareAndSet; on failure
* (another thread won the race), retry with a fresh read. No lock. */
public long increment() {
while (true) {
long current = value.get();
long next = current + 1;
if (value.compareAndSet(current, next)) {
return next;
}
// else: someone else updated `value` between get() and
// compareAndSet() -- loop and try again with the new current.
}
}
public long get() { return value.get(); }
/** ABA note (not exercised by the counter, which is monotonic and thus
* ABA-immune): compareAndSet(expected, next) only checks that the VALUE
* is still `expected` -- if another thread changed it away and back to
* the same value in between (A -> B -> A), your CAS still "succeeds"
* even though the world moved. Harmless for a monotonically increasing
* counter, but a real bug for CAS-based stacks/free-lists, where a
* reused node pointer can look identical after a pop-push-pop cycle.
* Fix: pair the value with a version/stamp (AtomicStampedReference) so
* A-v1 -> B -> A-v2 is distinguishable from the original A-v1. */
}
The bug CasCounter fixes, for contrast. Save as BrokenCounter.java:
/** The bug CasCounter fixes: a plain long, incremented with `count++`.
* `++` is read-modify-write, NOT atomic -- two threads can both read the
* same value, both add 1, both write the same result back. One increment
* is lost, silently, no exception. */
public final class BrokenCounter {
private long count = 0;
public void increment() {
count++; // BUG: read-modify-write, not atomic -- lost updates under contention
}
public long get() { return count; }
}
All four classes plus the two demo/benchmark mains compile clean under javac (JDK 8) with zero warnings.
Reference implementation — Go
M1 — exact LRU (container/list + a map, generic):
// Package lrucache builds a concurrent LRU cache from scratch, in stages:
// M1 exact single-goroutine LRU -> M2 coarse-lock thread-safety -> M3
// sharded/striped locks -> M4 approximate CLOCK cache that never mutates
// on a read.
package lrucache
import "container/list"
type entry[K comparable, V any] struct {
key K
value V
}
// LRUCache is M1: exact LRU, map + doubly linked list, O(1) get/put.
// NOT goroutine-safe on its own -- every Get mutates the list (move to
// front), which is the whole trap this kata is about.
type LRUCache[K comparable, V any] struct {
capacity int
ll *list.List
items map[K]*list.Element
}
func NewLRUCache[K comparable, V any](capacity int) *LRUCache[K, V] {
if capacity <= 0 {
panic("capacity must be > 0")
}
return &LRUCache[K, V]{
capacity: capacity,
ll: list.New(),
items: make(map[K]*list.Element, capacity),
}
}
// Get mutates the list on every call (move-to-front) -- that mutation is
// exactly why a naive concurrent Get needs a WRITE lock.
func (c *LRUCache[K, V]) Get(key K) (V, bool) {
var zero V
el, ok := c.items[key]
if !ok {
return zero, false
}
c.ll.MoveToFront(el)
return el.Value.(*entry[K, V]).value, true
}
func (c *LRUCache[K, V]) Put(key K, value V) {
if el, ok := c.items[key]; ok {
el.Value.(*entry[K, V]).value = value
c.ll.MoveToFront(el)
return
}
if len(c.items) >= c.capacity {
back := c.ll.Back()
if back != nil {
c.ll.Remove(back)
delete(c.items, back.Value.(*entry[K, V]).key)
}
}
el := c.ll.PushFront(&entry[K, V]{key: key, value: value})
c.items[key] = el
}
func (c *LRUCache[K, V]) Len() int { return len(c.items) }
M2 — coarse lock:
package lrucache
import "sync"
// CoarseLockLRUCache is M2: one Mutex around the whole cache. Correct, but
// Get takes the SAME exclusive lock as Put, because Get mutates the
// recency list. Reads serialize behind writes and behind each other.
type CoarseLockLRUCache[K comparable, V any] struct {
mu sync.Mutex
inner *LRUCache[K, V]
}
func NewCoarseLockLRUCache[K comparable, V any](capacity int) *CoarseLockLRUCache[K, V] {
return &CoarseLockLRUCache[K, V]{inner: NewLRUCache[K, V](capacity)}
}
func (c *CoarseLockLRUCache[K, V]) Get(key K) (V, bool) {
c.mu.Lock()
defer c.mu.Unlock()
return c.inner.Get(key)
}
func (c *CoarseLockLRUCache[K, V]) Put(key K, value V) {
c.mu.Lock()
defer c.mu.Unlock()
c.inner.Put(key, value)
}
M3 — sharded / striped locks:
package lrucache
import (
"hash/maphash"
"sync"
)
// StripedLRUCache is M3: partition the keyspace into N shards, each its
// own small LRUCache + its own Mutex. A Get on shard 3 no longer blocks a
// Get on shard 7. Cost: no single global recency order, only N local ones.
type StripedLRUCache[K comparable, V any] struct {
shards []*stripeShard[K, V]
shardMask uint64
seed maphash.Seed
keyString func(K) string
}
type stripeShard[K comparable, V any] struct {
mu sync.Mutex
cache *LRUCache[K, V]
}
// NewStripedLRUCache takes shardCount as a power of two and a keyString
// function to hash arbitrary comparable keys (Go generics can't hash an
// arbitrary K directly without reflection, so the caller supplies it --
// same trade every generic sharded cache in Go makes).
func NewStripedLRUCache[K comparable, V any](totalCapacity, shardCount int, keyString func(K) string) *StripedLRUCache[K, V] {
perShard := totalCapacity / shardCount
if perShard < 1 {
perShard = 1
}
shards := make([]*stripeShard[K, V], shardCount)
for i := range shards {
shards[i] = &stripeShard[K, V]{cache: NewLRUCache[K, V](perShard)}
}
return &StripedLRUCache[K, V]{
shards: shards,
shardMask: uint64(shardCount - 1),
seed: maphash.MakeSeed(),
keyString: keyString,
}
}
func (c *StripedLRUCache[K, V]) shardFor(key K) *stripeShard[K, V] {
var h maphash.Hash
h.SetSeed(c.seed)
h.WriteString(c.keyString(key))
return c.shards[h.Sum64()&c.shardMask]
}
func (c *StripedLRUCache[K, V]) Get(key K) (V, bool) {
s := c.shardFor(key)
s.mu.Lock()
defer s.mu.Unlock()
return s.cache.Get(key)
}
func (c *StripedLRUCache[K, V]) Put(key K, value V) {
s := c.shardFor(key)
s.mu.Lock()
defer s.mu.Unlock()
s.cache.Put(key, value)
}
M4 — CLOCK / second-chance, lock-free reads (fixed after go test -race caught a genuine data race — see Movement 5):
package lrucache
import (
"sync"
"sync/atomic"
)
// ClockCache is M4: approximate LRU via CLOCK / second-chance. Get never
// mutates a list -- it only flips a per-slot "referenced" bit with an
// atomic Store. The CAS is used by the clock hand during eviction to
// safely give a slot a second chance without a lock. This is the
// mechanism behind Linux page replacement and the eviction core of
// production caches like Caffeine.
type ClockCache[K comparable, V any] struct {
capacity int
// writeMu guards only Put/eviction -- Get never takes it.
writeMu sync.Mutex
slotOf sync.Map // K -> int (slot index); safe for concurrent Get/Put
keys []K
values []atomic.Pointer[V] // NOT a plain []V -- see the note below
referenced []atomic.Bool
occupied []atomic.Bool
clockHand atomic.Int32
}
func NewClockCache[K comparable, V any](capacity int) *ClockCache[K, V] {
return &ClockCache[K, V]{
capacity: capacity,
keys: make([]K, capacity),
values: make([]atomic.Pointer[V], capacity),
referenced: make([]atomic.Bool, capacity),
occupied: make([]atomic.Bool, capacity),
}
}
// Get is the lock-free read path: sync.Map lookup + one atomic Store on
// the reference bit, then an atomic Load of the value pointer. No list
// surgery, so concurrent Gets never block each other or the (rare)
// writer.
//
// IMPORTANT (a real bug this design hit while being verified): the
// reference BIT isn't the only shared state -- the payload is too. A
// naive `values []V` plain slice races: Put on slot S can be replacing the
// value while a concurrent Get on the SAME slot S (a stale hit racing an
// eviction reusing that slot) reads it, and `go test -race` catches this
// exact race deterministically. Fix: store *V behind atomic.Pointer[V],
// so the swap itself is atomic and properly synchronized -- still no
// mutex on the read path, but no torn/racy read either.
func (c *ClockCache[K, V]) Get(key K) (V, bool) {
var zero V
v, ok := c.slotOf.Load(key)
if !ok {
return zero, false
}
slot := v.(int)
c.referenced[slot].Store(true) // "I was used" -- second chance later
p := c.values[slot].Load()
if p == nil {
return zero, false
}
return *p, true
}
func (c *ClockCache[K, V]) Put(key K, value V) {
c.writeMu.Lock()
defer c.writeMu.Unlock()
if v, ok := c.slotOf.Load(key); ok {
slot := v.(int)
c.values[slot].Store(&value)
c.referenced[slot].Store(true)
return
}
slot := c.findSlotToUse()
if c.occupied[slot].Load() {
c.slotOf.Delete(c.keys[slot])
}
c.keys[slot] = key
c.values[slot].Store(&value)
c.occupied[slot].Store(true)
c.referenced[slot].Store(true)
c.slotOf.Store(key, slot)
}
// findSlotToUse is the clock sweep: advance the hand; a set reference bit
// gets one CAS'd-off second chance, a clear bit is the victim. The CAS is
// what makes "clear this bit" safe even while a concurrent Get on the same
// slot might be racing to set it back to true.
func (c *ClockCache[K, V]) findSlotToUse() int {
for i := 0; i < c.capacity; i++ {
if !c.occupied[i].Load() {
return i
}
}
for {
hand := int(c.clockHand.Add(1)-1) % c.capacity
if c.referenced[hand].CompareAndSwap(true, false) {
continue // gave it a second chance, keep sweeping
}
return hand // bit was already false -> evict this slot
}
}
The CAS counter:
package lrucache
import "sync/atomic"
// CasCounter: the CAS-counter mini-lesson. Increment is written out by
// hand as a compare-and-swap retry loop -- the same pattern
// atomic.Int64.Add uses internally under the hood on most platforms -- so
// the mechanism is visible, not hidden behind a library call.
type CasCounter struct {
value atomic.Int64
}
func (c *CasCounter) Increment() int64 {
for {
current := c.value.Load()
next := current + 1
if c.value.CompareAndSwap(current, next) {
return next
}
// else: value changed under us -- loop and retry.
}
}
func (c *CasCounter) Get() int64 { return c.value.Load() }
// ABA note (not exercised by this monotonic counter, which is immune to
// ABA): CompareAndSwap(old, new) only checks the VALUE still equals old --
// if another goroutine moved it A -> B -> A in between, the CAS still
// "succeeds" even though the world changed underneath you. Harmless here;
// a real bug for CAS-based stacks/free-lists where a reused pointer can
// look identical after a pop-push-pop cycle. Fix: pair the value with a
// version/generation counter so A-gen1 -> B -> A-gen2 is distinguishable.
// BrokenCounter is the bug CasCounter fixes: a plain int64 incremented
// with count++. That is a non-atomic read-modify-write -- two goroutines
// can both read the same value, both add 1, both write the same result
// back, and one increment is silently lost. go build -race / go test
// -race will also flag this as a genuine data race.
type BrokenCounter struct {
count int64
}
func (c *BrokenCounter) Increment() {
c.count++ // BUG: read-modify-write, not atomic -- data race, lost updates
}
func (c *BrokenCounter) Get() int64 { return c.count }
The full module (lrucache package + two cmd/ demo binaries + a race-detecting test file) passes go build ./... and go vet ./... clean.
5. Break it — the tests that fail
Break-it test #1: reads serialize on the coarse lock. Pre-warm each cache design, then measure pure get() throughput at 1, 2, 4, and 8 concurrent threads/goroutines, no writes in the timed window — so the only variable is how many readers can proceed at once. Best-of-5 timed trials after a warmup pass, on an 11-core Apple Silicon laptop (JDK 8 / Go 1.25.5); run 3× independently to confirm the pattern, not just the absolute numbers, holds:
Java (LockContentionDemo, 65,536-entry cache, 64 shards):
COARSE threads=1 ops/sec=32,322,274
COARSE threads=2 ops/sec=12,002,208 <- more than 60% of throughput GONE the instant a 2nd reader shows up
COARSE threads=4 ops/sec=9,077,172
COARSE threads=8 ops/sec=9,803,953 <- flat: 8x the threads bought ~0x more throughput
STRIPED threads=1 ops/sec=9,955,424 <- slower solo (hashing + indirection overhead)
STRIPED threads=2 ops/sec=16,929,974 <- but it SCALES: beats coarse from 2 threads on
STRIPED threads=4 ops/sec=18,015,201
STRIPED threads=8 ops/sec=14,563,348
CLOCK threads=1 ops/sec=38,872,755
CLOCK threads=2 ops/sec=79,075,346 <- ~2x per added thread: this is what "readers never block" looks like
CLOCK threads=4 ops/sec=146,777,771
CLOCK threads=8 ops/sec=146,641,004 <- plateaus near the physical core count, not near "1 lock"
Go (cmd/lockcontention, same shape):
COARSE goroutines=1 ops/sec=27,203,667
COARSE goroutines=2 ops/sec=11,147,717
COARSE goroutines=4 ops/sec=4,992,205 <- keeps getting WORSE with more goroutines, not just flat
COARSE goroutines=8 ops/sec=3,820,397
STRIPED goroutines=1 ops/sec=10,270,850
STRIPED goroutines=2 ops/sec=15,356,437
STRIPED goroutines=4 ops/sec=19,261,799
STRIPED goroutines=8 ops/sec=19,719,929 <- scales roughly 2x from 1->8
CLOCK goroutines=1 ops/sec=23,399,019
CLOCK goroutines=2 ops/sec=51,601,251
CLOCK goroutines=4 ops/sec=96,544,025
CLOCK goroutines=8 ops/sec=101,213,348 <- ~4.3x from 1->8, the largest scaling of the three designs
The signature to internalize: COARSE doesn't scale, it degrades — every additional concurrent reader in Go actively made things worse (Mutex acquisition under contention costs more than the useless work it protects), and in Java it collapses once and then flatlines. That is what "reads serialize behind a write lock" looks like on a chart: throughput stops responding to added parallelism entirely, because there is fundamentally one door and everyone (reader or writer) queues at it. STRIPED and CLOCK both scale because they removed that single door — CLOCK the most, because it removed the reader-side lock entirely rather than just narrowing it.
Break-it test #2: lost updates without CAS. 8 threads/goroutines each increment a shared counter 200,000 times. Expected total = 1,600,000, exactly, every run. BrokenCounter (plain count++) vs CasCounter (compare-and-swap retry loop):
-- Java (CasCounterDemo) --
BROKEN (count++) expected=1,600,000 actual=842,640 lostUpdates=757,360
CAS (compareAndSet) expected=1,600,000 actual=1,600,000 lostUpdates=0
-- Go (cmd/cascounter) --
BROKEN (count++) expected=1,600,000 actual=475,919 lostUpdates=1,124,081
CAS (CompareAndSwap) expected=1,600,000 actual=1,600,000 lostUpdates=0
Nearly half to nearly three-quarters of the increments simply vanish — no exception, no corrupted memory, just a wrong number that looks plausible. That's the entire danger of a non-atomic read-modify-write under contention: it fails silently, not loudly. Go's race detector makes this concrete and undeniable rather than just "the number looks off":
$ go test -race -run TestBrokenCounter_LosesUpdates -v ./lrucache/...
==================
WARNING: DATA RACE
Write at 0x00c0000102c8 by goroutine 38:
lrukata/lrucache.(*BrokenCounter).Increment()
.../cascounter.go:48 +0xa0
Previous write at 0x00c0000102c8 by goroutine ...
==================
lrucache_test.go:106: BUG REPRODUCED: want 320000, got 178021, lost 141979 updates
--- FAIL: TestBrokenCounter_LosesUpdates (0.01s)
And the correct designs are verified genuinely race-free under the same detector, not just "seems fine in testing":
$ go test -race -run 'RaceFree|ExactUnderContention' -v ./lrucache/...
--- PASS: TestCoarseLockLRUCache_RaceFree (0.12s)
--- PASS: TestStripedLRUCache_RaceFree (0.06s)
--- PASS: TestClockCache_RaceFree (0.15s)
--- PASS: TestCasCounter_ExactUnderContention (0.54s)
PASS
A finding worth naming honestly: the first version of ClockCache written for this lab did NOT pass TestClockCache_RaceFree — go test -race caught a genuine data race on the plain []V values slice (a concurrent Get and Put touching the same slot with no synchronization on the payload, only on the reference bit). The fix (shown above) was switching to atomic.Pointer[V] in Go and AtomicReferenceArray in Java. The lesson generalizes beyond this lab: removing the lock from a read path means EVERY piece of shared state that read touches needs its own synchronization story, not just the "obvious" one (here, the reference bit) — the payload counts too. This is exactly the kind of bug the adversarial-verify step (recompile, re-run, don't just eyeball it) exists to catch.
6. Optimise — with trade-offs
| Approach | Read throughput under contention | Eviction accuracy | Complexity | Use when |
|---|---|---|---|---|
| Exact LRU, coarse lock (this lab's M2) | Worst — degrades or flatlines as readers increase; one lock for reads AND writes | Perfect: true global LRU order | Lowest — trivial to reason about, trivial to get wrong under load | Low-concurrency callers, or a cache that's genuinely write-heavy so contention is unavoidable anyway; correctness-critical exact ordering (rare) |
| Sharded / striped locks (M3) | Good — scales roughly with shard count for uniform key traffic; degrades under key skew (hot shard) | Approximate: LRU per-shard, not globally | Moderate — more state, a hash function to get right, a shard-count tuning knob | Read-heavy AND write-heavy mixed traffic, moderate concurrency (tens of threads), uniform-ish key distribution; the default upgrade from a coarse lock |
| Sampled / CLOCK (second-chance) (M4) | Best for reads — genuinely lock-free reads, scales with cores | Approximate: "not touched in a full sweep," not exact recency | Highest of the three — atomics per slot, a sweeping hand, and (as Movement 5 showed) the payload itself needs its own synchronization plan | Read-dominated hot paths (routing tables, feature flags, hot rows) where p99 latency matters more than perfectly exact LRU ordering |
Go sync.Map (no manual eviction) | Very good for read-mostly, append-mostly key sets (it's optimized for exactly that access pattern) | None built in — no capacity bound or eviction policy at all; you'd have to add one yourself | Low to use, but it is NOT an LRU cache — it's an unbounded concurrent map that happens to be fast for skewed read/write patterns | An unbounded or externally-bounded map with disjoint keys per goroutine (e.g. a per-connection cache) — not a substitute for capacity-bounded LRU |
| Caffeine (Java) / Ristretto (Go) — production libraries | Excellent — lock-free reads via a ring-buffer-batched approximate LFU/LRU hybrid (Window-TinyLFU) | Very good in practice — admission + eviction tuned against real traces, usually beats naive LRU on hit rate | You don't write it, you configure it — but you lose the "I built and can defend this" depth this lab is for | Production code, basically always — hand-roll a cache for an interview or because you have a genuinely unusual access pattern the library doesn't fit, not because "how hard can it be" |
The real judgment call: a coarse lock is the right DEFAULT to start with — it's the easiest to prove correct, and premature striping/CLOCK-ification is wasted complexity if your actual read concurrency is low. Reach for sharding when you've measured contention on a shared lock and the workload has decent key spread. Reach for CLOCK/second-chance (or just adopt Caffeine/Ristretto) only once you've established that reads genuinely dominate and even sharded lock overhead shows up in a profile — the accuracy you give up (approximate vs. exact recency) is real, and you should be able to say, out loud, why your hit rate doesn't actually suffer for it.
7. Defend under drilling
- "Why does
get()even need a lock at all — it's just a read?" Because "just a read" is a lie for any LRU cache: recency tracking means every successful lookup writes to a shared data structure (the recency list, or at minimum a bit). The interviewer is testing whether you actually looked at what your ownget()does, not whether you can recite "readers don't need locks" as a slogan. - "Sharding fixed your benchmark — why not stop there?" Sharding caps your parallelism at the shard count and is still vulnerable to key skew: if 30% of traffic hits one hot key, that key's shard is a coarse-lock cache all over again, just smaller. CLOCK removes the read-side lock structurally, so it doesn't have a "hot shard" failure mode — hot keys just get their reference bit flipped more often, which is cheap and uncontended.
- "Your CLOCK cache is only approximately LRU — when does that actually matter?" When your workload has a sharp recency cliff (a small hot set that must never be evicted) and your capacity is tight relative to that hot set. In practice, most production hit-rate curves are fairly flat near the true-LRU optimum for a full clock sweep vs. exact ordering — the accuracy loss shows up in benchmarks against adversarial access patterns more than in real traffic. Say this, don't hand-wave it.
- "What if two threads race to insert the SAME new key in
ClockCache.put()?" They serialize onwriteMu— the write path is still a single coarse lock in this design, which is fine because writes are assumed rare. If writes are NOT rare in your workload, CLOCK's advantage evaporates and you're back to sharding (or sharding your write lock too, per-slot-range). - "What breaks at 100x the load, or across machines?" Single-process sharding caps out at your core count; the escalation is a distributed cache with consistent hashing to route keys to nodes (each node running something like this cache internally), which turns "which shard" into "which node," and turns local lock contention into network hops and cross-node invalidation — the same fundamental trade (exact global order vs. scalable approximate order) reappears one layer up.
- "Prove the CAS counter is actually correct under real contention, not just in theory." That's exactly what Movement 5's
TestCasCounter_ExactUnderContentiondoes — 32 goroutines × 10,000 increments each, asserting the exact total, run under-race. It passes every time by construction: the retry loop guarantees no increment is ever silently overwritten, unlikecount++.
8. You can now defend
- You can name, precisely, why a "thread-safe" LRU cache with one lock still serializes reads: eviction bookkeeping (move-to-front) makes every
get()a write, not a read. - You've measured that failure mode directly — coarse-lock throughput flatlining or degrading as concurrent readers increase, in both Java and Go — instead of taking it on faith.
- You can build a sharded LRU and a lock-free CLOCK/second-chance cache from scratch, in both languages, and explain the exact-vs-approximate-ordering trade each one makes.
- You can place all four designs (coarse, striped, CLOCK, and library options like Caffeine/Ristretto/
sync.Map) on one trade-off table and argue "use when" for each, not just "this one is faster." - You've reproduced a real lost-update bug from a non-atomic counter and fixed it with a hand-rolled CAS retry loop — and you can name the ABA problem as the one case where CAS alone still isn't enough.
Re-authored/Deepened for this guide. Reference code compiled and executed before publishing: Java (javac/java, JDK 8) and Go (go build ./..., go vet ./..., go test -race ./...) both clean; the lock-contention benchmark and the lost-update/CAS-counter demo reproduce the stated numbers on repeated runs; an initial data race in the Go ClockCache was caught by go test -race during verification and fixed (documented in Movement 5) before publishing. See also: Caching — LRU Lock Contention & Redis Ops, Atomics & Compare-And-Swap (CAS), CAS & the ABA Problem, and Build an LRU + TTL Cache.
🤖 Don't fully get this? Learn it with Claude
Stuck on Build a Concurrent LRU Cache? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Build a Concurrent LRU Cache** (Hands-On Builds) and want to truly understand it. Explain Build a Concurrent LRU Cache from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Build a Concurrent LRU Cache** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Build a Concurrent LRU Cache** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Build a Concurrent LRU Cache** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.