keysLruFirst() {
+ throw new UnsupportedOperationException("Caffeine does not expose recency order");
+ }
+}
diff --git a/lru-cache/src/main/java/com/ankurm/interviewlab/lrucache/HashMapLruCache.java b/lru-cache/src/main/java/com/ankurm/interviewlab/lrucache/HashMapLruCache.java
new file mode 100644
index 0000000..8cc50c8
--- /dev/null
+++ b/lru-cache/src/main/java/com/ankurm/interviewlab/lrucache/HashMapLruCache.java
@@ -0,0 +1,127 @@
+package com.ankurm.interviewlab.lrucache;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Implementation 2: what an interviewer means by "do it without LinkedHashMap".
+ *
+ * A {@link HashMap} gives O(1) key to node. A doubly linked list gives O(1) "move this node to
+ * the front" and "drop the last node", because every node knows both neighbours. Two sentinel
+ * nodes ({@code head} and {@code tail}) mean no operation ever has to ask "is there a previous
+ * node?", so there are no null checks and no special cases for an empty or one-element list.
+ *
+ *
+ * head(sentinel) <-> MRU <-> ... <-> LRU <-> tail(sentinel)
+ *
+ *
+ * NOT thread-safe by itself. The thread-safe variants in this package wrap it.
+ */
+public class HashMapLruCache implements LruCache {
+
+ static final class Node {
+ K key;
+ V value;
+ Node prev, next;
+ Node(K key, V value) { this.key = key; this.value = value; }
+ }
+
+ private final int capacity;
+ private final Map> index;
+ private final Node head = new Node<>(null, null);
+ private final Node tail = new Node<>(null, null);
+
+ /** Counts every pointer assignment made on a node. Lets a test prove the cost per operation. */
+ long pointerWrites;
+
+ public HashMapLruCache(int capacity) {
+ if (capacity <= 0) throw new IllegalArgumentException("capacity must be > 0: " + capacity);
+ this.capacity = capacity;
+ this.index = new HashMap<>(Math.max(16, (int) (capacity / 0.75f) + 1));
+ head.next = tail;
+ tail.prev = head;
+ }
+
+ @Override
+ public V get(K key) {
+ Objects.requireNonNull(key);
+ Node n = index.get(key); // O(1) expected
+ if (n == null) return null;
+ unlink(n); // O(1): 2 pointer writes
+ linkAfterHead(n); // O(1): 4 pointer writes
+ return n.value;
+ }
+
+ @Override
+ public void put(K key, V value) {
+ Objects.requireNonNull(key);
+ Objects.requireNonNull(value);
+ Node n = index.get(key);
+ if (n != null) { // existing key: replace value, make it MRU
+ n.value = value;
+ unlink(n);
+ linkAfterHead(n);
+ return;
+ }
+ if (index.size() == capacity) { // full: evict the node just before the tail sentinel
+ Node lru = tail.prev;
+ unlink(lru);
+ index.remove(lru.key); // the node must remember its key, or this is impossible
+ }
+ n = new Node<>(key, value);
+ index.put(key, n);
+ linkAfterHead(n);
+ }
+
+ private void unlink(Node n) {
+ n.prev.next = n.next;
+ n.next.prev = n.prev;
+ pointerWrites += 2;
+ }
+
+ private void linkAfterHead(Node n) {
+ n.next = head.next;
+ n.prev = head;
+ head.next.prev = n;
+ head.next = n;
+ pointerWrites += 4;
+ }
+
+ @Override
+ public int size() { return index.size(); }
+
+ @Override
+ public int capacity() { return capacity; }
+
+ @Override
+ public List keysLruFirst() {
+ List out = new ArrayList<>(index.size());
+ for (Node n = tail.prev; n != head; n = n.prev) out.add(n.key);
+ return out;
+ }
+
+ /**
+ * Structural self-check used by the stress tests: walks the list both ways and compares it to
+ * the hash index. Throws {@link IllegalStateException} naming the first broken invariant.
+ */
+ public void checkInvariants() {
+ if (head.prev != null || tail.next != null) throw new IllegalStateException("sentinel has outer link");
+ int forward = 0;
+ Node prev = head;
+ for (Node n = head.next; n != tail; n = n.next) {
+ if (n == null) throw new IllegalStateException("forward walk hit null: list is cut");
+ if (n.prev != prev) throw new IllegalStateException("prev/next disagree at key " + n.key);
+ if (index.get(n.key) != n) throw new IllegalStateException("list node not in index: " + n.key);
+ prev = n;
+ if (++forward > capacity) throw new IllegalStateException("list longer than capacity (or cyclic)");
+ }
+ if (tail.prev != prev) throw new IllegalStateException("tail.prev is not the last forward node");
+ if (forward != index.size()) {
+ throw new IllegalStateException("list length " + forward + " != index size " + index.size());
+ }
+ if (forward > capacity) throw new IllegalStateException("over capacity: " + forward);
+ }
+}
diff --git a/lru-cache/src/main/java/com/ankurm/interviewlab/lrucache/LinkedHashMapLruCache.java b/lru-cache/src/main/java/com/ankurm/interviewlab/lrucache/LinkedHashMapLruCache.java
new file mode 100644
index 0000000..44e08e0
--- /dev/null
+++ b/lru-cache/src/main/java/com/ankurm/interviewlab/lrucache/LinkedHashMapLruCache.java
@@ -0,0 +1,69 @@
+package com.ankurm.interviewlab.lrucache;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Implementation 1: the ten-line answer. {@link LinkedHashMap} with {@code accessOrder = true}
+ * keeps entries in access order; {@code removeEldestEntry} is the hook it calls after every
+ * insertion, and returning {@code true} evicts the eldest (= least recently used) entry.
+ *
+ * NOT thread-safe. See {@link SynchronizedLruCache} and friends.
+ * Explained in the article under "Implementation 1".
+ */
+public final class LinkedHashMapLruCache implements LruCache {
+
+ private final int capacity;
+ private final Lru map;
+
+ public LinkedHashMapLruCache(int capacity) {
+ if (capacity <= 0) throw new IllegalArgumentException("capacity must be > 0: " + capacity);
+ this.capacity = capacity;
+ this.map = new Lru<>(capacity);
+ }
+
+ /** The classic interview class, shown on its own so the article can quote it. */
+ static final class Lru extends LinkedHashMap {
+ private final int capacity;
+
+ Lru(int capacity) {
+ // 3rd argument true = access order. Load factor 1.0f + capacity+1 slots: the map
+ // never needs to resize, because removeEldestEntry trims it back to `capacity`.
+ super(capacity + 1, 1.0f, true);
+ this.capacity = capacity;
+ }
+
+ @Override
+ protected boolean removeEldestEntry(Map.Entry eldest) {
+ return size() > capacity;
+ }
+ }
+
+ @Override
+ public V get(K key) {
+ Objects.requireNonNull(key);
+ return map.get(key); // in access-order mode this relinks the entry to the tail
+ }
+
+ @Override
+ public void put(K key, V value) {
+ Objects.requireNonNull(key);
+ Objects.requireNonNull(value);
+ map.put(key, value);
+ }
+
+ @Override
+ public int size() { return map.size(); }
+
+ @Override
+ public int capacity() { return capacity; }
+
+ @Override
+ public List keysLruFirst() { return new ArrayList<>(map.keySet()); }
+
+ /** Exposed for the "get() is a structural modification" demo. */
+ Map rawMap() { return map; }
+}
diff --git a/lru-cache/src/main/java/com/ankurm/interviewlab/lrucache/LruCache.java b/lru-cache/src/main/java/com/ankurm/interviewlab/lrucache/LruCache.java
new file mode 100644
index 0000000..45e2449
--- /dev/null
+++ b/lru-cache/src/main/java/com/ankurm/interviewlab/lrucache/LruCache.java
@@ -0,0 +1,30 @@
+package com.ankurm.interviewlab.lrucache;
+
+import java.util.List;
+
+/**
+ * The contract every implementation in this module honours.
+ *
+ *
+ * - Fixed capacity, set at construction. Inserting a new key into a full cache evicts the
+ * least recently used entry.
+ * - "Used" means {@code get} that hits, or {@code put} (new key or existing key).
+ * - Null keys and null values are rejected with {@link NullPointerException}, so
+ * {@code get(k) == null} unambiguously means "absent".
+ *
+ */
+public interface LruCache {
+
+ /** Returns the value, or {@code null} if absent. A hit makes the key most recently used. */
+ V get(K key);
+
+ /** Inserts or replaces; makes the key most recently used; evicts the LRU entry if over capacity. */
+ void put(K key, V value);
+
+ int size();
+
+ int capacity();
+
+ /** Snapshot of the keys, least recently used first. Does NOT count as a use. For tests and demos. */
+ List keysLruFirst();
+}
diff --git a/lru-cache/src/main/java/com/ankurm/interviewlab/lrucache/ReentrantLockLruCache.java b/lru-cache/src/main/java/com/ankurm/interviewlab/lrucache/ReentrantLockLruCache.java
new file mode 100644
index 0000000..35fb94b
--- /dev/null
+++ b/lru-cache/src/main/java/com/ankurm/interviewlab/lrucache/ReentrantLockLruCache.java
@@ -0,0 +1,38 @@
+package com.ankurm.interviewlab.lrucache;
+
+import java.util.List;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * Thread-safe variant B: the same single critical section as {@link SynchronizedLruCache}, spelled
+ * with {@link ReentrantLock}. It buys {@code tryLock}, timeouts and fairness, not parallelism:
+ * there is still exactly one lock, and every operation takes it.
+ */
+public final class ReentrantLockLruCache implements LruCache {
+ private final HashMapLruCache delegate;
+ private final ReentrantLock lock = new ReentrantLock();
+
+ public ReentrantLockLruCache(int capacity) { this.delegate = new HashMapLruCache<>(capacity); }
+
+ @Override public V get(K key) {
+ lock.lock();
+ try { return delegate.get(key); } finally { lock.unlock(); }
+ }
+ @Override public void put(K key, V value) {
+ lock.lock();
+ try { delegate.put(key, value); } finally { lock.unlock(); }
+ }
+ @Override public int size() {
+ lock.lock();
+ try { return delegate.size(); } finally { lock.unlock(); }
+ }
+ @Override public int capacity() { return delegate.capacity(); }
+ @Override public List keysLruFirst() {
+ lock.lock();
+ try { return delegate.keysLruFirst(); } finally { lock.unlock(); }
+ }
+ public void checkInvariants() {
+ lock.lock();
+ try { delegate.checkInvariants(); } finally { lock.unlock(); }
+ }
+}
diff --git a/lru-cache/src/main/java/com/ankurm/interviewlab/lrucache/StripedLruCache.java b/lru-cache/src/main/java/com/ankurm/interviewlab/lrucache/StripedLruCache.java
new file mode 100644
index 0000000..fa2ea8e
--- /dev/null
+++ b/lru-cache/src/main/java/com/ankurm/interviewlab/lrucache/StripedLruCache.java
@@ -0,0 +1,71 @@
+package com.ankurm.interviewlab.lrucache;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Thread-safe variant C: lock striping. The key space is split by hash into {@code stripes}
+ * independent {@link SynchronizedLruCache} segments, each with its own lock and its own LRU list.
+ * Threads touching different segments do not contend.
+ *
+ * The price is exactness: the eviction order is LRU within a segment, not globally.
+ * The globally oldest key can survive while a younger key in a full segment is evicted. Total
+ * capacity is {@code stripes * (requested / stripes)}, rounded down, at least one per stripe.
+ */
+public final class StripedLruCache implements LruCache {
+ /**
+ * How a key picks its segment. LOW_BITS is the obvious first attempt and a performance bug: it
+ * uses the same low bits of the hash that each segment's inner {@code HashMap} uses to pick a
+ * bucket, so every key in one segment lands in the same few buckets. MIXED multiplies by the
+ * 32-bit golden-ratio constant and takes high bits, which are independent of the low bits.
+ */
+ public enum Selector {
+ LOW_BITS { @Override int index(int hash, int stripes) { return Math.floorMod(hash ^ (hash >>> 16), stripes); } },
+ MIXED { @Override int index(int hash, int stripes) { return Math.floorMod((hash * 0x9E3779B9) >>> 16, stripes); } };
+ abstract int index(int hash, int stripes);
+
+ /** Public for the bucket-distribution test. */
+ public int select(int hash, int stripes) { return index(hash, stripes); }
+ }
+
+ private final SynchronizedLruCache[] segments;
+ private final int capacity;
+ private final Selector selector;
+
+ public StripedLruCache(int capacity, int stripes) { this(capacity, stripes, Selector.MIXED); }
+
+ @SuppressWarnings("unchecked")
+ public StripedLruCache(int capacity, int stripes, Selector selector) {
+ this.selector = selector;
+ if (capacity <= 0 || stripes <= 0) throw new IllegalArgumentException("capacity and stripes must be > 0");
+ int perStripe = Math.max(1, capacity / stripes);
+ this.segments = new SynchronizedLruCache[stripes];
+ for (int i = 0; i < stripes; i++) segments[i] = new SynchronizedLruCache<>(perStripe);
+ this.capacity = perStripe * stripes;
+ }
+
+ private SynchronizedLruCache segmentFor(K key) {
+ return segments[selector.index(Objects.requireNonNull(key).hashCode(), segments.length)];
+ }
+
+ @Override public V get(K key) { return segmentFor(key).get(key); }
+ @Override public void put(K key, V value) { segmentFor(key).put(key, value); }
+
+ @Override public int size() {
+ int n = 0;
+ for (var s : segments) n += s.size(); // not an atomic snapshot across segments
+ return n;
+ }
+
+ @Override public int capacity() { return capacity; }
+
+ /** Concatenation of per-segment orders: NOT a global recency order. */
+ @Override public List keysLruFirst() {
+ List all = new ArrayList<>();
+ for (var s : segments) all.addAll(s.keysLruFirst());
+ return all;
+ }
+
+ public void checkInvariants() { for (var s : segments) s.checkInvariants(); }
+}
diff --git a/lru-cache/src/main/java/com/ankurm/interviewlab/lrucache/SynchronizedLruCache.java b/lru-cache/src/main/java/com/ankurm/interviewlab/lrucache/SynchronizedLruCache.java
new file mode 100644
index 0000000..84ef13a
--- /dev/null
+++ b/lru-cache/src/main/java/com/ankurm/interviewlab/lrucache/SynchronizedLruCache.java
@@ -0,0 +1,20 @@
+package com.ankurm.interviewlab.lrucache;
+
+import java.util.List;
+
+/**
+ * Thread-safe variant A: one coarse monitor. {@code get} MUTATES the recency list, so every
+ * method, reads included, needs exclusive access. Simplest correct answer.
+ */
+public final class SynchronizedLruCache implements LruCache {
+ private final HashMapLruCache delegate;
+
+ public SynchronizedLruCache(int capacity) { this.delegate = new HashMapLruCache<>(capacity); }
+
+ @Override public synchronized V get(K key) { return delegate.get(key); }
+ @Override public synchronized void put(K key, V value) { delegate.put(key, value); }
+ @Override public synchronized int size() { return delegate.size(); }
+ @Override public int capacity() { return delegate.capacity(); }
+ @Override public synchronized List keysLruFirst() { return delegate.keysLruFirst(); }
+ public synchronized void checkInvariants() { delegate.checkInvariants(); }
+}
diff --git a/lru-cache/src/main/java/com/ankurm/interviewlab/lrucache/bench/LruBenchmark.java b/lru-cache/src/main/java/com/ankurm/interviewlab/lrucache/bench/LruBenchmark.java
new file mode 100644
index 0000000..a74b5e3
--- /dev/null
+++ b/lru-cache/src/main/java/com/ankurm/interviewlab/lrucache/bench/LruBenchmark.java
@@ -0,0 +1,69 @@
+package com.ankurm.interviewlab.lrucache.bench;
+
+import com.ankurm.interviewlab.lrucache.*;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.TimeUnit;
+import org.openjdk.jmh.annotations.*;
+
+/**
+ * The cache-aside access pattern: get; on a miss, put. Keys are uniform over 2x the capacity, so
+ * roughly half the lookups hit. The single-threaded implementations must be run with -t 1.
+ *
+ * Run: scripts/run-all.sh (short forks, INDICATIVE numbers on a shared 2-vCPU sandbox).
+ */
+@BenchmarkMode(Mode.Throughput)
+@OutputTimeUnit(TimeUnit.MICROSECONDS)
+@State(Scope.Benchmark)
+public class LruBenchmark {
+ static final int CAPACITY = 1024;
+ static final int KEY_RANGE = CAPACITY * 2;
+
+ @Param({"linkedhashmap-unsafe", "dll-unsafe", "synchronizedMap(LinkedHashMap)", "synchronized", "reentrantlock", "striped16", "striped16-lowbits", "caffeine"})
+ public String impl;
+
+ private LruCache cache;
+
+ @Setup(Level.Trial)
+ public void setUp() {
+ cache = switch (impl) {
+ case "linkedhashmap-unsafe" -> new LinkedHashMapLruCache<>(CAPACITY);
+ case "dll-unsafe" -> new HashMapLruCache<>(CAPACITY);
+ case "synchronizedMap(LinkedHashMap)" -> new SyncMapAdapter(CAPACITY);
+ case "synchronized" -> new SynchronizedLruCache<>(CAPACITY);
+ case "reentrantlock" -> new ReentrantLockLruCache<>(CAPACITY);
+ case "striped16" -> new StripedLruCache<>(CAPACITY, 16);
+ case "striped16-lowbits" -> new StripedLruCache<>(CAPACITY, 16, StripedLruCache.Selector.LOW_BITS);
+ case "caffeine" -> new CaffeineLru<>(CAPACITY);
+ default -> throw new IllegalArgumentException(impl);
+ };
+ for (int i = 0; i < CAPACITY; i++) cache.put(i, i); // warm: start full
+ }
+
+ @Benchmark
+ public Integer lookupThenFillOnMiss() {
+ int k = ThreadLocalRandom.current().nextInt(KEY_RANGE);
+ Integer v = cache.get(k);
+ if (v == null) { v = k; cache.put(k, v); }
+ return v;
+ }
+
+ /** Collections.synchronizedMap around an access-order LinkedHashMap: the "free" thread-safe LRU. */
+ static final class SyncMapAdapter implements LruCache {
+ private final Map m;
+ private final int capacity;
+ SyncMapAdapter(int capacity) {
+ this.capacity = capacity;
+ this.m = Collections.synchronizedMap(new LinkedHashMap<>(capacity + 1, 1.0f, true) {
+ @Override protected boolean removeEldestEntry(Map.Entry e) { return size() > CAPACITY; }
+ });
+ }
+ public Integer get(Integer k) { return m.get(k); }
+ public void put(Integer k, Integer v) { m.put(k, v); }
+ public int size() { return m.size(); }
+ public int capacity() { return capacity; }
+ public java.util.List keysLruFirst() { throw new UnsupportedOperationException(); }
+ }
+}
diff --git a/lru-cache/src/test/java/com/ankurm/interviewlab/lrucache/ConcurrencyStressTest.java b/lru-cache/src/test/java/com/ankurm/interviewlab/lrucache/ConcurrencyStressTest.java
new file mode 100644
index 0000000..94b893c
--- /dev/null
+++ b/lru-cache/src/test/java/com/ankurm/interviewlab/lrucache/ConcurrencyStressTest.java
@@ -0,0 +1,124 @@
+package com.ankurm.interviewlab.lrucache;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import com.ankurm.interviewlab.lrucache.support.Transcript;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.IntFunction;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Hammers each thread-safe implementation from many threads with a deliberately small capacity so
+ * eviction happens constantly, then asserts hard facts (not "it didn't crash"):
+ *
+ * - no worker threw;
+ * - a get never returns a value that belongs to a different key (value is a pure function of key);
+ * - a monitor thread never saw size() above capacity;
+ * - the internal list/index invariants hold at the end;
+ * - afterwards the cache still behaves as a correct LRU (for the global-LRU implementations).
+ *
+ */
+class ConcurrencyStressTest {
+ static final int THREADS = 8, OPS_PER_THREAD = 60_000, CAPACITY = 64, KEYS = 256;
+
+ static int valueFor(int key) { return key * 31 + 7; }
+
+ interface Checked { LruCache cache(); void check(); }
+
+ static Checked sync(int cap) {
+ var c = new SynchronizedLruCache(cap);
+ return new Checked() { public LruCache cache() { return c; } public void check() { c.checkInvariants(); } };
+ }
+ static Checked lock(int cap) {
+ var c = new ReentrantLockLruCache(cap);
+ return new Checked() { public LruCache cache() { return c; } public void check() { c.checkInvariants(); } };
+ }
+ static Checked striped(int cap) {
+ var c = new StripedLruCache(cap, 8);
+ return new Checked() { public LruCache cache() { return c; } public void check() { c.checkInvariants(); } };
+ }
+
+ @Test
+ void stressAllThreadSafeImplementations() throws Exception {
+ Transcript t = Transcript.start("05-concurrency-stress.txt",
+ THREADS + " threads x " + OPS_PER_THREAD + " ops (80% get / 20% put), capacity " + CAPACITY + ", " + KEYS + " keys");
+ t.line(String.format("%-24s %-12s %-14s %-22s %-16s %s", "implementation", "exceptions", "wrong values", "size>capacity seen?", "invariants", "post-stress LRU check"));
+ t.line(stress("SynchronizedLruCache", ConcurrencyStressTest::sync, true));
+ t.line(stress("ReentrantLockLruCache", ConcurrencyStressTest::lock, true));
+ t.line(stress("StripedLruCache(8)", ConcurrencyStressTest::striped, false));
+ t.blank();
+ t.line("Hit/miss counts are deliberately not printed: they differ on every run and nothing asserts them.");
+ t.flush();
+ }
+
+ private String stress(String name, IntFunction factory, boolean globalLru) throws Exception {
+ Checked c = factory.apply(CAPACITY);
+ LruCache cache = c.cache();
+ AtomicInteger exceptions = new AtomicInteger();
+ AtomicLong wrongValues = new AtomicLong();
+ AtomicBoolean oversize = new AtomicBoolean();
+ AtomicBoolean done = new AtomicBoolean();
+
+ Thread monitor = new Thread(() -> {
+ while (!done.get()) {
+ if (cache.size() > cache.capacity()) oversize.set(true);
+ Thread.onSpinWait();
+ }
+ });
+ monitor.start();
+
+ ExecutorService pool = Executors.newFixedThreadPool(THREADS);
+ CountDownLatch go = new CountDownLatch(1);
+ List> fs = new ArrayList<>();
+ for (int t = 0; t < THREADS; t++) {
+ final int seed = t;
+ fs.add(pool.submit(() -> {
+ go.await();
+ ThreadLocalRandom r = ThreadLocalRandom.current();
+ for (int i = 0; i < OPS_PER_THREAD; i++) {
+ int k = r.nextInt(KEYS);
+ try {
+ if (r.nextInt(5) == 0) {
+ cache.put(k, valueFor(k));
+ } else {
+ Integer v = cache.get(k);
+ if (v != null && v != valueFor(k)) wrongValues.incrementAndGet();
+ }
+ } catch (Throwable e) {
+ exceptions.incrementAndGet();
+ return null;
+ }
+ }
+ return null;
+ }));
+ }
+ go.countDown();
+ for (Future> f : fs) f.get(60, TimeUnit.SECONDS);
+ pool.shutdown();
+ done.set(true);
+ monitor.join();
+
+ assertEquals(0, exceptions.get(), name + ": worker threw");
+ assertEquals(0, wrongValues.get(), name + ": get returned another key's value");
+ assertFalse(oversize.get(), name + ": size() exceeded capacity");
+ c.check();
+ assertTrue(cache.size() <= cache.capacity());
+
+ String post = "n/a (per-segment LRU)";
+ if (globalLru) {
+ // After the storm, one thread inserts `capacity` brand-new keys: they must be exactly what remains, in order.
+ List expected = new ArrayList<>();
+ for (int k = 1000; k < 1000 + CAPACITY; k++) { cache.put(k, valueFor(k)); expected.add(k); }
+ assertEquals(expected, cache.keysLruFirst());
+ c.check();
+ post = "ok";
+ }
+ return String.format("%-24s %-12d %-14d %-22s %-16s %s", name, exceptions.get(), wrongValues.get(),
+ oversize.get() ? "YES" : "no", "ok", post);
+ }
+}
diff --git a/lru-cache/src/test/java/com/ankurm/interviewlab/lrucache/ConstantCostTest.java b/lru-cache/src/test/java/com/ankurm/interviewlab/lrucache/ConstantCostTest.java
new file mode 100644
index 0000000..cb2d81b
--- /dev/null
+++ b/lru-cache/src/test/java/com/ankurm/interviewlab/lrucache/ConstantCostTest.java
@@ -0,0 +1,51 @@
+package com.ankurm.interviewlab.lrucache;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import com.ankurm.interviewlab.lrucache.support.Transcript;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Evidence for "O(1)": the number of list-pointer writes per operation is the same constant whether
+ * the cache holds 10 entries or 100,000. (The hash lookup is O(1) expected; that part is HashMap's.)
+ */
+class ConstantCostTest {
+
+ @Test
+ void pointerWritesPerOperationDoNotDependOnCacheSize() {
+ Transcript t = Transcript.start("02-constant-cost-per-operation.txt",
+ "Pointer writes per HashMapLruCache operation, by cache size");
+ t.line(String.format("%-10s %-12s %-12s %-14s %-22s", "capacity", "get(hit)", "get(miss)", "put(new)", "put(new, evicts LRU)"));
+ for (int n : new int[] {10, 1_000, 100_000}) {
+ HashMapLruCache c = new HashMapLruCache<>(n);
+ for (int i = 0; i < n - 1; i++) c.put(i, i); // one free slot left
+
+ long w = c.pointerWrites;
+ c.get(0); // the oldest entry: worst case for a naive scan
+ long hit = c.pointerWrites - w;
+
+ w = c.pointerWrites;
+ c.get(-1);
+ long miss = c.pointerWrites - w;
+
+ w = c.pointerWrites;
+ c.put(n, n); // fills the last free slot
+ long putNew = c.pointerWrites - w;
+
+ w = c.pointerWrites;
+ c.put(n + 1, n + 1); // now full: evicts the LRU
+ long putEvict = c.pointerWrites - w;
+
+ assertEquals(6, hit);
+ assertEquals(0, miss);
+ assertEquals(4, putNew);
+ assertEquals(6, putEvict);
+ assertEquals(n, c.size());
+ c.checkInvariants();
+ t.line(String.format("%-10d %-12d %-12d %-14d %-22d", n, hit, miss, putNew, putEvict));
+ }
+ t.blank();
+ t.line("get(hit) is measured on the OLDEST key (position n from the front). Cost is 6 writes anyway: the node is reached through the hash index, not by walking the list.");
+ t.flush();
+ }
+}
diff --git a/lru-cache/src/test/java/com/ankurm/interviewlab/lrucache/DryRunTranscriptTest.java b/lru-cache/src/test/java/com/ankurm/interviewlab/lrucache/DryRunTranscriptTest.java
new file mode 100644
index 0000000..b227e4f
--- /dev/null
+++ b/lru-cache/src/test/java/com/ankurm/interviewlab/lrucache/DryRunTranscriptTest.java
@@ -0,0 +1,69 @@
+package com.ankurm.interviewlab.lrucache;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import com.ankurm.interviewlab.lrucache.support.Transcript;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+/** The dry-run table from the article: capacity 3, one operation per row, state after each. */
+class DryRunTranscriptTest {
+
+ record Op(String text, String expectedReturn, List expectedLruFirst) {}
+
+ @Test
+ void dryRunTable() {
+ // Each row: operation, what it returns, keys LRU -> MRU afterwards.
+ List ops = List.of(
+ new Op("put(1,A)", "-", List.of(1)),
+ new Op("put(2,B)", "-", List.of(1, 2)),
+ new Op("put(3,C)", "-", List.of(1, 2, 3)),
+ new Op("get(1)", "A", List.of(2, 3, 1)),
+ new Op("put(4,D)", "- (evicts 2)", List.of(3, 1, 4)),
+ new Op("get(2)", "null", List.of(3, 1, 4)),
+ new Op("put(3,C2)", "- (replace, refresh)", List.of(1, 4, 3)),
+ new Op("put(5,E)", "- (evicts 1)", List.of(4, 3, 5)),
+ new Op("get(4)", "D", List.of(3, 5, 4)),
+ new Op("get(1)", "null", List.of(3, 5, 4)));
+
+ HashMapLruCache dll = new HashMapLruCache<>(3);
+ LinkedHashMapLruCache lhm = new LinkedHashMapLruCache<>(3);
+
+ Transcript t = Transcript.start("01-dry-run-capacity-3.txt",
+ "Dry run, capacity 3: state after each operation (keys listed LRU -> MRU)");
+ t.line(String.format("%-3s %-12s %-22s %-14s %-9s %s", "#", "operation", "returns", "LRU -> MRU", "ptr-writes", "same as LinkedHashMap?"));
+ int n = 0;
+ for (Op op : ops) {
+ long before = dll.pointerWrites;
+ String got = apply(op.text, dll);
+ String gotLhm = apply(op.text, lhm);
+ long cost = dll.pointerWrites - before;
+
+ String expectedValue = op.expectedReturn.split(" ")[0];
+ assertEquals(expectedValue, got, op.text);
+ assertEquals(got, gotLhm, op.text);
+ assertEquals(op.expectedLruFirst, dll.keysLruFirst(), op.text);
+ assertEquals(op.expectedLruFirst, lhm.keysLruFirst(), op.text);
+ dll.checkInvariants();
+
+ t.line(String.format("%-3d %-12s %-22s %-14s %-10d %s", ++n, op.text, op.expectedReturn,
+ dll.keysLruFirst().toString().replace(" ", ""), cost, "yes"));
+ }
+ t.blank();
+ t.line("ptr-writes = pointer assignments on list nodes made by HashMapLruCache for that operation.");
+ t.line("A miss costs 0, a hit costs 6 (unlink 2 + link 4), a new insert 4 (+2 for the eviction unlink). None depends on the cache size.");
+ t.flush();
+ }
+
+ private static String apply(String op, LruCache c) {
+ String args = op.substring(op.indexOf('(') + 1, op.length() - 1);
+ String[] p = args.split(",");
+ int key = Integer.parseInt(p[0]);
+ if (op.startsWith("put")) {
+ c.put(key, p[1]);
+ return "-";
+ }
+ String v = c.get(key);
+ return v == null ? "null" : v;
+ }
+}
diff --git a/lru-cache/src/test/java/com/ankurm/interviewlab/lrucache/LinkedHashMapBehaviourTest.java b/lru-cache/src/test/java/com/ankurm/interviewlab/lrucache/LinkedHashMapBehaviourTest.java
new file mode 100644
index 0000000..d07e824
--- /dev/null
+++ b/lru-cache/src/test/java/com/ankurm/interviewlab/lrucache/LinkedHashMapBehaviourTest.java
@@ -0,0 +1,65 @@
+package com.ankurm.interviewlab.lrucache;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import com.ankurm.interviewlab.lrucache.support.Transcript;
+import java.util.ConcurrentModificationException;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+/** Which LinkedHashMap calls count as an "access" in accessOrder=true mode, and the iteration trap. */
+class LinkedHashMapBehaviourTest {
+
+ private static LinkedHashMapLruCache.Lru filled() {
+ var m = new LinkedHashMapLruCache.Lru(3);
+ m.put(1, "a"); m.put(2, "b"); m.put(3, "c");
+ return m;
+ }
+
+ @Test
+ void whichCallsReorder() {
+ Transcript t = Transcript.start("03-linkedhashmap-access-order.txt",
+ "LinkedHashMap(accessOrder=true), start [1, 2, 3] (LRU -> MRU): does the call move key 1?");
+ record Case(String call, java.util.function.Consumer