Add lru-cache module: LinkedHashMap and hand-rolled DLL LRU, coarse-lock/striped thread-safe variants, broken ReadWriteLock demo, Caffeine reference, 43 tests, JMH harness

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ic4b4VQVEJWetTT1dYzyJ
This commit is contained in:
2026-10-29 05:52:27 +00:00
co-authored by Claude Sonnet 5.5
parent 4a376b94e9
commit 0457cf4bfb
31 changed files with 1425 additions and 0 deletions
+32
View File
@@ -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.
@@ -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.
@@ -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.
@@ -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
@@ -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
@@ -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.
@@ -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)
+15
View File
@@ -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).
@@ -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.
@@ -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
+32
View File
@@ -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
+91
View File
@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.ankurm.interviewlab</groupId>
<artifactId>lru-cache</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>java-interview-lab :: lru-cache</name>
<description>
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.
</description>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit.version>6.1.3</junit.version>
<jmh.version>1.37</jmh.version>
<caffeine.version>3.3.1</caffeine.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>${junit.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>${caffeine.version}</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>${jmh.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-launcher</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.0</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>${jmh.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.6.0</version>
<configuration>
<argLine>-Xmx512m -Duser.timezone=UTC -Dfile.encoding=UTF-8</argLine>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.8.1</version>
</plugin>
</plugins>
</build>
</project>
+12
View File
@@ -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
+25
View File
@@ -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
@@ -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<Integer, Integer> { 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<Integer, Integer> 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<Integer> keysLruFirst() { return c.keysLruFirst(); }
public void checkInvariants() { c.checkInvariants(); }
};
}
static Checked wrap(SynchronizedLruCache<Integer, Integer> 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<Integer> 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()); }
}
@@ -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<K, V> implements LruCache<K, V> {
private final HashMapLruCache<K, V> 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<K> keysLruFirst() { return delegate.keysLruFirst(); }
public void checkInvariants() { delegate.checkInvariants(); }
}
@@ -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<K, V> implements LruCache<K, V> {
private final Cache<K, V> 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<Object, Object> 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<K> keysLruFirst() {
throw new UnsupportedOperationException("Caffeine does not expose recency order");
}
}
@@ -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".
*
* <p>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.
*
* <pre>
* head(sentinel) <-> MRU <-> ... <-> LRU <-> tail(sentinel)
* </pre>
*
* <p>NOT thread-safe by itself. The thread-safe variants in this package wrap it.
*/
public class HashMapLruCache<K, V> implements LruCache<K, V> {
static final class Node<K, V> {
K key;
V value;
Node<K, V> prev, next;
Node(K key, V value) { this.key = key; this.value = value; }
}
private final int capacity;
private final Map<K, Node<K, V>> index;
private final Node<K, V> head = new Node<>(null, null);
private final Node<K, V> 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<K, V> 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<K, V> 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<K, V> 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<K, V> n) {
n.prev.next = n.next;
n.next.prev = n.prev;
pointerWrites += 2;
}
private void linkAfterHead(Node<K, V> 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<K> keysLruFirst() {
List<K> out = new ArrayList<>(index.size());
for (Node<K, V> 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<K, V> prev = head;
for (Node<K, V> 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);
}
}
@@ -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.
*
* <p>NOT thread-safe. See {@link SynchronizedLruCache} and friends.
* Explained in the article under "Implementation 1".
*/
public final class LinkedHashMapLruCache<K, V> implements LruCache<K, V> {
private final int capacity;
private final Lru<K, V> 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<K, V> extends LinkedHashMap<K, V> {
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<K, V> 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<K> keysLruFirst() { return new ArrayList<>(map.keySet()); }
/** Exposed for the "get() is a structural modification" demo. */
Map<K, V> rawMap() { return map; }
}
@@ -0,0 +1,30 @@
package com.ankurm.interviewlab.lrucache;
import java.util.List;
/**
* The contract every implementation in this module honours.
*
* <ul>
* <li>Fixed capacity, set at construction. Inserting a new key into a full cache evicts the
* <em>least recently used</em> entry.</li>
* <li>"Used" means {@code get} that hits, or {@code put} (new key or existing key).</li>
* <li>Null keys and null values are rejected with {@link NullPointerException}, so
* {@code get(k) == null} unambiguously means "absent".</li>
* </ul>
*/
public interface LruCache<K, V> {
/** 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<K> keysLruFirst();
}
@@ -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<K, V> implements LruCache<K, V> {
private final HashMapLruCache<K, V> 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<K> keysLruFirst() {
lock.lock();
try { return delegate.keysLruFirst(); } finally { lock.unlock(); }
}
public void checkInvariants() {
lock.lock();
try { delegate.checkInvariants(); } finally { lock.unlock(); }
}
}
@@ -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.
*
* <p>The price is exactness: the eviction order is LRU <em>within a segment</em>, 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<K, V> implements LruCache<K, V> {
/**
* 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<K, V>[] 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<K, V> 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<K> keysLruFirst() {
List<K> all = new ArrayList<>();
for (var s : segments) all.addAll(s.keysLruFirst());
return all;
}
public void checkInvariants() { for (var s : segments) s.checkInvariants(); }
}
@@ -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<K, V> implements LruCache<K, V> {
private final HashMapLruCache<K, V> 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<K> keysLruFirst() { return delegate.keysLruFirst(); }
public synchronized void checkInvariants() { delegate.checkInvariants(); }
}
@@ -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<Integer, Integer> 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<Integer, Integer> {
private final Map<Integer, Integer> 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<Integer, Integer> 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<Integer> keysLruFirst() { throw new UnsupportedOperationException(); }
}
}
@@ -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"):
* <ol>
* <li>no worker threw;</li>
* <li>a get never returns a value that belongs to a different key (value is a pure function of key);</li>
* <li>a monitor thread never saw size() above capacity;</li>
* <li>the internal list/index invariants hold at the end;</li>
* <li>afterwards the cache still behaves as a correct LRU (for the global-LRU implementations).</li>
* </ol>
*/
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<Integer, Integer> cache(); void check(); }
static Checked sync(int cap) {
var c = new SynchronizedLruCache<Integer, Integer>(cap);
return new Checked() { public LruCache<Integer, Integer> cache() { return c; } public void check() { c.checkInvariants(); } };
}
static Checked lock(int cap) {
var c = new ReentrantLockLruCache<Integer, Integer>(cap);
return new Checked() { public LruCache<Integer, Integer> cache() { return c; } public void check() { c.checkInvariants(); } };
}
static Checked striped(int cap) {
var c = new StripedLruCache<Integer, Integer>(cap, 8);
return new Checked() { public LruCache<Integer, Integer> 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<Checked> factory, boolean globalLru) throws Exception {
Checked c = factory.apply(CAPACITY);
LruCache<Integer, Integer> 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<Future<?>> 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<Integer> 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);
}
}
@@ -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<Integer, Integer> 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();
}
}
@@ -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<Integer> expectedLruFirst) {}
@Test
void dryRunTable() {
// Each row: operation, what it returns, keys LRU -> MRU afterwards.
List<Op> 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<Integer, String> dll = new HashMapLruCache<>(3);
LinkedHashMapLruCache<Integer, String> 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<Integer, String> 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;
}
}
@@ -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<Integer, String> filled() {
var m = new LinkedHashMapLruCache.Lru<Integer, String>(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<Map<Integer, String>> 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<Integer, String>();
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();
}
}
@@ -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<LruCache<Integer, String>> factory) {
@Override public String toString() { return name; }
}
static Stream<Arguments> 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<Integer, String> 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<Integer, String> 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<Integer, String> 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<Integer, String> 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<Integer, String> 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<Integer, String> 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<Integer, String> c = impl.factory.apply(capacity);
List<Integer> 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());
}
}
}
@@ -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<Integer, String> 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<Integer, String> 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();
}
}
@@ -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<String> 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);
}
}
}