diff --git a/lru-cache/README.md b/lru-cache/README.md new file mode 100644 index 0000000..927acfb --- /dev/null +++ b/lru-cache/README.md @@ -0,0 +1,32 @@ +# lru-cache + +Runnable companion for the ankurm.com article +[LRU Cache in Java: LinkedHashMap, HashMap + Doubly Linked List and a Thread-Safe Version](https://ankurm.com/lru-cache-in-java/). + +Three LRU caches, three thread-safe variants, a Caffeine reference, a deliberately broken +`ReadWriteLock` version, correctness and stress tests, and a short JMH comparison. + +| Component | Version | Verified against | +|---|---|---| +| JDK | 25.0.5+8 (Temurin, LTS) | `java -version` | +| JUnit | 6.1.3 | `maven-metadata.xml` on Maven Central | +| Caffeine | 3.3.1 | `maven-metadata.xml`; GitHub release page lists it as Latest | +| JMH | 1.37 | `maven-metadata.xml` | + +```bash +export JAVA_HOME=/path/to/jdk-25 && export PATH="$JAVA_HOME/bin:$PATH" +cd lru-cache +mvn test # 43 tests; rewrites output/01-08 +./scripts/run-all.sh # also regenerates output/09 (broken-lock demo) and 10 (JMH) +``` + +| Source | What it is | +|---|---| +| `LinkedHashMapLruCache` | `LinkedHashMap(accessOrder=true)` + `removeEldestEntry` | +| `HashMapLruCache` | `HashMap` + hand-rolled doubly linked list with sentinel head/tail | +| `SynchronizedLruCache`, `ReentrantLockLruCache` | one coarse lock around variant 2 | +| `StripedLruCache` | N independent segments, LRU per segment (not global) | +| `BrokenReadWriteLockLruCache`, `BrokenLockDemo` | the tempting wrong answer, and what it does | +| `CaffeineLru`, `bench/LruBenchmark` | production reference and the JMH harness | + +`output/09` and `output/10` are measurements from a shared 2-vCPU sandbox: indicative, not asserted. diff --git a/lru-cache/output/01-dry-run-capacity-3.txt b/lru-cache/output/01-dry-run-capacity-3.txt new file mode 100644 index 0000000..d012f93 --- /dev/null +++ b/lru-cache/output/01-dry-run-capacity-3.txt @@ -0,0 +1,17 @@ +Dry run, capacity 3: state after each operation (keys listed LRU -> MRU) +======================================================================== + +# operation returns LRU -> MRU ptr-writes same as LinkedHashMap? +1 put(1,A) - [1] 4 yes +2 put(2,B) - [1,2] 4 yes +3 put(3,C) - [1,2,3] 4 yes +4 get(1) A [2,3,1] 6 yes +5 put(4,D) - (evicts 2) [3,1,4] 6 yes +6 get(2) null [3,1,4] 0 yes +7 put(3,C2) - (replace, refresh) [1,4,3] 6 yes +8 put(5,E) - (evicts 1) [4,3,5] 6 yes +9 get(4) D [3,5,4] 6 yes +10 get(1) null [3,5,4] 0 yes + +ptr-writes = pointer assignments on list nodes made by HashMapLruCache for that operation. +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. diff --git a/lru-cache/output/02-constant-cost-per-operation.txt b/lru-cache/output/02-constant-cost-per-operation.txt new file mode 100644 index 0000000..e2c8493 --- /dev/null +++ b/lru-cache/output/02-constant-cost-per-operation.txt @@ -0,0 +1,9 @@ +Pointer writes per HashMapLruCache operation, by cache size +=========================================================== + +capacity get(hit) get(miss) put(new) put(new, evicts LRU) +10 6 0 4 6 +1000 6 0 4 6 +100000 6 0 4 6 + +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. diff --git a/lru-cache/output/03-linkedhashmap-access-order.txt b/lru-cache/output/03-linkedhashmap-access-order.txt new file mode 100644 index 0000000..ad1bff2 --- /dev/null +++ b/lru-cache/output/03-linkedhashmap-access-order.txt @@ -0,0 +1,12 @@ +LinkedHashMap(accessOrder=true), start [1, 2, 3] (LRU -> MRU): does the call move key 1? +======================================================================================== + +get(1) -> order [2, 3, 1] key 1 moved to MRU end: true +put(1, "x") (existing key) -> order [2, 3, 1] key 1 moved to MRU end: true +getOrDefault(1, "-") -> order [2, 3, 1] key 1 moved to MRU end: true +putIfAbsent(1, "x") (key present) -> order [2, 3, 1] key 1 moved to MRU end: true +computeIfAbsent(1, k -> "x") (key present) -> order [2, 3, 1] key 1 moved to MRU end: true +containsKey(1) -> order [1, 2, 3] key 1 moved to MRU end: false +keySet() iteration -> order [1, 2, 3] key 1 moved to MRU end: false +entrySet() iteration -> order [1, 2, 3] key 1 moved to MRU end: false +size() -> order [1, 2, 3] key 1 moved to MRU end: false diff --git a/lru-cache/output/04-get-is-structural.txt b/lru-cache/output/04-get-is-structural.txt new file mode 100644 index 0000000..dc23a35 --- /dev/null +++ b/lru-cache/output/04-get-is-structural.txt @@ -0,0 +1,7 @@ +In access-order mode get() is a structural modification (single thread, no concurrency) +======================================================================================= + +for (Integer k : accessOrderMap.keySet()) accessOrderMap.get(k); +-> java.util.ConcurrentModificationException + +same loop on an insertion-order LinkedHashMap -> completes normally diff --git a/lru-cache/output/05-concurrency-stress.txt b/lru-cache/output/05-concurrency-stress.txt new file mode 100644 index 0000000..b8eb540 --- /dev/null +++ b/lru-cache/output/05-concurrency-stress.txt @@ -0,0 +1,9 @@ +8 threads x 60000 ops (80% get / 20% put), capacity 64, 256 keys +================================================================ + +implementation exceptions wrong values size>capacity seen? invariants post-stress LRU check +SynchronizedLruCache 0 0 no ok ok +ReentrantLockLruCache 0 0 no ok ok +StripedLruCache(8) 0 0 no ok n/a (per-segment LRU) + +Hit/miss counts are deliberately not printed: they differ on every run and nothing asserts them. diff --git a/lru-cache/output/06-striped-is-not-global-lru.txt b/lru-cache/output/06-striped-is-not-global-lru.txt new file mode 100644 index 0000000..41a2c1d --- /dev/null +++ b/lru-cache/output/06-striped-is-not-global-lru.txt @@ -0,0 +1,7 @@ +StripedLruCache(capacity 4, 2 stripes): even keys -> stripe 0, odd keys -> stripe 1 +=================================================================================== + +put(1) put(0) put(2) put(4) // key 1 is the globally least recently used +get(0) -> null (evicted: stripe 0 held {0,2} and was full) +get(1) -> present (a strict global LRU would have evicted it instead) +size() = 3 of capacity 4 (evicted while not full) diff --git a/lru-cache/output/07-caffeine-same-ops.txt b/lru-cache/output/07-caffeine-same-ops.txt new file mode 100644 index 0000000..674a620 --- /dev/null +++ b/lru-cache/output/07-caffeine-same-ops.txt @@ -0,0 +1,15 @@ +Caffeine 3.3.1 maximumSize(3) given the dry-run operations (same-thread executor, cleanUp after each) +==================================================================================================== + +put(1,A) -> resident keys (unordered): [1] +put(2,B) -> resident keys (unordered): [1, 2] +put(3,C) -> resident keys (unordered): [1, 2, 3] +get(1) -> resident keys (unordered): [1, 2, 3] +put(4,D) -> resident keys (unordered): [1, 2, 4] +get(2) -> resident keys (unordered): [1, 2, 4] +put(3,C2) -> resident keys (unordered): [1, 2, 3] +put(5,E) -> resident keys (unordered): [1, 2, 5] +get(4) -> resident keys (unordered): [1, 2, 5] +get(1) -> resident keys (unordered): [1, 2, 5] + +A strict LRU of capacity 3 ends this sequence with [3, 5, 4] (see 01-dry-run-capacity-3.txt). diff --git a/lru-cache/output/08-stripe-selector-buckets.txt b/lru-cache/output/08-stripe-selector-buckets.txt new file mode 100644 index 0000000..7c337ee --- /dev/null +++ b/lru-cache/output/08-stripe-selector-buckets.txt @@ -0,0 +1,9 @@ +Keys 0..2047 that land in segment 0 of 16: how many distinct buckets of that segment's 128-slot HashMap do they use? +==================================================================================================== + +(bucket = (h ^ (h >>> 16)) & 127, the formula java.util.HashMap uses; computed arithmetically, not by reflection) + +LOW_BITS keys in segment 0: 128 buckets used: 8 of 128 longest bucket: 16 keys +MIXED keys in segment 0: 129 buckets used: 65 of 128 longest bucket: 4 keys + +Each of those 8 buckets holds 16 keys: past the treeify threshold of 8 (the table has >= 64 slots), so java.util.HashMap turns them into trees. diff --git a/lru-cache/output/09-broken-readwritelock-demo.txt b/lru-cache/output/09-broken-readwritelock-demo.txt new file mode 100644 index 0000000..e344d43 --- /dev/null +++ b/lru-cache/output/09-broken-readwritelock-demo.txt @@ -0,0 +1,18 @@ +cores=2 threads=4 opsPerThread=400000 capacity=64 keys=128 +workload: 90% get, 10% put, uniform random keys + +round 1 + ReadWriteLock, get under READ lock worker exceptions=0; size=64/64; INVARIANT BROKEN: list length 17 != index size 64 + synchronized (correct) worker exceptions=0; size=64/64; invariants OK +round 2 + ReadWriteLock, get under READ lock worker exceptions=0; size=64/64; INVARIANT BROKEN: list node not in index: 94 + synchronized (correct) worker exceptions=0; size=64/64; invariants OK +round 3 + ReadWriteLock, get under READ lock worker exceptions=0; size=64/64; invariants OK + synchronized (correct) worker exceptions=0; size=64/64; invariants OK +round 4 + ReadWriteLock, get under READ lock worker exceptions=0; size=64/64; INVARIANT BROKEN: list node not in index: 125 + synchronized (correct) worker exceptions=0; size=64/64; invariants OK +round 5 + ReadWriteLock, get under READ lock worker exceptions=0; size=64/64; invariants OK + synchronized (correct) worker exceptions=0; size=64/64; invariants OK diff --git a/lru-cache/output/10-jmh-indicative.txt b/lru-cache/output/10-jmh-indicative.txt new file mode 100644 index 0000000..a56b700 --- /dev/null +++ b/lru-cache/output/10-jmh-indicative.txt @@ -0,0 +1,32 @@ +JMH 1.37 / openjdk version "25.0.5" 2026-10-20 LTS +cores=2 loadavg-at-start=24.40 26.08 18.95 (shared sandbox: INDICATIVE) +workload: get; on miss put. Keys uniform over 2048, capacity 1024. ops/microsecond, higher is better. + +### 1 thread, all implementations +Benchmark (impl) Mode Cnt Score Error Units +LruBenchmark.lookupThenFillOnMiss linkedhashmap-unsafe thrpt 5 4.908 ± 0.912 ops/us +LruBenchmark.lookupThenFillOnMiss dll-unsafe thrpt 5 3.946 ± 3.172 ops/us +LruBenchmark.lookupThenFillOnMiss synchronizedMap(LinkedHashMap) thrpt 5 2.619 ± 3.358 ops/us +LruBenchmark.lookupThenFillOnMiss synchronized thrpt 5 2.832 ± 1.957 ops/us +LruBenchmark.lookupThenFillOnMiss reentrantlock thrpt 5 3.089 ± 1.593 ops/us +LruBenchmark.lookupThenFillOnMiss striped16 thrpt 5 2.389 ± 1.758 ops/us +LruBenchmark.lookupThenFillOnMiss striped16-lowbits thrpt 5 1.337 ± 0.436 ops/us +LruBenchmark.lookupThenFillOnMiss caffeine thrpt 5 1.307 ± 2.076 ops/us + +### 2 threads, thread-safe implementations only +Benchmark (impl) Mode Cnt Score Error Units +LruBenchmark.lookupThenFillOnMiss synchronizedMap(LinkedHashMap) thrpt 5 2.807 ± 1.557 ops/us +LruBenchmark.lookupThenFillOnMiss synchronized thrpt 5 2.609 ± 1.794 ops/us +LruBenchmark.lookupThenFillOnMiss reentrantlock thrpt 5 2.665 ± 2.561 ops/us +LruBenchmark.lookupThenFillOnMiss striped16 thrpt 5 1.769 ± 1.727 ops/us +LruBenchmark.lookupThenFillOnMiss striped16-lowbits thrpt 5 1.048 ± 0.400 ops/us +LruBenchmark.lookupThenFillOnMiss caffeine thrpt 5 2.248 ± 4.427 ops/us + +### 4 threads, thread-safe implementations only +Benchmark (impl) Mode Cnt Score Error Units +LruBenchmark.lookupThenFillOnMiss synchronizedMap(LinkedHashMap) thrpt 5 5.475 ± 5.365 ops/us +LruBenchmark.lookupThenFillOnMiss synchronized thrpt 5 4.926 ± 3.010 ops/us +LruBenchmark.lookupThenFillOnMiss reentrantlock thrpt 5 4.230 ± 3.008 ops/us +LruBenchmark.lookupThenFillOnMiss striped16 thrpt 5 2.399 ± 1.785 ops/us +LruBenchmark.lookupThenFillOnMiss striped16-lowbits thrpt 5 1.256 ± 0.635 ops/us +LruBenchmark.lookupThenFillOnMiss caffeine thrpt 5 1.821 ± 1.796 ops/us diff --git a/lru-cache/pom.xml b/lru-cache/pom.xml new file mode 100644 index 0000000..2014850 --- /dev/null +++ b/lru-cache/pom.xml @@ -0,0 +1,91 @@ + + + 4.0.0 + com.ankurm.interviewlab + lru-cache + 1.0.0 + jar + + java-interview-lab :: lru-cache + + Runnable companion code for ankurm.com's "LRU Cache in Java" article: LinkedHashMap, HashMap plus + a hand-rolled doubly linked list, three thread-safe variants, a Caffeine reference, correctness + and stress tests, and a short JMH comparison. + + + + 25 + UTF-8 + 6.1.3 + 1.37 + 3.3.1 + + + + + + org.junit + junit-bom + ${junit.version} + pom + import + + + + + + + com.github.ben-manes.caffeine + caffeine + ${caffeine.version} + + + org.openjdk.jmh + jmh-core + ${jmh.version} + + + org.junit.jupiter + junit-jupiter + test + + + org.junit.platform + junit-platform-launcher + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.0 + + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.6.0 + + -Xmx512m -Duser.timezone=UTC -Dfile.encoding=UTF-8 + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.8.1 + + + + diff --git a/lru-cache/scripts/run-all.sh b/lru-cache/scripts/run-all.sh new file mode 100755 index 0000000..abe4494 --- /dev/null +++ b/lru-cache/scripts/run-all.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Regenerates every file in output/. Needs JDK 25 and Maven. First Maven run must be online. +# 01-08 are written by JUnit tests that assert the same facts (deterministic). +# 09 (broken ReadWriteLock demo) and 10 (JMH) are MEASUREMENTS: they vary per run and machine. +set -euo pipefail +cd "$(dirname "$0")/.." +mkdir -p output +mvn -B -q -T1 test +mvn -B -q -T1 compile +{ java -cp target/classes com.ankurm.interviewlab.lrucache.BrokenLockDemo 2>&1 | grep -v '^Picked up JAVA_TOOL_OPTIONS' ; } > output/09-broken-readwritelock-demo.txt +scripts/run-jmh.sh > /dev/null +echo "done:"; ls -1 output diff --git a/lru-cache/scripts/run-jmh.sh b/lru-cache/scripts/run-jmh.sh new file mode 100755 index 0000000..438865c --- /dev/null +++ b/lru-cache/scripts/run-jmh.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Short JMH run: INDICATIVE only. One fork, 3 x 1s warmup, 5 x 2s measurement per cell. +# On a shared 2-vCPU sandbox the absolute numbers move 10-30% between runs; read the ORDER and +# the SHAPE (who collapses with more threads), not the third digit. +set -euo pipefail +cd "$(dirname "$0")/.." +mkdir -p output +mvn -B -q -T1 compile +mvn -B -q dependency:build-classpath -Dmdep.outputFile=target/cp.txt +CP="target/classes:$(cat target/cp.txt)" +J="java -cp $CP org.openjdk.jmh.Main LruBenchmark -f 1 -wi 3 -w 1 -i 5 -r 2 -tu us" +{ + echo "JMH 1.37 / $(java -version 2>&1 | grep -v Picked | head -1)" + echo "cores=$(nproc) loadavg-at-start=$(cut -d' ' -f1-3 /proc/loadavg) (shared sandbox: INDICATIVE)" + echo "workload: get; on miss put. Keys uniform over 2048, capacity 1024. ops/microsecond, higher is better." + echo + echo "### 1 thread, all implementations" + $J -t 1 2>&1 | grep -E '^LruBenchmark|^Benchmark' + for t in 2 4; do + echo + echo "### $t threads, thread-safe implementations only" + $J -t $t -p "impl=synchronizedMap(LinkedHashMap),synchronized,reentrantlock,striped16,striped16-lowbits,caffeine" 2>&1 | grep -E '^LruBenchmark|^Benchmark' + done +} > output/10-jmh-indicative.txt +cat output/10-jmh-indicative.txt diff --git a/lru-cache/src/main/java/com/ankurm/interviewlab/lrucache/BrokenLockDemo.java b/lru-cache/src/main/java/com/ankurm/interviewlab/lrucache/BrokenLockDemo.java new file mode 100644 index 0000000..708c744 --- /dev/null +++ b/lru-cache/src/main/java/com/ankurm/interviewlab/lrucache/BrokenLockDemo.java @@ -0,0 +1,88 @@ +package com.ankurm.interviewlab.lrucache; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Hammers {@link BrokenReadWriteLockLruCache} (read lock around get) and, for contrast, + * {@link SynchronizedLruCache} with the same workload, then runs the structural invariant check. + * The broken result is a MEASUREMENT of a race: it depends on timing and core count and can + * (rarely) come out clean. Run it a few times; run-all.sh runs five rounds. + */ +public final class BrokenLockDemo { + static final int THREADS = 4, OPS = 400_000, CAPACITY = 64, KEYS = 128; + + interface Checked extends LruCache { void checkInvariants(); } + + public static void main(String[] args) throws Exception { + System.out.println("cores=" + Runtime.getRuntime().availableProcessors() + + " threads=" + THREADS + " opsPerThread=" + OPS + " capacity=" + CAPACITY + " keys=" + KEYS); + System.out.println("workload: 90% get, 10% put, uniform random keys"); + System.out.println(); + for (int round = 1; round <= 5; round++) { + System.out.println("round " + round); + report(" ReadWriteLock, get under READ lock ", wrap(new BrokenReadWriteLockLruCache<>(CAPACITY))); + report(" synchronized (correct) ", wrap(new SynchronizedLruCache<>(CAPACITY))); + } + } + + static Checked wrap(BrokenReadWriteLockLruCache c) { + return new Checked() { + public Integer get(Integer k) { return c.get(k); } + public void put(Integer k, Integer v) { c.put(k, v); } + public int size() { return c.size(); } + public int capacity() { return c.capacity(); } + public java.util.List keysLruFirst() { return c.keysLruFirst(); } + public void checkInvariants() { c.checkInvariants(); } + }; + } + + static Checked wrap(SynchronizedLruCache c) { + return new Checked() { + public Integer get(Integer k) { return c.get(k); } + public void put(Integer k, Integer v) { c.put(k, v); } + public int size() { return c.size(); } + public int capacity() { return c.capacity(); } + public java.util.List keysLruFirst() { return c.keysLruFirst(); } + public void checkInvariants() { c.checkInvariants(); } + }; + } + + static void report(String label, Checked cache) throws Exception { + AtomicInteger exceptions = new AtomicInteger(); + String[] firstException = new String[1]; + CountDownLatch go = new CountDownLatch(1); + Thread[] ts = new Thread[THREADS]; + for (int t = 0; t < THREADS; t++) { + ts[t] = new Thread(() -> { + try { go.await(); } catch (InterruptedException e) { return; } + ThreadLocalRandom r = ThreadLocalRandom.current(); + for (int i = 0; i < OPS; i++) { + int k = r.nextInt(KEYS); + try { + if (r.nextInt(10) == 0) cache.put(k, k); else cache.get(k); + } catch (Throwable e) { + if (exceptions.getAndIncrement() == 0) firstException[0] = e.toString(); + return; + } + } + }); + ts[t].start(); + } + go.countDown(); + for (Thread t : ts) t.join(); + String verdict; + try { + cache.checkInvariants(); + verdict = "invariants OK"; + } catch (Throwable e) { + verdict = "INVARIANT BROKEN: " + e.getMessage(); + } + System.out.println(label + " worker exceptions=" + exceptions.get() + + (firstException[0] != null ? " (first: " + firstException[0] + ")" : "") + + "; size=" + safeSize(cache) + "/" + CAPACITY + "; " + verdict); + } + + static String safeSize(Checked c) { return String.valueOf(c.size()); } +} diff --git a/lru-cache/src/main/java/com/ankurm/interviewlab/lrucache/BrokenReadWriteLockLruCache.java b/lru-cache/src/main/java/com/ankurm/interviewlab/lrucache/BrokenReadWriteLockLruCache.java new file mode 100644 index 0000000..4dc9ea7 --- /dev/null +++ b/lru-cache/src/main/java/com/ankurm/interviewlab/lrucache/BrokenReadWriteLockLruCache.java @@ -0,0 +1,30 @@ +package com.ankurm.interviewlab.lrucache; + +import java.util.List; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +/** + * DELIBERATELY BROKEN. The tempting "reads are common, so use a ReadWriteLock" design. + * {@code get} takes only the READ lock, so several threads run {@code delegate.get} at once, and + * {@code get} rewrites four list pointers. Concurrent pointer rewrites corrupt the list. + * {@link BrokenLockDemo} shows what that looks like. Never use this class. + */ +public final class BrokenReadWriteLockLruCache implements LruCache { + private final HashMapLruCache delegate; + private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock(); + + public BrokenReadWriteLockLruCache(int capacity) { this.delegate = new HashMapLruCache<>(capacity); } + + @Override public V get(K key) { + rw.readLock().lock(); // BUG: get() is a write to the recency list + try { return delegate.get(key); } finally { rw.readLock().unlock(); } + } + @Override public void put(K key, V value) { + rw.writeLock().lock(); + try { delegate.put(key, value); } finally { rw.writeLock().unlock(); } + } + @Override public int size() { return delegate.size(); } + @Override public int capacity() { return delegate.capacity(); } + @Override public List keysLruFirst() { return delegate.keysLruFirst(); } + public void checkInvariants() { delegate.checkInvariants(); } +} diff --git a/lru-cache/src/main/java/com/ankurm/interviewlab/lrucache/CaffeineLru.java b/lru-cache/src/main/java/com/ankurm/interviewlab/lrucache/CaffeineLru.java new file mode 100644 index 0000000..dc64aad --- /dev/null +++ b/lru-cache/src/main/java/com/ankurm/interviewlab/lrucache/CaffeineLru.java @@ -0,0 +1,35 @@ +package com.ankurm.interviewlab.lrucache; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import java.util.List; +import java.util.Objects; + +/** + * Adapter that lets Caffeine, the production answer, stand next to the hand-written caches in the + * tests and benchmark. Caffeine is a bounded cache, not a strict LRU: it uses the Window TinyLFU + * admission policy and does its bookkeeping asynchronously, so {@code size()} can briefly exceed the + * maximum until maintenance runs, and the victim is not always the least recently used key. + */ +public final class CaffeineLru implements LruCache { + private final Cache cache; + private final int capacity; + + /** @param sameThread true runs maintenance on the caller thread: deterministic, for tests. */ + public CaffeineLru(int capacity, boolean sameThread) { + this.capacity = capacity; + Caffeine b = Caffeine.newBuilder().maximumSize(capacity); + if (sameThread) b.executor(Runnable::run); + this.cache = b.build(); + } + + public CaffeineLru(int capacity) { this(capacity, false); } + + @Override public V get(K key) { return cache.getIfPresent(Objects.requireNonNull(key)); } + @Override public void put(K key, V value) { cache.put(Objects.requireNonNull(key), Objects.requireNonNull(value)); } + @Override public int size() { cache.cleanUp(); return (int) cache.estimatedSize(); } + @Override public int capacity() { return capacity; } + @Override public List 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"): + *

    + *
  1. no worker threw;
  2. + *
  3. a get never returns a value that belongs to a different key (value is a pure function of key);
  4. + *
  5. a monitor thread never saw size() above capacity;
  6. + *
  7. the internal list/index invariants hold at the end;
  8. + *
  9. afterwards the cache still behaves as a correct LRU (for the global-LRU implementations).
  10. + *
+ */ +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> action, boolean moves) {} + Case[] cases = { + new Case("get(1)", m -> m.get(1), true), + new Case("put(1, \"x\") (existing key)", m -> m.put(1, "x"), true), + new Case("getOrDefault(1, \"-\")", m -> m.getOrDefault(1, "-"), true), + new Case("putIfAbsent(1, \"x\") (key present)", m -> m.putIfAbsent(1, "x"), true), + new Case("computeIfAbsent(1, k -> \"x\") (key present)", m -> m.computeIfAbsent(1, k -> "x"), true), + new Case("containsKey(1)", m -> m.containsKey(1), false), + new Case("keySet() iteration", m -> { for (Integer ignored : m.keySet()) { } }, false), + new Case("entrySet() iteration", m -> { for (var ignored : m.entrySet()) { } }, false), + new Case("size()", Map::size, false), + }; + for (Case c : cases) { + var m = filled(); + c.action.accept(m); + boolean moved = !m.keySet().iterator().next().equals(1) || m.keySet().stream().toList().indexOf(1) != 0; + t.line(String.format("%-52s -> order %-10s key 1 moved to MRU end: %s", c.call, m.keySet(), moved)); + assertEquals(c.moves, moved, c.call); + } + t.flush(); + } + + @Test + void getWhileIteratingThrowsConcurrentModificationException() { + var m = filled(); + ConcurrentModificationException cme = assertThrows(ConcurrentModificationException.class, () -> { + for (Integer k : m.keySet()) { + m.get(k); // a plain read, on ONE thread, no other thread involved + } + }); + var plain = new java.util.LinkedHashMap(); + plain.put(1, "a"); plain.put(2, "b"); plain.put(3, "c"); + for (Integer k : plain.keySet()) plain.get(k); // insertion-order map: reads are harmless + + Transcript.start("04-get-is-structural.txt", + "In access-order mode get() is a structural modification (single thread, no concurrency)") + .line("for (Integer k : accessOrderMap.keySet()) accessOrderMap.get(k);") + .line("-> " + cme.getClass().getName()) + .blank() + .line("same loop on an insertion-order LinkedHashMap -> completes normally") + .flush(); + } +} diff --git a/lru-cache/src/test/java/com/ankurm/interviewlab/lrucache/LruContractTest.java b/lru-cache/src/test/java/com/ankurm/interviewlab/lrucache/LruContractTest.java new file mode 100644 index 0000000..9c203a0 --- /dev/null +++ b/lru-cache/src/test/java/com/ankurm/interviewlab/lrucache/LruContractTest.java @@ -0,0 +1,118 @@ +package com.ankurm.interviewlab.lrucache; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.function.IntFunction; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.Arguments; + +/** Every implementation with strict global LRU semantics must pass the same contract. */ +class LruContractTest { + + record Impl(String name, IntFunction> factory) { + @Override public String toString() { return name; } + } + + static Stream impls() { + return Stream.of( + new Impl("LinkedHashMapLruCache", LinkedHashMapLruCache::new), + new Impl("HashMapLruCache", HashMapLruCache::new), + new Impl("SynchronizedLruCache", SynchronizedLruCache::new), + new Impl("ReentrantLockLruCache", ReentrantLockLruCache::new), + new Impl("StripedLruCache(1 stripe)", c -> new StripedLruCache<>(c, 1))) + .map(Arguments::of); + } + + @ParameterizedTest @MethodSource("impls") + void evictsLeastRecentlyUsed(Impl impl) { + LruCache c = impl.factory.apply(3); + c.put(1, "a"); c.put(2, "b"); c.put(3, "c"); + c.put(4, "d"); // evicts 1 + assertNull(c.get(1)); + assertEquals(List.of(2, 3, 4), c.keysLruFirst()); + assertEquals(3, c.size()); + } + + @ParameterizedTest @MethodSource("impls") + void getRefreshesRecency(Impl impl) { + LruCache c = impl.factory.apply(3); + c.put(1, "a"); c.put(2, "b"); c.put(3, "c"); + assertEquals("a", c.get(1)); // 1 is now MRU, 2 is LRU + c.put(4, "d"); // evicts 2, not 1 + assertNull(c.get(2)); + assertEquals("a", c.get(1)); + assertEquals(List.of(3, 4, 1), c.keysLruFirst()); + } + + @ParameterizedTest @MethodSource("impls") + void putExistingKeyReplacesValueAndRefreshes(Impl impl) { + LruCache c = impl.factory.apply(2); + c.put(1, "a"); c.put(2, "b"); + c.put(1, "A"); // replace, no eviction, 1 is MRU + assertEquals(2, c.size()); + c.put(3, "c"); // evicts 2 + assertNull(c.get(2)); + assertEquals("A", c.get(1)); + assertEquals("c", c.get(3)); + } + + @ParameterizedTest @MethodSource("impls") + void capacityOneKeepsOnlyTheLastKey(Impl impl) { + LruCache c = impl.factory.apply(1); + c.put(1, "a"); c.put(2, "b"); + assertNull(c.get(1)); + assertEquals("b", c.get(2)); + c.put(2, "B"); + assertEquals(1, c.size()); + assertEquals("B", c.get(2)); + } + + @ParameterizedTest @MethodSource("impls") + void missReturnsNullAndDoesNotChangeOrder(Impl impl) { + LruCache c = impl.factory.apply(2); + c.put(1, "a"); c.put(2, "b"); + assertNull(c.get(99)); + assertEquals(List.of(1, 2), c.keysLruFirst()); + } + + @ParameterizedTest @MethodSource("impls") + void rejectsNullsAndBadCapacity(Impl impl) { + LruCache c = impl.factory.apply(2); + assertThrows(NullPointerException.class, () -> c.get(null)); + assertThrows(NullPointerException.class, () -> c.put(null, "x")); + assertThrows(NullPointerException.class, () -> c.put(1, null)); + assertThrows(IllegalArgumentException.class, () -> impl.factory.apply(0)); + assertThrows(IllegalArgumentException.class, () -> impl.factory.apply(-5)); + } + + /** Random operations compared against an obviously-correct O(n) list model. */ + @ParameterizedTest @MethodSource("impls") + void matchesReferenceModelOverRandomOperations(Impl impl) { + for (int capacity : new int[] {1, 2, 5, 17}) { + LruCache c = impl.factory.apply(capacity); + List model = new ArrayList<>(); // LRU first + Random rnd = new Random(42L + capacity); + for (int i = 0; i < 20_000; i++) { + int k = rnd.nextInt(capacity * 3); + if (rnd.nextInt(3) == 0) { + c.put(k, "v" + k); + model.remove((Integer) k); + model.add(k); + if (model.size() > capacity) model.removeFirst(); + } else { + String got = c.get(k); + boolean present = model.remove((Integer) k); + if (present) model.add(k); + assertEquals(present ? "v" + k : null, got, "capacity " + capacity + " op " + i); + } + if (i % 1000 == 0) assertEquals(model, c.keysLruFirst()); + } + assertEquals(model, c.keysLruFirst()); + } + } +} diff --git a/lru-cache/src/test/java/com/ankurm/interviewlab/lrucache/StripedAndCaffeineTest.java b/lru-cache/src/test/java/com/ankurm/interviewlab/lrucache/StripedAndCaffeineTest.java new file mode 100644 index 0000000..a3071b9 --- /dev/null +++ b/lru-cache/src/test/java/com/ankurm/interviewlab/lrucache/StripedAndCaffeineTest.java @@ -0,0 +1,83 @@ +package com.ankurm.interviewlab.lrucache; + +import static org.junit.jupiter.api.Assertions.*; + +import com.ankurm.interviewlab.lrucache.support.Transcript; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import java.util.List; +import org.junit.jupiter.api.Test; + +class StripedAndCaffeineTest { + + /** Striping trades exact global LRU for less contention. This is the concrete price. */ + @Test + void stripedEvictsWithinASegmentNotGlobally() { + // capacity 4, 2 stripes -> 2 slots per stripe. LOW_BITS selector on small Integer keys: even -> stripe 0, odd -> stripe 1. + StripedLruCache c = new StripedLruCache<>(4, 2, StripedLruCache.Selector.LOW_BITS); + c.put(1, "oldest overall (stripe 1)"); + c.put(0, "a"); + c.put(2, "b"); + c.put(4, "c"); // stripe 0 is full: evicts key 0, although key 1 is older overall + assertNull(c.get(0)); + assertNotNull(c.get(1)); // a global LRU would have evicted key 1 + assertEquals(3, c.size()); // and the cache is not even full + Transcript.start("06-striped-is-not-global-lru.txt", + "StripedLruCache(capacity 4, 2 stripes): even keys -> stripe 0, odd keys -> stripe 1") + .line("put(1) put(0) put(2) put(4) // key 1 is the globally least recently used") + .line("get(0) -> null (evicted: stripe 0 held {0,2} and was full)") + .line("get(1) -> present (a strict global LRU would have evicted it instead)") + .line("size() = " + c.size() + " of capacity " + c.capacity() + " (evicted while not full)") + .flush(); + } + + @Test + void caffeineIsBoundedButNotStrictLru() { + // Same operations as the dry-run table, executed on the caller thread so the result is deterministic. + Cache c = Caffeine.newBuilder().maximumSize(3).executor(Runnable::run).build(); + Transcript t = Transcript.start("07-caffeine-same-ops.txt", + "Caffeine 3.3.1 maximumSize(3) given the dry-run operations (same-thread executor, cleanUp after each)"); + String[] ops = {"put(1,A)", "put(2,B)", "put(3,C)", "get(1)", "put(4,D)", "get(2)", "put(3,C2)", "put(5,E)", "get(4)", "get(1)"}; + for (String op : ops) { + int k = Integer.parseInt(op.substring(op.indexOf('(') + 1, op.indexOf(op.contains(",") ? ',' : ')'))); + if (op.startsWith("put")) c.put(k, op.substring(op.indexOf(',') + 1, op.length() - 1)); + else c.getIfPresent(k); + c.cleanUp(); + assertTrue(c.estimatedSize() <= 3, op); + t.line(String.format("%-10s -> resident keys (unordered): %s", op, new java.util.TreeSet<>(c.asMap().keySet()))); + } + t.blank(); + t.line("A strict LRU of capacity 3 ends this sequence with [3, 5, 4] (see 01-dry-run-capacity-3.txt)."); + t.flush(); + } + + /** Same low bits for stripe choice and inner bucket choice means most buckets stay empty. */ + @Test + void naiveStripeSelectorCrowdsEachSegmentsHashMapIntoFewBuckets() { + int stripes = 16, capacity = 1024; + int perStripe = capacity / stripes; // 64 + int table = 1 << (32 - Integer.numberOfLeadingZeros((int) (perStripe / 0.75f))); // HashMapLruCache asks for 86 -> table 128 + assertEquals(128, table); + Transcript t = Transcript.start("08-stripe-selector-buckets.txt", + "Keys 0..2047 that land in segment 0 of 16: how many distinct buckets of that segment's 128-slot HashMap do they use?"); + t.line("(bucket = (h ^ (h >>> 16)) & 127, the formula java.util.HashMap uses; computed arithmetically, not by reflection)"); + t.blank(); + for (var sel : StripedLruCache.Selector.values()) { + int[] perBucket = new int[table]; + int keys = 0; + for (int k = 0; k < 2048; k++) { + if (sel.select(k, stripes) != 0) continue; + keys++; + perBucket[(k ^ (k >>> 16)) & (table - 1)]++; + } + int used = 0, max = 0; + for (int n : perBucket) { if (n > 0) used++; max = Math.max(max, n); } + t.line(String.format("%-9s keys in segment 0: %3d buckets used: %3d of %d longest bucket: %2d keys", sel, keys, used, table, max)); + if (sel == StripedLruCache.Selector.LOW_BITS) { assertEquals(8, used); assertEquals(16, max); } + else { assertTrue(used > 60, "MIXED should spread keys: " + used); assertTrue(max <= 6, "max " + max); } + } + t.blank(); + t.line("Each of those 8 buckets holds 16 keys: past the treeify threshold of 8 (the table has >= 64 slots), so java.util.HashMap turns them into trees."); + t.flush(); + } +} diff --git a/lru-cache/src/test/java/com/ankurm/interviewlab/lrucache/support/Transcript.java b/lru-cache/src/test/java/com/ankurm/interviewlab/lrucache/support/Transcript.java new file mode 100644 index 0000000..fab587e --- /dev/null +++ b/lru-cache/src/test/java/com/ankurm/interviewlab/lrucache/support/Transcript.java @@ -0,0 +1,43 @@ +package com.ankurm.interviewlab.lrucache.support; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** Writes output/NN-*.txt while the calling test asserts the same numbers it prints. */ +public final class Transcript { + private final Path path; + private final List lines = new ArrayList<>(); + + private Transcript(Path path, String header) { + this.path = path; + lines.add(header); + lines.add("=".repeat(Math.min(header.length(), 100))); + lines.add(""); + } + + public static Transcript start(String fileName, String header) { + Path dir = Path.of("output"); + try { + Files.createDirectories(dir); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + return new Transcript(dir.resolve(fileName), header); + } + + public Transcript line(String text) { lines.add(text); return this; } + public Transcript blank() { lines.add(""); return this; } + + public void flush() { + try { + Files.write(path, (String.join("\n", lines) + "\n").getBytes(StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +}