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:
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
Executable
+12
@@ -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
|
||||||
Executable
+25
@@ -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()); }
|
||||||
|
}
|
||||||
+30
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user