Skip to main content

LRU Cache in Java: LinkedHashMap, HashMap + Doubly Linked List and a Thread-Safe Version

Three ways to build an LRU cache in Java: LinkedHashMap with accessOrder, HashMap plus a hand-rolled doubly linked list with sentinels, and thread-safe variants (synchronized, ReentrantLock, striping). Includes an O(1) dry-run table, a demo of why ReadWriteLock corrupts the list, a stress test with real assertions, an indicative JMH comparison and Caffeine as the production answer.

An LRU cache is the interview question that looks small and keeps going. The first answer takes ten lines. The follow-ups — “now without LinkedHashMap”, “prove it is O(1)”, “now make it thread-safe” — are where the real answer is tested. This page gives you the short answer first, then everything underneath it, as lru cache java code that was compiled, tested and benchmarked rather than recalled. Here is the order: the 30-second answer you can say out loud, the contract, three implementations (LinkedHashMap; HashMap plus a hand-rolled doubly linked list; a thread-safe family of three), the O(1) argument as a dry-run table, the concurrency trap that sinks most answers, an honest benchmark, and what to use in production instead of any of it. Deeper material sits in the collapsed sections, so you can read straight through or open only what you need.
Versions this was built and run against. JDK 25.0.5+8 (Temurin, LTS), JUnit 6.1.3, JMH 1.37 (latest GA on Maven Central; released August 2023), Caffeine 3.3.1 (latest GA on Maven Central; the project’s release page lists it as Latest, 13 October 2026). Everything quoted below comes from a committed file in the lru-cache module of the java-interview-lab repository. The benchmark ran on a shared 2-vCPU sandbox with a load average above 20, so treat its numbers as indicative; the page says where that matters.

The 30-second answer

Say this, then write the code.
  • What it is: a fixed-size cache that, when full, throws away the entry that was used longest ago. “Used” means read or written.
  • The data structure: a hash map for O(1) lookup plus a doubly linked list that keeps entries in recency order. The map points at list nodes. A hit unlinks its node and relinks it at the front; an insert into a full cache drops the node at the back and forgets its key. Every step is a handful of pointer writes, so get and put are O(1).
  • The shortcut: Java already ships that combination. LinkedHashMap with accessOrder=true and an overridden removeEldestEntry is an LRU cache in about ten lines.
  • The trap: get changes the list, so even reads need exclusive access. A ReadWriteLock does not help. Use one lock (synchronized or ReentrantLock) or stripe the cache, and accept that striping makes eviction approximate.
  • The production answer: do not hand-roll it. Use Caffeine (Caffeine.newBuilder().maximumSize(n).build()).
The ten-line version, exactly as it lives in the repository (LinkedHashMapLruCache.java):
    /** 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;
        }
    }
If you only remember one sentence for the thread-safety follow-up: “get mutates the recency list, so it is a write; a read-write lock would let two readers rewrite the same four pointers at once, so I use a single lock, and I would reach for Caffeine in production.” The rest of this page earns that sentence.

What the cache must do before you write any code

Interviewers watch for whether you pin down the contract first. The one used here, and enforced by a shared test suite that every implementation must pass, is small:
  • Fixed capacity chosen at construction; zero or negative capacity is rejected.
  • get(k) returns the value or null; a hit makes k the most recently used.
  • put(k, v) inserts or replaces, makes k the most recently used, and evicts the least recently used entry if the cache would exceed capacity. Replacing an existing key never evicts anything.
  • Null keys and null values are rejected with NullPointerException, so a null from get can only mean “not here”.
The interface is LruCache.java. Before the implementations, the picture that explains all of them: two structures that share the same nodes.
Two structures, one set of nodes HashMap: key to node, O(1) 7 → node 3 → node 9 → node Doubly linked list: recency order, O(1) to move or drop a node headsentinel key 9MRU key 3  key 7LRU tailsentinel The index finds a node without walking the list. The node finds its neighbours without searching. Hit: unlink the node, relink it after head. Full: drop the node before tail, and remove ITS key from the index. That last step is why every node stores its own key: the list alone cannot tell the HashMap what to forget.
The hash map answers “where is the node for key 7?” without walking anything. The list answers “which node is oldest?” by always being sorted: the head end is the most recently used, the tail end is the least. Each structure is useless alone — a map has no order, a list has no fast lookup — and together they give O(1) for both questions. Everything below is a variation on this picture.

Implementation 1: LinkedHashMap does it for you

LinkedHashMap is a HashMap whose entries are also threaded onto a doubly linked list. By default the list is in insertion order. Pass true as the third constructor argument and it switches to access order: every access moves the entry to the end of the list. The eldest entry is then, by definition, the least recently used one. removeEldestEntry is a hook the map calls right after every insertion; if you return true, the eldest entry is removed. The class above is the whole implementation. Two details are worth saying out loud in an interview. The constructor is super(capacity + 1, 1.0f, true): the map briefly holds capacity + 1 entries between the insertion and the eviction, so the table is sized for that and, at load factor 1.0, never has to grow. And the check is size() > capacity, not ==, which is the safer habit. Which calls count as an “access”? The Javadoc lists put, putIfAbsent, get, getOrDefault, compute, computeIfAbsent, computeIfPresent and merge. I ran five of those plus the calls that should not count, and the test asserts each result (LinkedHashMapBehaviourTest.java; the transcript is output/03-linkedhashmap-access-order.txt):
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
A read that is a structural modification. In access-order mode get relinks the entry, and the Javadoc says so in as many words: “merely querying the map with get is a structural modification”. The consequence needs no second thread at all — a single-threaded loop that calls get while iterating the key set throws ConcurrentModificationException. It is also the reason the thread-safety section below exists.
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
That transcript is output/04-get-is-structural.txt, written by the same test that asserts the exception.
Going deeper: what the interviewer is probing with LinkedHashMap
  • “Why not just extend LinkedHashMap in production?” You get an unsynchronized cache with no statistics, no expiry and no loading. It is fine for a small, single-threaded, bounded memo table. Anywhere else it is a liability.
  • “Is removeEldestEntry called on get?” No. The Javadoc says it is invoked by put and putAll, after inserting a new entry. A get reorders but never evicts.
  • Composition or inheritance? The repository wraps the subclass inside a final class that implements LruCache, so callers cannot reach the inherited Map methods and break the null-rejecting contract. The inheritance version above is what you write on a whiteboard.
  • Not the same as sorting by time. There are no timestamps anywhere; recency is purely the list position.
For the structure underneath, see the LinkedHashMap explainer and the HashMap internals in the HashMap vs ConcurrentHashMap guide.

Implementation 2: HashMap plus a doubly linked list, by hand

“Now do it without LinkedHashMap” means: build the picture above yourself. You need a node that holds a key, a value and two neighbours; a HashMap<K, Node>; and two operations on the list, unlink a node and link a node right after the head. Here are the node, the fields and the pointer counter (HashMapLruCache.java):
    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;
The two sentinel nodes, head and tail, never hold data. They exist so that no operation ever has to ask “is there a previous node?”. With sentinels an empty list is just head ↔ tail, a one-element list is head ↔ n ↔ tail, and unlinking or inserting is the same four lines in every case. Without them, every method grows if (n.prev == null) branches, and those branches are where whiteboard solutions go wrong. Now the operations. This is the heart of the answer, taken verbatim from the same file:
    @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;
    }
Read get first: look up the node, unlink it, link it at the front, return the value. put has three cases — existing key (replace and move), full cache (evict the node before tail, and remove its key from the map), and the ordinary insert. The picture below shows what “move to the front” costs in pointer writes.
get(3): move one node to the front, six pointer writes Before head 9 3 7 tail After head 3 9 7 tail unlink (2 writes)n.prev.next = n.nextn.next.prev = n.prev link after head (4 writes)n.next = head.nextn.prev = headhead.next.prev = nhead.next = n Six writes whether the list holds 10 nodes or 100,000: the code never looks at any node except n and its two neighbours. The same six writes happen to the SAME memory from every thread that calls get(): that is the thread-safety problem.
Two writes unlink, four writes relink, and the code touches only the node and its two neighbours. That count is the whole O(1) argument, and the next section turns it into a table you can reproduce.
Going deeper: the four bugs that sink hand-rolled versions
  1. The node does not store its key. When you evict the tail node you hold a node, not a key, and index.remove(?) has nothing to pass. The key lives in the node for exactly this reason.
  2. Updating an existing key by inserting a second node. The old node stays in the list; the map forgets it; the list now has a ghost. put must check the map first.
  3. Evicting before checking whether the key already exists. A put that only replaces a value must not evict anything. The shared contract test putExistingKeyReplacesValueAndRefreshes catches this.
  4. Wrong order of pointer writes. In linkAfterHead, head.next.prev = n must run before head.next = n, or you lose the old first node. Writing the four lines in the order shown, from the node outwards to its neighbours, avoids the question.
The repository tests this implementation three ways: a shared contract suite that all five sequential variants must pass, a 20,000-operation differential test against an obviously correct O(n) list model at capacities 1, 2, 5 and 17, and a structural checkInvariants() that walks the list forwards, checks every prev against every next, and compares the length with the index. See LruContractTest.java.

Why every operation is O(1): a dry run and a measurement

Here is the argument as a table. Capacity 3; each row is one call; the last column is how many list-pointer writes HashMapLruCache made for it, counted by the field you saw above. The test also runs the same ten operations against the LinkedHashMap version and asserts that both give the same return value and the same order after every row (DryRunTranscriptTest.java; transcript output/01-dry-run-capacity-3.txt):
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.
Walk two rows. Row 4, get(1): key 1 was oldest, so the order goes from [1,2,3] to [2,3,1] at a cost of 6 writes. Row 5, put(4,D) on a full cache: key 2 (now the oldest) is dropped, which is two writes to unlink it plus four to link the new node — 6 again. Row 6 is a miss and touches no list node at all. A dry run on three entries proves little about 100,000. So the second test fills caches of 10, 1,000 and 100,000 entries and measures the pointer writes for each operation type, using the oldest key for the hit — the case a naive list scan would hate (ConstantCostTest.java; output/02-constant-cost-per-operation.txt):
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.
The columns do not change as the cache grows by four orders of magnitude, and the test fails if they do. That is the part you control. The remaining piece, HashMap.get and put, is O(1) expected, as it is for any hash map.
Going deeper: the honest fine print on “O(1)”
  • Expected, not worst case. A HashMap is O(1) expected when keys hash well. If every key has the same hashCode, lookups degrade; since Java 8 a long bucket chain is converted to a balanced tree, so the worst case is O(log n) for keys that are Comparable and worse for those that are not. The list part stays O(1) regardless.
  • Space is O(capacity) and each entry costs a map entry plus a list node. LinkedHashMap stores both in one object (its entry carries before/after links), which is one reason it is the lighter choice in practice.
  • What the table does not measure: allocation (one Node per new key), memory locality (a linked list chases pointers), and the cost of boxing Integer keys. Big-O hides all three; the benchmark section shows what they add up to.
  • Counting writes is a proxy, deliberately. It is a deterministic one, so it can be asserted in a test. Wall-clock time cannot.
Background on how bucket chains and treeification work is in the HashMap vs ConcurrentHashMap guide.

Making it thread-safe: why reads are writes

Everything so far is single-threaded. Put either class behind two request threads and it corrupts silently. The interesting part is which locking strategy is correct, because the tempting one is wrong. The tempting design says: caches are read-heavy, so use a ReadWriteLock — many concurrent readers, one writer. It would be right for a cache whose get only reads. An LRU cache’s get is the six-write operation from the diagram above. Two threads holding the read lock are allowed to run it at the same time, on shared nodes.
Why a ReadWriteLock cannot protect get(): one possible interleaving Thread A (holds READ lock) n = index.get(3)unlink(n)n.next = head.next // reads 9head.next.prev = nhead.next = n Thread B (ALSO holds READ lock) n = index.get(7)unlink(n)n.next = head.next // reads 9 toohead.next.prev = nhead.next = n Read locks do not exclude each other, so both threads interleave. Both nodes may claim to sit right after head; the last write wins and the other node is half-linked, yet the index still points at it. (Illustrative; see output 09.)
The diagram shows one plausible interleaving; I did not trace which one actually fires, and there are several. What I can show is the outcome. The repository contains a deliberately broken class that takes only the read lock in get (BrokenReadWriteLockLruCache.java):
    @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(); }
    }
and a demo that runs 4 threads x 400,000 operations (90% get) against it and against the correct synchronized version, then runs the structural invariant check (BrokenLockDemo.java; output output/09-broken-readwritelock-demo.txt). This is a race, so it is a measurement and not an assertion; the file is the output of the run I made:
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
Look at the file: in three of the five rounds of the committed run the read-locked cache finished with a structure that no longer matches its own index — once with a list of 17 nodes against an index of 64, twice with a list node whose key is not in the index at all — and in the other two rounds it happened to come out clean. No exception was thrown in any round, which is what makes this bug dangerous: the cache keeps answering, and it is quietly wrong. The synchronized version passed all five. Because the outcome depends on timing, a clean round proves nothing, which is also why the proper test is the structural invariant check and not “did it crash”.
StampedLock does not rescue you either. Its optimistic-read mode is also for read-only critical sections that can be validated and retried. A get that rewrites pointers is not that. The rule is general: a lock’s read mode protects operations that do not write to shared state, and an LRU get always does.

The three correct answers

A. One coarse lock, synchronized. Every method takes the same monitor. This is the answer to give first (SynchronizedLruCache.java):
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(); }
}
B. One coarse lock, ReentrantLock. The same critical section spelled with an explicit lock (ReentrantLockLruCache.java). It buys you tryLock, timeouts, interruptibility and an optional fairness policy. It does not buy parallelism: there is still one lock and every operation still takes it.
    @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(); }
    }
C. Lock striping. Split the key space across N independent caches, each with its own lock and its own list, and pick one by hash (StripedLruCache.java). Threads touching different stripes do not contend. The price is that LRU order now exists per stripe, not globally:
    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); }
The test makes the price concrete: capacity 4 over 2 stripes, the globally oldest key survives while a younger key is evicted from a full stripe, and the cache is not even full (StripedAndCaffeineTest.java; output/06-striped-is-not-global-lru.txt):
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)
A real bug I wrote and then measured. My first segmentFor was the obvious one: take the key’s hash, spread it, floorMod by the number of stripes. With 16 stripes that sends every key with the same low four bits to the same stripe — and inside the stripe, the HashMap picks a bucket from the same low bits. So the 128 keys of one stripe used 8 of its 128 buckets. The cache was correct and slower than the coarse lock in every timing I took (roughly two to three times in a quick loop, with the same 50% hit ratio). The fix is to choose the stripe from different bits (multiply by the golden-ratio constant, take the high bits). Both selectors are kept in the repository so you can see the difference (output/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?
====================================================================================================

(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.
The rule of thumb for any striped structure: the bits that choose the stripe must be independent of the bits the inner structure uses. The selector code is in the same file (StripedLruCache.java):
    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); }
    }

The stress test that actually asserts something

“I ran it with 8 threads and it did not crash” is not evidence. The stress test in the repository runs 8 threads x 60,000 operations (80% get, 20% put) against a 64-entry cache over 256 keys, so eviction fires constantly, while a separate monitor thread polls size() as fast as it can. Then it asserts five facts (ConcurrencyStressTest.java):
        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";
        }
The value stored for key k is always k * 31 + 7, so a get that returns another key’s value is detected, not just a thrown exception. The last check is the strongest: after the storm, a single thread inserts exactly capacity new keys and the cache must contain precisely those, in order, with a valid list. A corrupted list or index would fail it. The result (output/05-concurrency-stress.txt):
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.
Going deeper: what is still not atomic, and the ConcurrentHashMap approach
  • Check-then-act is still yours to handle. if (cache.get(k) == null) cache.put(k, load(k)) is two separately locked calls. Two threads can both miss and both load the same value (a cache stampede for expensive loads). Caffeine solves it with cache.get(key, mappingFunction), which computes at most once per key.
  • size() across stripes is not a snapshot. StripedLruCache.size() adds the segment sizes one after another while other threads keep working.
  • Collections.synchronizedMap(new LinkedHashMap<>(.., true)) is the zero-code coarse-lock version. It is correct for single calls and is included in the benchmark. Iterating over it still needs a manual synchronized block, and iteration while another thread calls get hits the structural-modification rule above.
  • The ConcurrentHashMap approach was not built. The usual sketch is a ConcurrentHashMap for lookup plus a ConcurrentLinkedDeque for order. Its trouble is that moving a key to the front means removing it from the middle of the deque, and remove(Object) has to search for it, so a hit is O(n). That reasoning comes from the API shape, not from a run, and the repository does not contain that variant. The designs that avoid the problem — buffering access events and replaying them under a lock, as Caffeine does — are a library, not an interview answer.
  • synchronized and virtual threads. JEP 491 (JDK 24) lets a virtual thread unmount while blocked on a monitor, so the old reason to prefer ReentrantLock with virtual threads no longer applies on JDK 24 and later. See the multithreading interview programs for the wider locking toolbox.

How fast is each? A short, honest JMH run

Correctness comes first, speed second, and here speed comes with a large caveat. The harness (LruBenchmark.java) measures the cache-aside pattern: get, and on a miss put, with keys uniform over twice the capacity so about half the lookups hit. JMH 1.37, one fork, 3 warm-up and 5 measured iterations per cell. The sandbox it ran on is shared by ten jobs and had a load average around 24 on 2 vCPUs when the run started, so the figures below are indicative only. Full output: output/10-jmh-indicative.txt.
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
Read the error column before the score column. Most confidence intervals overlap, which means this run cannot rank the implementations that sit close together, and I will not pretend it does. What I am willing to say:
  • The unsynchronised LinkedHashMap cache scored highest at one thread (4.9 ops/µs), as expected from doing the least work.
  • synchronized, ReentrantLock and Collections.synchronizedMap are indistinguishable here. That matches the reasoning above: three spellings of one lock.
  • Throughput did not fall as threads went from 1 to 4 for the coarse locks (4 threads even scored higher). On a 2-vCPU machine under heavy external load, that is noise and scheduling, not a finding. Do not conclude that contention is free.
  • The one gap I trust for a reason other than this table is the striped cache with the naive selector (striped16-lowbits), which was the slowest thread-safe hand-written variant at every thread count. The bucket-distribution transcript above explains why, and a separate throw-away timing loop I ran while debugging showed the same direction; the JMH intervals alone do overlap.
  • Caffeine scored low here. This workload is half misses, each followed by a write, on a loaded 2-vCPU machine with Caffeine’s default executor. Caffeine is designed around read-heavy hit paths and I did not tune or investigate this result, so it says nothing about Caffeine in your service.
To get numbers you can act on: run scripts/run-jmh.sh on an idle machine with at least as many cores as your threads, raise the iteration counts, and replace the uniform key distribution with one that resembles your traffic.

What to use in production: Caffeine

Everything above is an exercise. In a real service you want expiry, statistics, a loading function that computes once per key, and an eviction policy chosen for hit rate. That is Caffeine. The latest GA on Maven Central at the time of writing is 3.3.1, and it was the version used here. Its adapter in the repository is five lines (CaffeineLru.java):
    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); }
Do not expect it to behave like the caches above. Caffeine’s policy is Window TinyLFU, which its documentation describes as a substantial hit-rate improvement over plain LRU, and it does its housekeeping asynchronously. To make the difference visible I ran the dry-run operations from earlier against maximumSize(3) with a same-thread executor, so the result is deterministic (output/07-caffeine-same-ops.txt):
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).
Same operations, different residents: strict LRU ends with keys 3, 5 and 4; Caffeine ends with 1, 2 and 5, and the newly inserted keys 3 and 4 were discarded while keys 1 and 2 stayed. That is consistent with a frequency-aware admission policy protecting entries that have been seen before; I did not trace Caffeine’s internals to confirm the exact path. Practical consequence: tests that assume “the oldest key is evicted” will fail against Caffeine, and Caffeine’s own documentation says it only “will try to evict entries that have not been used recently or very often”.
Caffeine is a better cache, not a faster LRU. If an interviewer asks for an LRU, give them an LRU. If a service asks for a cache, give it Caffeine. Mixing the two up — shipping a hand-rolled LRU, or expecting strict LRU order from Caffeine — is the mistake.
Should you even build this yourself? Only for an interview, a teaching exercise, or a tiny single-threaded memo table where LinkedHashMap is plenty. Not for anything shared across request threads. The coarse-lock version is correct but serialises every call; the striped version needs careful hashing and gives up exact LRU; and neither has expiry, metrics or stampede protection. The benchmark above was run on a heavily loaded sandbox and cannot settle which is fastest on your hardware — measure with your own key distribution before choosing anything over the coarse lock, and use a library when the cache matters.

FAQ

What is an LRU cache?

A bounded cache that evicts the least recently used entry when it is full. Both reads and writes count as use.

How do you implement an LRU cache in Java?

The shortest way is LinkedHashMap with accessOrder=true and removeEldestEntry returning size() > capacity. Without it, combine a HashMap from key to node with a doubly linked list that keeps nodes in recency order, using sentinel head and tail nodes.

Why is LRU cache get and put O(1)?

The hash map finds a node in expected O(1) time, and a doubly linked list can unlink and relink a node in a fixed number of pointer writes because each node knows both neighbours. In the repository’s measurement a hit costs 6 pointer writes at 10, 1,000 and 100,000 entries.

Why use a doubly linked list instead of a singly linked list?

To remove a node from the middle in O(1) you need its predecessor. A singly linked list would have to search for it; a doubly linked list stores it.

Is LinkedHashMap thread-safe?

No. In access-order mode even get modifies the structure, so concurrent use needs external synchronisation, for example Collections.synchronizedMap or a lock around your own wrapper.

Can I use a ReadWriteLock for an LRU cache?

Not for get. A hit rewrites list pointers, so it must run under an exclusive lock. In this page’s demo, a read-locked get left the cache with a broken structure in several rounds out of five.

synchronized or ReentrantLock for a thread-safe LRU cache?

They give the same parallelism here, which is none: one lock guards everything. Choose ReentrantLock only if you need tryLock, timeouts or fairness. Since JDK 24, synchronized no longer pins virtual threads either.

What is the best LRU cache library for Java?

Caffeine. It is bounded, thread-safe and has expiry and loading built in, but it uses Window TinyLFU rather than strict LRU.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.