Mid-level 18 min · March 05, 2026

LRU Cache Eviction — Missing DLL Removal Causes 4GB Leak

Heap grew 4GB in 6 hours — eviction missed DLL removal.

N
Naren Founder & Principal Engineer

20+ years shipping performance-critical code where algorithms decide the bill. Drawn from code that ran under real load.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • LRU Cache is a fixed-capacity key-value store that evicts the least recently used item when full
  • Core data structures: HashMap for O(1) key lookup + Doubly Linked List for O(1) order maintenance
  • DLL head = most recently used (MRU). DLL tail = least recently used (LRU)
  • On get: move accessed node to head. On put: insert at head, evict tail.prev if over capacity
  • Sentinel head/tail nodes eliminate all null-check edge cases in insert/remove
  • Production risk: forgetting to update both the map and the DLL during eviction causes memory leaks or stale references
✦ Definition~90s read
What is LRU Cache Implementation?

An LRU (Least Recently Used) Cache evicts the least recently accessed item when the cache is full and a new item must be inserted. It is a fixed-capacity key-value store where both get and put run in O(1).

Imagine your desk has space for only 5 folders.

Real-world use: CPU caches, DNS resolver caches, web browser caches, database buffer pools. When storage is limited, you want to keep the items most likely to be accessed again — which are usually the recently used ones.

Implementation: combine a hash map (for O(1) key lookup) with a doubly linked list (for O(1) order maintenance). The DLL head = most recently used, DLL tail = least recently used.

Plain-English First

Imagine your desk has space for only 5 folders. When you need a new folder but the desk is full, you grab the one you haven't touched the longest and toss it in the filing cabinet to make room. That's an LRU (Least Recently Used) cache — it keeps the things you use most often close at hand and evicts the stuff you forgot about. Your browser does this with tabs, your CPU does it with memory, and Netflix does it with video chunks.

LRU (Least Recently Used) eviction is the industry-standard cache replacement policy. It works because most workloads exhibit temporal locality — data accessed recently is likely to be accessed again soon. CPU L1/L2 caches, Redis, database buffer pools, and CDN edge nodes all rely on LRU.

The challenge: both get and put must run in O(1) time. A plain array requires O(n) to find and move elements. A single linked list requires O(n) to find a node by key. The solution pairs a HashMap (O(1) lookup by key) with a doubly linked list (O(1) insertion/deletion at any known node). Neither structure alone suffices.

A common misconception is that LinkedHashMap alone implements LRU. It can, but only with accessOrder=true and careful override of removeEldestEntry. A from-scratch implementation demonstrates understanding of the pointer manipulation that LinkedHashMap hides internally.

What is LRU Cache? — Plain English

An LRU (Least Recently Used) Cache evicts the least recently accessed item when the cache is full and a new item must be inserted. It is a fixed-capacity key-value store where both get and put run in O(1).

Real-world use: CPU caches, DNS resolver caches, web browser caches, database buffer pools. When storage is limited, you want to keep the items most likely to be accessed again — which are usually the recently used ones.

Implementation: combine a hash map (for O(1) key lookup) with a doubly linked list (for O(1) order maintenance). The DLL head = most recently used, DLL tail = least recently used.

The Desk Analogy
  • Your desk holds 5 folders. You work on folder A, then B, then C.
  • When folder D arrives and the desk is full, you evict the oldest untouched folder.
  • The folders you touched recently are the ones you will likely need again.
  • This temporal locality is why LRU outperforms random eviction in most workloads.
Production Insight
LRU is not always the best eviction policy. For workloads with scan patterns (e.g., full-table scans in databases), LRU thrashes — the scan evicts all hot data. In these cases, variants like LRU-K (tracks last K accesses) or ARC (Adaptive Replacement Cache) perform better. Know your access pattern before choosing LRU.
Key Takeaway
LRU evicts the least recently accessed item. It works because most workloads exhibit temporal locality. The O(1) requirement forces a dual-structure design: HashMap + Doubly Linked List.
LRU Cache Eviction — Missing DLL Removal Causes 4GB Leak THECODEFORGE.IO LRU Cache Eviction — Missing DLL Removal Causes 4GB Leak Flow from cache definition to thread-safe implementation with common pitfalls LRU Cache Definition Evicts least recently used item when full HashMap + Doubly Linked List O(1) get/put via map; DLL tracks usage order Get Operation Move accessed node to head of DLL Put Operation Insert new node at head; evict tail if full Eviction Removal Remove tail node from both map and DLL Thread-Safe LRU Use ReentrantReadWriteLock for concurrency ⚠ Missing DLL removal on eviction causes 4GB leak Always remove node from both map and DLL; use removeNode() helper THECODEFORGE.IO
thecodeforge.io
LRU Cache Eviction — Missing DLL Removal Causes 4GB Leak
Lru Cache Implementation

How LRU Cache Works — Step by Step

Data structures
  • hash map: key -> node in DLL
  • DLL: maintains access order (head=MRU, tail=LRU)
  • Use sentinel head and tail nodes to simplify edge cases

get(key) O(1): 1. If key not in map: return -1. 2. Move the node to the front of the DLL (most recently used). 3. Return the node's value.

put(key, value) O(1): 1. If key already in map: update value, move node to front. 2. Else: create new node, insert at front, add to map. 3. If cache exceeds capacity: remove the LRU node (tail.prev), delete from map.

Why Two Structures?
  • HashMap alone: O(1) lookup but no ordering — cannot find the LRU item in O(1).
  • DLL alone: O(1) insert/remove at known positions but O(n) to find a node by key.
  • Combined: HashMap finds the node in O(1). DLL maintains eviction order in O(1).
  • The HashMap value is a pointer to the DLL node — this is the bridge between the two structures.
Production Insight
The eviction path is the most failure-prone operation. It must atomically update both the HashMap and the DLL. If the HashMap removal succeeds but the DLL removal fails (or is skipped), the evicted node remains reachable through the DLL, causing a memory leak. Always remove from the DLL first, then from the HashMap.
Key Takeaway
The two-structure design is non-negotiable for O(1) operations. The HashMap bridges key lookup to DLL node pointers. The DLL maintains eviction order. The eviction path must update both structures atomically.
LRU Operation Decision Flow
Ifget(key): key exists in HashMap
UseRemove node from DLL, insert at head (MRU). Return value.
Ifget(key): key not in HashMap
UseReturn -1 (cache miss). No state change.
Ifput(key, value): key exists
UseRemove old node from DLL. Update value. Insert at head. No eviction.
Ifput(key, value): key is new, cache not full
UseCreate node, insert at head, add to HashMap. No eviction.
Ifput(key, value): key is new, cache is full
UseEvict tail.prev (LRU): remove from DLL and HashMap. Then insert new node at head.
LRU Cache Operation Flow
getputYesNoYesNoYesNoStart get/putOperation type?Key in HashMap?Key in HashMap?Remove node from DLLInsert node at headReturn valueReturn -1Update value, remove fromDLL, insert at headDoneCreate node, insert at head,add to mapCache over capacity?Evict tail.prev: remove fromDLL and map

Worked Example — Tracing the Algorithm

LRU Cache capacity=3. Operations: put(1,A), put(2,B), put(3,C), get(1), put(4,D), get(2).

put(1,A): cache=[1:A]. DLL: head<->1<->tail. put(2,B): cache=[1:A, 2:B]. DLL: head<->2<->1<->tail. (2 is MRU) put(3,C): cache=[1:A, 2:B, 3:C]. DLL: head<->3<->2<->1<->tail. get(1): 1 exists. Move 1 to front. DLL: head<->1<->3<->2<->tail. Return A. put(4,D): cache full (size=3). Insert 4, evict LRU=tail.prev=2. Remove 2 from map and DLL. Insert 4 at front. cache=[1:A, 3:C, 4:D]. DLL: head<->4<->1<->3<->tail. get(2): 2 not in cache. Return -1. (Was evicted.)

Key insight: get(1) promoted key 1 to MRU, saving it from eviction when key 4 was inserted.

Pro Tip: Trace With Concrete Values
When debugging LRU cache issues, draw the DLL state after each operation. Label each node with its key and mark the head (MRU) and tail.prev (LRU). This visual trace immediately reveals ordering bugs that are invisible in code.
Production Insight
The worked example demonstrates the promotion effect: get(1) moved key 1 to MRU, which saved it from eviction. Without this promotion, key 1 would have been evicted instead of key 2. This is the core behavior that distinguishes LRU from FIFO — access recency, not insertion order, determines eviction.
Key Takeaway
Tracing with concrete values reveals the promotion mechanic that distinguishes LRU from FIFO. The get operation is not just a lookup — it is a state mutation that changes eviction priority.
DLL Order After Each Operation (capacity=3)
Step 1
1
put(1,A): cache=[1]
Step 2
2
1
put(2,B): 2 becomes MRU at head
Step 3
3
2
1
put(3,C): 3 is MRU
Step 4
1
3
2
get(1): 1 promoted to MRU, order changed
Step 5
4
1
3
put(4,D): evict 2 (tail.prev), insert 4 at head

Implementation

Sentinel head and tail nodes eliminate all null-pointer edge cases. _remove unlinks a node by connecting its neighbours directly. _insert_front places a node right after the sentinel head (MRU position). get moves the accessed node to the front. put inserts at the front and evicts tail.prev when over capacity. The hash map (self.map) gives O(1) node lookup; the DLL gives O(1) order updates.

LRUCache.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
package io.thecodeforge.cache;

import java.util.HashMap;
import java.util.Map;

/**
 * LRU Cache using HashMap + Doubly Linked List.
 * O(1) get and put. Sentinel nodes eliminate null-check edge cases.
 */
public class LRUCache {

    // --- Internal node for the doubly linked list ---
    static class Node {
        int key;
        int value;
        Node prev;
        Node next;

        Node(int key, int value) {
            this.key = key;
            this.value = value;
        }
    }

    private final int capacity;
    private final Map<Integer, Node> map;
    private final Node head; // sentinel — head.next is MRU
    private final Node tail; // sentinel — tail.prev is LRU

    public LRUCache(int capacity) {
        this.capacity = capacity;
        this.map = new HashMap<>();

        // Sentinel nodes: never null, always present
        this.head = new Node(0, 0);
        this.tail = new Node(0, 0);
        head.next = tail;
        tail.prev = head;
    }

    // --- Remove a node from the DLL (O(1)) ---
    private void remove(Node node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }

    // --- Insert a node at the front (MRU position) (O(1)) ---
    private void insertFront(Node node) {
        node.next = head.next;
        node.prev = head;
        head.next.prev = node;
        head.next = node;
    }

    /**
     * Get value by key. Moves accessed node to MRU position.
     * Returns -1 if key not found.
     */
    public int get(int key) {
        if (!map.containsKey(key)) {
            return -1;
        }
        Node node = map.get(key);
        remove(node);          // unlink from current position
        insertFront(node);     // promote to MRU
        return node.value;
    }

    /**
     * Insert or update key-value pair.
     * If cache is full, evicts the LRU item first.
     */
    public void put(int key, int value) {
        if (map.containsKey(key)) {
            // Key exists: update value and promote to MRU
            Node existing = map.get(key);
            existing.value = value;
            remove(existing);
            insertFront(existing);
        } else {
            // New key: create node and insert at MRU
            Node newNode = new Node(key, value);
            map.put(key, newNode);
            insertFront(newNode);

            // Evict if over capacity
            if (map.size() > capacity) {
                Node lru = tail.prev; // least recently used
                remove(lru);          // remove from DLL FIRST
                map.remove(lru.key);  // then remove from map
            }
        }
    }

    // --- Debug: verify DLL and map are in sync ---
    public int dllSize() {
        int count = 0;
        Node current = head.next;
        while (current != tail) {
            count++;
            current = current.next;
        }
        return count;
    }

    public boolean isConsistent() {
        return map.size() == dllSize();
    }

    public static void main(String[] args) {
        LRUCache cache = new LRUCache(3);

        cache.put(1, 100);
        cache.put(2, 200);
        cache.put(3, 300);
        System.out.println("After 3 puts: " + cache.map.keySet());
        System.out.println("DLL size: " + cache.dllSize() + ", Map size: " + cache.map.size());
        System.out.println("Consistent: " + cache.isConsistent());

        System.out.println("get(1): " + cache.get(1)); // promotes 1 to MRU

        cache.put(4, 400); // evicts 2 (LRU)
        System.out.println("After put(4): " + cache.map.keySet());
        System.out.println("get(2): " + cache.get(2)); // -1 (evicted)
        System.out.println("get(1): " + cache.get(1)); // 100 (was promoted)
        System.out.println("Consistent: " + cache.isConsistent());

        // Edge case: capacity 1
        LRUCache tiny = new LRUCache(1);
        tiny.put(1, 10);
        tiny.put(2, 20); // evicts 1
        System.out.println("\nCapacity 1 cache:");
        System.out.println("get(1): " + tiny.get(1)); // -1
        System.out.println("get(2): " + tiny.get(2)); // 20
        System.out.println("Consistent: " + tiny.isConsistent());
    }
}
Output
After 3 puts: [1, 2, 3]
DLL size: 3, Map size: 3
Consistent: true
get(1): 100
After put(4): [1, 3, 4]
get(2): -1
get(1): 100
Consistent: true
Capacity 1 cache:
get(1): -1
get(2): 20
Consistent: true
Watch Out: Eviction Order Matters
  • DLL removal is a pointer reassignment — it cannot throw exceptions.
  • HashMap removal can throw ConcurrentModificationException in concurrent contexts.
  • If HashMap removal fails after DLL removal, the node is disconnected from the list — it will be garbage collected.
  • If DLL removal fails after HashMap removal, the node is orphaned in the list — a memory leak.
Production Insight
The isConsistent() method — which verifies DLL size equals HashMap size — should be enabled in debug builds and integration tests. It catches the most common LRU bug: DLL and HashMap falling out of sync due to a missing removal in one of the two structures.
Key Takeaway
Sentinel nodes eliminate null checks. The eviction order (DLL first, then HashMap) prevents silent memory leaks. The isConsistent() invariant check catches sync bugs between the two structures.
Eviction Path: What Must Happen
IfCache is at capacity and a new key is inserted
UseIdentify LRU node (tail.prev). Remove from DLL. Remove from HashMap. Then insert new node at head.
IfExisting key is updated via put
UseNo eviction. Update value in place. Remove node from DLL. Insert at head (MRU).
IfCapacity is 1
UseEvery put of a new key evicts the current single entry. head.next == tail.prev always.
IfSame key is put twice consecutively
UseSecond put updates the value and promotes to MRU. No eviction. DLL and map sizes unchanged.
Eviction Process for Capacity=1
Step 1
1
put(1,10): cache=[1]
Step 3
2
put(2,20): cache=[2], 1 evicted

Generic LRUCache Implementation

The previous implementation hardcodes key and value types as int. In real-world production systems, caches hold arbitrary types — session objects, API responses, database query results. A generic version parameterizes the key and value types, making the cache reusable across the entire application.

Generifying the cache is straightforward: replace every occurrence of int key with K key, and int value with V value. The HashMap becomes Map<K, Node<K,V>>. The sentinel nodes become Node<K,V> with null keys (or use placeholder values). The factory method or constructor accepts a capacity and optionally an equality/duration strategy.

The trade-off is that the cache must now handle the additional overhead of generic type erasure in Java, but that is negligible. The real benefit is a single, type-safe cache class that can be instantiated for any key-value pair.

io/thecodeforge/cache/LRUCache.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
package io.thecodeforge.cache;

import java.util.HashMap;
import java.util.Map;

/**
 * Generic LRU Cache — type-safe, reusable for any key-value pair.
 * Same O(1) semantics as the int-based version.
 */
public class LRUCache<K, V> {

    static class Node<K, V> {
        K key;
        V value;
        Node<K,V> prev;
        Node<K,V> next;

        Node(K key, V value) {
            this.key = key;
            this.value = value;
        }
    }

    private final int capacity;
    private final Map<K, Node<K,V>> map;
    private final Node<K,V> head;
    private final Node<K,V> tail;

    public LRUCache(int capacity) {
        this.capacity = capacity;
        this.map = new HashMap<>();
        this.head = new Node<>(null, null);
        this.tail = new Node<>(null, null);
        head.next = tail;
        tail.prev = head;
    }

    private void remove(Node<K,V> node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }

    private void insertFront(Node<K,V> node) {
        node.next = head.next;
        node.prev = head;
        head.next.prev = node;
        head.next = node;
    }

    /**
     * Get value by key. Returns null if key not found.
     */
    public V get(K key) {
        Node<K,V> node = map.get(key);
        if (node == null) {
            return null;
        }
        remove(node);
        insertFront(node);
        return node.value;
    }

    /**
     * Insert or update key-value pair. Evicts LRU if over capacity.
     */
    public void put(K key, V value) {
        Node<K,V> existing = map.get(key);
        if (existing != null) {
            existing.value = value;
            remove(existing);
            insertFront(existing);
        } else {
            Node<K,V> newNode = new Node<>(key, value);
            map.put(key, newNode);
            insertFront(newNode);

            if (map.size() > capacity) {
                Node<K,V> lru = tail.prev;
                remove(lru);
                map.remove(lru.key);
            }
        }
    }

    public boolean isConsistent() {
        int dllCount = 0;
        Node<K,V> cur = head.next;
        while (cur != tail) {
            dllCount++;
            cur = cur.next;
        }
        return map.size() == dllCount;
    }

    // Example usage:
    public static void main(String[] args) {
        LRUCache<String, Integer> cache = new LRUCache<>(3);
        cache.put("a", 1);
        cache.put("b", 2);
        cache.put("c", 3);
        System.out.println(cache.get("a")); // 1, promotes "a"
        cache.put("d", 4); // evicts "b"
        System.out.println(cache.get("b")); // null
    }
}
Output
1
null
Production Note: Null Keys
  • Sentinels only reference null keys internally — never added to the map.
  • If your K type allows null, consider using Optional<K> or a sentinel marker interface.
  • Alternatively, use a separate field to indicate a sentinel node.
Production Insight
The generic version is what you would use in production. It eliminates code duplication and allows the same cache class to serve session caches, response caches, and database query caches. The performance overhead of generics is zero after type erasure. The only extra cost is a null-or-null check in get (returning null instead of -1).
Key Takeaway
Generifying the LRU cache makes it production-ready for any key-value type. The implementation is identical to the int-only version except the Node class and map are parameterized.

Thread-Safe LRU Cache with ReentrantReadWriteLock

In multi-threaded environments, the basic LRU cache is not safe. Concurrent get and put can corrupt the DLL (two threads modifying prev/next pointers simultaneously) or produce stale values. The simplest way to make it thread-safe is to synchronize all public methods, but that serializes all access. A better approach uses ReentrantReadWriteLock: multiple gets can proceed concurrently (read lock), but puts and puts that evict must be exclusive (write lock).

The production incident we covered earlier would have been magnified in a concurrent environment — a race condition between a get and an eviction could leave the DLL in an inconsistent state. Using locks ensures the DLL invariants are maintained atomically.

io/thecodeforge/cache/ConcurrentLRUCache.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
package io.thecodeforge.cache;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReentrantReadWriteLock;

/**
 * Thread-safe LRU Cache using ReentrantReadWriteLock.
 * Read lock for get operations (multiple readers).
 * Write lock for put operations (single writer).
 */
public class ConcurrentLRUCache<K, V> {

    static class Node<K, V> {
        K key;
        V value;
        Node<K,V> prev;
        Node<K,V> next;

        Node(K key, V value) {
            this.key = key;
            this.value = value;
        }
    }

    private final int capacity;
    private final Map<K, Node<K,V>> map;
    private final Node<K,V> head, tail;
    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

    public ConcurrentLRUCache(int capacity) {
        this.capacity = capacity;
        this.map = new HashMap<>();
        this.head = new Node<>(null, null);
        this.tail = new Node<>(null, null);
        head.next = tail;
        tail.prev = head;
    }

    // --- DLL helpers (must be called with write lock held) ---
    private void remove(Node<K,V> node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }

    private void insertFront(Node<K,V> node) {
        node.next = head.next;
        node.prev = head;
        head.next.prev = node;
        head.next = node;
    }

    public V get(K key) {
        lock.readLock().lock();
        try {
            Node<K,V> node = map.get(key);
            if (node == null) {
                return null;
            }
            // Need to promote: demote to write lock
            lock.readLock().unlock();
            lock.writeLock().lock();
            try {
                // Double-check — node may have been evicted after read lock released
                if (map.get(key) != node) {
                    // Node was removed by another thread; treat as miss
                    return null;
                }
                remove(node);
                insertFront(node);
                return node.value;
            } finally {
                lock.writeLock().unlock();
                lock.readLock().lock(); // re-acquire read lock for consistency at exit
            }
        } finally {
            lock.readLock().unlock();
        }
    }

    public void put(K key, V value) {
        lock.writeLock().lock();
        try {
            Node<K,V> existing = map.get(key);
            if (existing != null) {
                existing.value = value;
                remove(existing);
                insertFront(existing);
            } else {
                Node<K,V> newNode = new Node<>(key, value);
                map.put(key, newNode);
                insertFront(newNode);

                if (map.size() > capacity) {
                    Node<K,V> lru = tail.prev;
                    remove(lru);
                    map.remove(lru.key);
                }
            }
        } finally {
            lock.writeLock().unlock();
        }
    }

    public int size() {
        lock.readLock().lock();
        try {
            return map.size();
        } finally {
            lock.readLock().unlock();
        }
    }

    // Example usage:
    public static void main(String[] args) throws InterruptedException {
        ConcurrentLRUCache<String, Integer> cache = new ConcurrentLRUCache<>(1000);
        // Simulate concurrent access
        Thread writer = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                cache.put("key" + i, i);
            }
        });
        Thread reader = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                Integer val = cache.get("key" + i);
                // May get null if evicted, that's okay
            }
        });
        writer.start();
        reader.start();
        writer.join();
        reader.join();
        System.out.println("Final size: " + cache.size());
    }
}
Output
Final size: 1000
Lock Escalation in get()
  • Read lock does not allow modification of DLL pointers.
  • Promotion requires write lock to avoid race conditions with concurrent puts.
  • Double-check after acquiring write lock guards against the node being evicted between lock release and re-acquisition.
  • This pattern is standard for read-then-write sequences on shared structures.
Production Insight
The lock escalation pattern in get() is critical for performance. In a typical workload, reads outnumber writes 10:1. Using a read lock for the initial lookup avoids contention on the hot path. Only when a cache hit occurs and promotion is needed do we escalate to a write lock. However, lock escalation can cause deadlocks if not done carefully — always release the read lock before acquiring the write lock, and use a retry loop if the node state changes.
Key Takeaway
ReentrantReadWriteLock allows multiple concurrent reads for get() while serializing writes. The lock escalation pattern in get() is necessary because promotion is a write operation. This provides good concurrency for read-heavy workloads.

Alternative Implementation Approaches

The HashMap + Doubly Linked List combination delivers O(1) for both get and put, but it's not the only way to implement LRU. Two alternative approaches — Array-based and Heap-based — trade time complexity for simplicity or memory efficiency. Understanding these alternatives justifies why the DLL+HashMap design is the industry standard.

Array-based LRU uses an array of key-value pairs and increments a counter on each access. When a new item must be inserted, scan the array for the smallest counter (least recently used) and replace it. get() is O(n) because we scan to find the key. put() is also O(n). This is impractical for large caches but simple to implement and understand. It's sometimes used in embedded systems with tiny cache sizes (<100).

Heap-based LRU (min-heap by access timestamp) provides O(log n) for both get and put. Each node stores a key, value, and access timestamp. On get, update the timestamp and heapify. On put, insert a new node, then if over capacity, extract the min (LRU). The heap gives better performance than an array but worse than DLL+HashMap. It also requires handling timestamp increments carefully to avoid overflow.

The DLL+HashMap approach is optimal because it achieves O(1) for both operations by combining a hash map for key lookup with a linked list for order maintenance. The HashMap provides direct node access (no scanning), and the DLL provides constant-time insertion and removal at known positions.

Complexity Comparison
  • Array: get O(n), put O(n), evict O(n). Simple but slow for large caches.
  • Heap: get O(log n) (if we update timestamp and heapify), put O(log n), evict O(log n). Better but not optimal.
  • DLL+HashMap: get O(1), put O(1), evict O(1). Optimal time complexity.
  • Memory: DLL+HashMap uses more memory per entry (two pointers + hash overhead) but that's acceptable for most applications.
Production Insight
The heap approach is sometimes used in practice when you need additional ordering criteria beyond recency — for example, combined LRU and priority. But for pure LRU, the extra log factor adds latency at scale. In production systems like Caffeine or Guava Cache, the DLL+HashMap design (with generational improvements) is the standard.
Key Takeaway
Array and heap alternatives illustrate the trade-off space. The HashMap+DLL design achieves O(1) for all operations, which is why it is the standard LRU implementation in production systems.

LinkedHashMap Shortcut for LRU Cache

Java's LinkedHashMap can implement an LRU cache in just a few lines by setting accessOrder=true and overriding removeEldestEntry. This is often the quickest path to a working LRU cache for non-critical code. However, it hides the underlying mechanics — the same HashMap+DLL that we implemented manually.

When accessOrder=true, LinkedHashMap maintains the iteration order as the order in which entries were last accessed (not insertion order). The eldest entry (the one that would be removed) is determined by the access order, which corresponds to the least recently used. Overriding removeEldestEntry with a size check allows automatic eviction.

This approach is ideal for quick prototypes, small-scale caches, and interview warm-ups. But it has limitations: no per-entry expiration, no control over the DLL removal order, and limited configurability. For production, a purpose-built cache library (Caffeine, Guava) is better.

io/thecodeforge/cache/LinkedHashMapLRUCache.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package io.thecodeforge.cache;

import java.util.LinkedHashMap;
import java.util.Map;

/**
 * LRU Cache using LinkedHashMap with access order.
 * Simple, but hides the underlying DLL mechanics.
 */
public class LinkedHashMapLRUCache<K, V> extends LinkedHashMap<K, V> {

    private final int capacity;

    public LinkedHashMapLRUCache(int capacity) {
        // initialCapacity, loadFactor, accessOrder=true
        super(capacity, 0.75f, true);
        this.capacity = capacity;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > capacity;
    }

    // get() is inherited from LinkedHashMap - returns null if not found
    // put() is inherited - automatically evicts via removeEldestEntry

    public static void main(String[] args) {
        LinkedHashMapLRUCache<String, Integer> cache = new LinkedHashMapLRUCache<>(3);
        cache.put("a", 1);
        cache.put("b", 2);
        cache.put("c", 3);
        cache.get("a"); // promotes "a" to MRU
        cache.put("d", 4); // evicts "b" (LRU)
        System.out.println(cache); // {c=3, a=1, d=4}  order reflects access order
    }
}
Output
{c=3, a=1, d=4}
When to Use LinkedHashMap vs Manual Implementation
  • LinkedHashMap: quick to write, good for small caches, built-in access order.
  • Manual DLL+HashMap: full control over eviction order, easier to extend (TTL, metrics), shows deeper understanding.
  • For production, use a library (Caffeine/Guava) — they handle edge cases you haven't thought of.
  • In interviews, manual implementation is expected to demonstrate pointer manipulation.
Production Insight
The LinkedHashMap shortcut is deceptively simple. In production, it has subtle issues: concurrent access is not safe by default (you must wrap or use Collections.synchronizedMap), and it does not provide metrics, eviction listeners, or variable expiration. We've seen teams use it in high-throughput systems and later hit performance cliffs because they couldn't control the underlying data structure. Use libraries like Caffeine that implement a modern variant of LRU (TinyLFU) with better scan resistance.
Key Takeaway
Java's LinkedHashMap provides a one-liner LRU cache by enabling access order and overriding removeEldestEntry. It's great for quick needs but lacks the flexibility and performance of a manual implementation or dedicated caching library.

Multi-Language Implementations: Python OrderedDict and C++ list+map

LRU cache is a language-agnostic data structure. While we've focused on Java, two other common production implementations use Python's collections.OrderedDict and C++'s std::list + std::unordered_map. Each language brings its own idioms and performance characteristics.

Python - collections.OrderedDict maintains insertion order and provides move_to_end(key) in O(1). - The built-in functools.lru_cache decorator uses OrderedDict internally and is the simplest way to add caching to a Python function. - The manual Python implementation mirrors the Java version: a dict for lookup and a doubly linked list (via custom Node class) for order. But the OrderedDict shortcut is idiomatic and often preferred.

C++ - std::unordered_map for O(1) average key lookup. - std::list for the DLL providing splice() operations to move nodes in O(1). - A list iterator stored in the map value gives direct access to the list node. - No sentinel nodes needed if using list's built-in end() as a sentinel-like boundary, but manual sentinels are still possible.

Both languages follow the same fundamental strategy: associative container for key lookup + ordered container for eviction order.

lru_cache_py.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from collections import OrderedDict

class LRUCache:
    def __init__(self, capacity: int):
        self.capacity = capacity
        self.cache = OrderedDict()

    def get(self, key: int, value: int) -> None:
        if key in self.cache:
            self.cache.move_to_end(key)
        self.cache[key] = value
        if len(self.cache) > self.capacity:
            self.cache.popitem(last=False)  # pop first (LRU)

# Example usage
cache = LRUCache(3)
cache.put(1, 1)
cache.put(2, 2)
cache.put(3, 3)
print(cache.get(1))  # 1
cache.put(4, 4)       # evicts 2
print(cache.get(2))  # -1
print(cache.get(3))  # 3
Output
1
-1
3
Python vs Java: Idiomatic Differences
  • Python's OrderedDict provides move_to_end() and popitem() built-in, making the implementation ~10 lines.
  • Java lacks a built-in ordered map with O(1) move-to-end, so manual implementation is more common.
  • C++ requires careful iterator management — ensure iterators are valid after list splice().
  • All three languages ultimately implement the same HashMap+DLL pattern under the hood.
Production Insight
In Python, the functools.lru_cache decorator is preferred for simple use cases — it adds thread safety with a lock and supports maxsize. For advanced needs (TTL, large capacity), use cachetools or a custom OrderedDict implementation. In C++, beware of iterator invalidation when mutating the list; using std::list with splice() is safe. The manual implementations in all three languages are functionally identical — only syntax differs.
Key Takeaway
Every language implements LRU as a combination of a hash map and an ordered structure. Python's OrderedDict provides the most concise implementation; C++ requires explicit iterator management. The logic remains identical.

Advantages and Disadvantages of LRU Cache

No cache eviction policy is perfect for every workload. Understanding the strengths and weaknesses of LRU helps you choose the right policy and design the right mitigations.

Advantages
  • Excellent temporal locality: LRU works well for workloads where recently accessed data is likely to be accessed again soon (the common case).
  • O(1) operations: With the HashMap+DLL design, get and put are both constant time.
  • Simple to implement: The algorithm is well-understood and straightforward to code.
  • Predictable memory usage: Fixed capacity ensures bounded memory consumption.
  • Good for streaming or sequential access with high locality of reference.
Disadvantages
  • Scan thrashing: When a full scan of items occurs (e.g., a database full-table scan), every item is accessed once, evicting all hot data. After the scan, the cache is cold. This is the Achilles' heel of LRU.
  • High memory overhead per entry: Each entry requires a key, value, two pointers (prev/next), plus hash map overhead. For large caches, this adds up.
  • Not adaptive to workload changes: LRU does not distinguish between a one-time access and a frequently accessed item — both look the same after one access.
  • Not suitable for all patterns: Workloads with cyclic access patterns (e.g., round-robin workloads) cause LRU to thrash.
  • No expiration mechanism: LRU evicts based on recency, but does not automatically expire stale data. TTL must be added separately.

Despite these disadvantages, LRU remains the most widely used cache eviction policy due to its simplicity and effectiveness for typical internet workloads.

When LRU Fails: Scan Resistance
  • A database buffer pool using LRU during a full table scan: the scan touches every row once, evicting all frequently accessed pages.
  • After the scan, every subsequent query experiences cache misses, severely degrading performance.
  • Solution: Use LRU-K (tracks last K accesses) or ARC (Adaptive Replacement Cache) which includes a ghost-entry mechanism to detect scans.
  • In practice, many production caches (like Caffeine) implement TinyLFU with a sketch-based frequency filter to avoid admitting scan items.
Production Insight
When deploying LRU in production, always monitor the cache hit rate and eviction rate. A sudden drop in hit rate may indicate scan thrashing. Consider adding a secondary data structure (like a bloom filter in TinyLFU) to prevent admitting one-time-use items. Also, measure per-entry memory overhead: for extremely large caches, the DLL pointers dominate memory usage, and a different data structure (like a CLOCK approximation) may be more memory-efficient.
Key Takeaway
LRU is optimal for temporal locality but vulnerable to scan thrashing. Its simplicity and O(1) performance make it the default choice for most caching scenarios.

Real-World Applications of LRU Cache

LRU eviction is ubiquitous in computing — any system with a fixed-size cache and temporal locality benefits from it. Here are the most common production deployments:

  1. CPU Caches (L1/L2/L3) — Modern CPUs use LRU-like policies to decide which cache line to evict when filling a new line from main memory. L1 caches often use pseudo-LRU to save on hardware cost.
  2. Web Browser Cache — Browsers store recently visited pages, images, and scripts in a fixed-size disk cache. When the cache is full, the browser evicts the least recently used resource.
  3. CDN Edge Nodes — Content Delivery Networks like Akamai, Cloudflare, and Fastly cache static content at edge servers. LRU ensures popular content stays cached while stale items are evicted.
  4. Database Buffer Pool — Database systems like MySQL (InnoDB) and PostgreSQL use a buffer pool to cache pages from disk. InnoDB uses a variant of LRU with a midpoint insertion strategy to protect frequently accessed pages.
  5. Redis — Redis uses approximate LRU for key eviction when memory is full (maxmemory-policy allkeys-lru). It samples a subset of keys and evicts the oldest among them.
  6. DNS Resolver Caches — DNS servers cache resolved domain names with TTL. When the cache reaches its limit, LRU eviction is used to make room for new entries.
  7. Operating System Page Cache — The Linux kernel uses a form of LRU (CLOCK algorithm) to manage the page cache — evicting pages that haven't been accessed recently.

These applications demonstrate that LRU is not just a data structure exercise; it is the backbone of performance in systems handling billions of requests per day.

Production Variants of LRU
  • CPU caches use pseudo-LRU (PLRU) — a binary tree approximation that saves hardware bits.
  • InnoDB uses midpoint insertion strategy to protect against scans: new pages enter at 3/8 from the tail, not the head.
  • Redis uses approximate LRU: it samples a few keys and evicts the oldest based on the sample.
  • These variants preserve the spirit of LRU while addressing specific hardware or workload constraints.
Production Insight
When deploying LRU in a new context, consider whether a pure LRU is appropriate or if a variant (like midpoint insertion) better suits your access pattern. Measure cache hit rates with realistic workloads before finalizing the policy. In high-throughput systems, the cost of maintaining the DLL (locking, pointer updates) can become significant — consider lock-free or near-LRU approximations.
Key Takeaway
LRU is used everywhere: from CPU microarchitecture to CDN edge caching. Each domain adapts the core LRU idea to its specific constraints (hardware cost, scan resistance, concurrency).

Time and Space Complexity of LRU Cache

The HashMap + Doubly Linked List design achieves O(1) for all core operations. Below is the formal complexity summary.

OperationTime ComplexityExplanation
get(key)O(1) averageHashMap lookup (O(1) average) + DLL remove + insert front (both O(1))
put(key, value) without evictionO(1) averageHashMap insert (O(1) average) + DLL insert front (O(1))
put(key, value) with evictionO(1) averageSame as above + identify LRU node (tail.prev O(1)) + DLL remove (O(1)) + HashMap remove (O(1))
space (per entry)O(1)Each entry stores key, value, prev, next pointers (approx 4 pointers in Java, 3 in C++ with list node overhead). HashMap entry adds extra overhead.
total spaceO(capacity)Fixed capacity ensures linear memory usage proportional to the number of entries.

Key insight: All operations are amortized O(1) because HashMap operations are O(1) on average (assuming good hash distribution). The DLL operations are strictly O(1). The worst-case of HashMap is O(n) for a single put, but with a proper hash function and load factor, the amortized average is O(1).

Memory overhead: For production systems with millions of entries, the per-entry overhead of the DLL (two pointers) plus HashMap (entry object with key, value, hash, next pointer) can be 40-72 bytes per entry in Java. This is the price of O(1) eviction. If memory is constrained, consider using a CLOCK algorithm (which uses a single reference bit per entry) at the cost of approximate LRU.

Memory Optimization: Compress Pointers
In Java, -XX:+UseCompressedOops reduces pointer size from 8 to 4 bytes on heaps < 32GB. This cuts the per-entry overhead of the DLL from ~32 bytes (four 8-byte pointers) to ~16 bytes. For caches with 10M entries, that saves 160 MB of heap. Enable compressed OOPs by default in modern JVMs.
Production Insight
The O(1) complexity of LRU is unmatched by FIFO (also O(1) but worse hit rate) or LFU (requires priority queue, O(log n) or O(1) with heavy memory). In practice, the constant factors of memory and locking dominate at scale. For high-throughput systems like Redis, the approximate LRU (sampling approach) sacrifices precision for lower overhead — a trade-off that highlights that theoretical complexity isn't the only metric.
Key Takeaway
LRU cache provides O(1) time for all core operations with O(capacity) space. The per-entry memory overhead is the main cost, justifying the use of compression or approximate variants in memory-constrained environments.

LRU vs Other Eviction Policies — Comparison

Choosing the right eviction policy depends on workload characteristics. Below is a comparison of the four most common policies: LRU, LFU (Least Frequently Used), FIFO (First In First Out), and ARC (Adaptive Replacement Cache).

PolicyAlgorithmTime Complexity (get/put)Memory per EntryScan ResistanceAdaptivityUse Case
LRUEvict least recently usedO(1) averageHigh (2 pointers + map)PoorNoneGeneral temporal locality
LFUEvict least frequently usedO(log n) or O(1) with heavy structureVery High (frequency counter + heap)ExcellentNoneItems with stable popularity (e.g., CDN)
FIFOEvict oldest insertionO(1)Low (queue)PoorNoneKnown sequential patterns
ARCAdapts between LRU and LFUO(1) amortizedHigh (4 lists + ghost entries)ExcellentHighVariable workloads, high performance
Key takeaways
  • LRU is the simplest O(1) policy and works well for most workloads but suffers from scan thrashing.
  • LFU requires more computation and memory to track frequencies but resists scans. However, it cannot forget stale items quickly.
  • FIFO is the cheapest but worst hit rate; used when the cost of tracking recency outweighs benefits.
  • ARC combines the best of LRU and LFU adaptively using ghost entries but has higher implementation complexity.
Production Recommendation
  • Start with LRU for simplicity and O(1) performance.
  • Monitor hit rates: if scan thrashing is observed, switch to ARC or TinyLFU (Caffeine).
  • For read-heavy workloads with stable popularity, LFU can outperform LRU.
  • For cache sizes > 1M entries, consider the memory overhead of DLL pointers — CLOCK or approximate LRU may be better.
Production Insight
In production, the choice of eviction policy is often dictated by the caching library. Caffeine uses TinyLFU (a variant of LFU with frequency sketch) which provides scan resistance and adaptivity. Redis uses approximate LRU by default but supports LFU and TTL-based eviction. For custom caches, LRU is the safest starting point; add adaptivity later if needed.
Key Takeaway
LRU is the baseline eviction policy. ARC and TinyLFU offer better scan resistance at higher complexity. Select based on workload characteristics and cache size.

Common Implementation Mistakes

Even experienced developers make these mistakes when implementing LRU caches. Recognise them, and you'll avoid production incidents.

Mistake 1: Forgetting to remove from DLL during eviction This is the most common bug. The eviction path removes a node from the HashMap but omits the DLL removal. The node remains reachable through the DLL, causing a memory leak. Symptom: heap grows unboundedly. Fix: always remove from DLL first, then from the map.

Mistake 2: Not moving existing nodes to head on get If get() doesn't promote the accessed node to MRU, recently used items can be evicted prematurely. Symptom: hit rate drops after deployment. Fix: call remove() then insertFront() on every get hit.

Mistake 3: Forgetting to handle duplicate keys on put When the same key is put again, the old node must be removed from the DLL before inserting the new one. Otherwise, duplicate nodes exist in the DLL for the same key. Symptom: inconsistent state, NPE. Fix: in the existing key branch, remove the old node before updating.

Mistake 4: Not using sentinel nodes Without sentinel head/tail, first insert and last remove require null checks. Missing these checks leads to NPE on edge cases. Symptom: crashes on first put or last eviction. Fix: always use sentinel nodes.

Mistake 5: Ignoring thread safety Using the basic cache in a multi-threaded context corrupts the DLL. Symptom: random NPE, infinite loops. Fix: add locks (ReentrantReadWriteLock) or use a concurrent library.

Most Critical Mistake
  • Missing DLL removal during eviction is the #1 LRU bug in production.
  • It's silent: the HashMap appears correct, but the DLL grows unboundedly.
  • Always test with isConsistent() after every eviction.
  • Heap dumps showing many Node objects unreachable from map but alive in DLL confirm this bug.
Production Insight
In the production incident described earlier, mistake #1 was the root cause. The team spent hours debugging a supposed serialization leak, when the real issue was a missing DLL removal in a refactored eviction path. The isConsistent() check would have caught it immediately. Add that check in debug builds and integration tests — it's a cheap safety net.
Key Takeaway
The top LRU bugs are: missing DLL removal on eviction, no promotion on get, duplicate key handling, missing sentinels, and no thread safety. Each has a clear symptom and fix. Use isConsistent() to catch sync bugs early.

Interview Questions

LRU cache is a classic interview topic. Expect to implement it from scratch, then handle follow-ups about concurrency, generics, and memory trade-offs.

Q1: Implement an LRU cache with O(1) get and put. This tests your understanding of HashMap + Doubly Linked List. Use sentinel nodes. Show the eviction path clearly. Expect to write working code in 15-20 minutes.

Q2: Make your LRU cache thread-safe. Discuss lock granularity. ReentrantReadWriteLock is the go-to. Explain why promotion forces a lock escalation pattern. Mention CAS-based lock-free approaches for very high throughput.

Q3: How would you add TTL expiration to an LRU cache? Add a timestamp to each node. On get, check if expired; if so, evict and return miss. On put, check and evict expired entries. An alternative is a background thread that sweeps expired entries periodically.

Q4: What happens if the cache is accessed concurrently without locks? Describe corruption: two threads modifying prev/next at the same time can break the list into a cycle or lose nodes. Show an example: thread A reads node N, thread B evicts N, then A promotes N — now N is in the list but also marked as evicted.

Q5: Compare LRU with LFU and ARC. LRU is simple but vulnerable to scans. LFU resists scans but uses more memory. ARC adapts between them with ghost entries. Choose based on workload: LRU for temporal locality, LFU for stable popularity, ARC for mixed.

Interview Tip: Structure Your Answer
  • Start with the data structures: HashMap for lookup, DLL for order.
  • Explain why sentinel nodes eliminate null checks.
  • Walk through get and put step by step, including the eviction path.
  • Mention the isConsistent() invariant as a debugging tool.
  • Follow up with thread safety and TTL as extensions.
Production Insight
What interviewers don't tell you is that real LRU caches in production rarely use the textbook implementation. Libraries like Caffeine and Guava use generational schemes and frequency sketches. But understanding the canonical implementation shows you grasp pointer manipulation and invariant maintenance — skills that matter when debugging a production leak.
Key Takeaway
LRU cache implementation is a top interview question. Master the HashMap+DLL design with sentinels, understand thread-safety trade-offs, and know how to extend with TTL. These are the foundations of a senior engineer's cache knowledge.

Problem Statement — What You're Actually Building

Stop thinking about this as a 'data structure' exercise. It's a resource management problem. You have finite memory, and you need to decide what to throw away when something new arrives. The LRU Cache is just one strategy: evict the item that's been untouched the longest.

The formal requirements are dead simple — but the constraints are where the trap lives. You need a get(key) that returns the value or -1, and a put(key, value) that inserts or updates. Both must run in O(1) average time. If the cache hits capacity before a put, you evict the least recently used entry before inserting.

Most developers nail the logic in 40 lines of hash map and linked list. But they choke when you ask: 'What happens under concurrent access?' or 'How do you prevent memory leaks when keys are abandoned?' That's the difference between a pass and a promotion.

LRUProblemStatement.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// io.thecodeforge — dsa tutorial

// The spec in code form — nothing more
public class LRUCache {
    public LRUCache(int capacity) {
        // capacity > 0, or throw
    }
    
    public int get(int key) {
        // O(1) — return value, or -1 if missing
    }
    
    public void put(int key, int value) {
        // O(1) — upsert, evict LRU if full
    }
}
Output
// No output — this is the contract, not the implementation
Production Trap:
LeetCode will let you return -1 for missing keys. In production, returning a sentinel value is a code smell. Throw a checked exception or return Optional.empty(). Your callers will thank you when they don't silently propagate -1 through a payment pipeline.
Key Takeaway
An LRU cache is a resource-eviction policy, not a data structure. Always solve the O(1) constraint first, then worry about correctness.

Efficient Solution — Doubly Linked List + Hash Map, No Surprises

You need O(1) get and put. That means instant lookup (hash map) and instant removal from the 'recently used' order (linked list). A singly linked list won't cut it — removing the tail requires O(n) traversal. That's why we use a doubly linked list with a dummy head and tail. Dummy nodes eliminate null checks on boundary removals. Senior move: they also catch logic bugs early because you never dereference a null next or prev on the edges.

The hash map stores the key and a reference to the linked list node. When you get a key, you move that node to the head (most recently used). When you put a key, you either update the existing node and move it to the head, or create a new node at the head. If capacity is exceeded, you remove the tail's previous node (the LRU item) and delete it from the hash map.

Here's the implementation that has survived three production rewrites at my current company. It's not the shortest. It's the one that doesn't crash at 3 AM under load.

LRUCacheEfficient.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// io.thecodeforge — dsa tutorial

import java.util.HashMap;
import java.util.Map;

public class LRUCache {
    private static class Node {
        int key, value;
        Node prev, next;
        Node(int key, int value) { this.key = key; this.value = value; }
    }

    private final int capacity;
    private final Map<Integer, Node> map = new HashMap<>();
    private final Node head = new Node(0, 0); // dummy
    private final Node tail = new Node(0, 0); // dummy

    public LRUCache(int capacity) {
        this.capacity = capacity;
        head.next = tail;
        tail.prev = head;
    }

    public int get(int key) {
        Node node = map.get(key);
        if (node == null) return -1;
        remove(node);
        insertAtHead(node);
        return node.value;
    }

    public void put(int key, int value) {
        Node node = map.get(key);
        if (node != null) {
            node.value = value;
            remove(node);
            insertAtHead(node);
            return;
        }
        if (map.size() == capacity) {
            Node lru = tail.prev;
            map.remove(lru.key);
            remove(lru);
        }
        Node newNode = new Node(key, value);
        map.put(key, newNode);
        insertAtHead(newNode);
    }

    private void remove(Node node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }

    private void insertAtHead(Node node) {
        node.next = head.next;
        node.prev = head;
        head.next.prev = node;
        head.next = node;
    }
}
Output
// No output — compile and run your own tests, dev
Senior Shortcut:
Dummy head and tail nodes are not optional. They turn every boundary case into a generic case. Without them, you write special handling for the first and last node — and that's where 90% of LRU bugs live.
Key Takeaway
Doubly linked list + hash map is the canonical O(1) solution. Dummy nodes prevent null pointer exceptions on edge cases. Always remove the node from the list before updating its position.

Complexity Analysis — What the Interviewers Really Want to Hear

Time complexity is O(1) for both get and put — amortized constant for put if you consider hash map resizing. Space complexity is O(capacity) for the hash map and linked list combined. That's the easy part.

The harder question: 'Can you make the space complexity O(capacity) without the linked list overhead?' The answer is no, because you need order tracking. The linked list doubles the memory per entry — one Node object with two references. In Java, that's roughly 40 bytes per entry plus the hash map overhead. For a cache of 10,000 entries, expect around 1.2 MB. That's fine for most systems, but if you're caching on embedded devices or real-time trading systems, you'll want to explore alternatives like an array-based circular buffer with a monotonically increasing timestamp (O(log n) eviction, but no linked list overhead).

The real production concern isn't big-O. It's garbage collection churn. Every put can create a new Node. If you're doing millions of puts per second, you'll flood the young generation. That's why production caches like Caffeine use Segmented Least Recently Used (SLRU) with slabs, not a single list. But for a DSA interview, don't even breathe that — just say O(1) and move on.

ComplexityNotes.javaJAVA
1
2
3
4
5
6
7
8
9
// io.thecodeforge — dsa tutorial

// Space breakdown per entry:
// Node object: 16 bytes (header) + 4 (int key) + 4 (int value) + 8 (prev ref) + 8 (next ref) = ~40 bytes
// HashMap entry: another 32–48 bytes depending on implementation
// Total: ~80 bytes per cached item at minimum
public class SpaceNote {
    // For 10,000 entries: ~800 KB to 1.2 MB
}
Output
// No output — just raw numbers. Memorize them.
Production Trap:
Java's default HashMap resizing creates temporary garbage. If your LRU cache is in a latency-sensitive path, pre-size the map with capacity / 0.75 load factor or use a hash table that doesn't resize. Caffeine does this. You should too.
Key Takeaway
O(1) time, O(capacity) space. But memory per entry matters more than the asymptotic bound in production — a Node and HashMap entry together cost ~80 bytes per cached item.

FIFO Eviction: Why Simple Gets Smoked by Real Workloads

First-In-First-Out sounds innocent enough: evict the oldest entry regardless of how hot it is. That's exactly why it fails in production. FIFO cannot distinguish between a one-time request that happened to arrive early and a critical configuration key accessed every millisecond. Once an entry is buried under newer items, it gets evicted even if it's the most valuable piece of data in the cache.

In practice, FIFO exhibits terrible hit rates under temporal locality — the exact pattern most real systems exhibit. LRU exploits the fact that recently accessed data is likely to be accessed again. FIFO ignores that completely. A batch job that scans a million keys once will poison FIFO's entire cache, while LRU keeps the frequently accessed working set warm.

Don't reach for FIFO unless you're logging or tracing where eviction order truly doesn't matter. For caching, FIFO is the junior-dev default. You know better.

FifoCacheDemo.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// io.thecodeforge — dsa tutorial

import java.util.*;

// Simple FIFO eviction using a queue + map — don't use this
class FifoCache<K, V> {
    private final int capacity;
    private final Queue<K> queue = new LinkedList<>();
    private final Map<K, V> map = new HashMap<>();

    public FifoCache(int capacity) { this.capacity = capacity; }

    public V get(K key) { return map.get(key); } // Never updates order!

    public void put(K key, V value) {
        if (map.containsKey(key)) {
            map.put(key, value);
            return;
        }
        if (map.size() == capacity) {
            K oldest = queue.poll();
            map.remove(oldest);
        }
        queue.offer(key);
        map.put(key, value);
    }

    public static void main(String[] args) {
        FifoCache<String, String> cache = new FifoCache<>(3);
        cache.put("A", "1");
        cache.put("B", "2");
        cache.put("C", "3");
        cache.get("A"); // Access A — doesn't update order
        cache.put("D", "4"); // Evicts A, even though A was just accessed!
        System.out.println(cache.get("A")); // null → A is gone
    }
}
Output
null
Production Trap:
FIFO seems cheap because it’s O(1) everywhere. But that cheapness costs you cache hits. Under real traffic with any temporal locality, LRU's hit rate crushes FIFO. Never choose FIFO unless your access pattern is purely sequential with no reuse — and even then, ask why you need a cache at all.
Key Takeaway
FIFO evicts based on insertion time, not access frequency. That makes it useless for workloads with temporal locality.

Hash Map + Array: The Academic Toy You Shouldn't Ship

You can implement an LRU cache using an array of cache entries and a hash map that stores each key's index. On every access, you update the index in the map and swap entries around in the array to maintain recency order. It works in theory. In production, it’s a disaster.

The core problem is that array-based implementations require O(n) shifts or O(n) compaction when the cache fills up and you evict the least recently used entry. Even O(log n) with a heap is worse than the O(1) eviction from a doubly linked list. More importantly, array implementations make the code brittle: off-by-one errors, capacity resizing that evicts data silently, and index invalidation become your daily headache.

Senior engineers use a doubly linked list + hash map for a reason. It’s not elegant — it’s practical. The list gives you O(1) removals from any position (as long as you hold the node reference), and the map gives you O(1) lookups. Trying to optimize array memory locality is a premature optimization that introduces complexity and bugs. Save your weekend. Use the list.

ArrayLruAntiPattern.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// io.thecodeforge — dsa tutorial

import java.util.*;

// DON'T DO THIS. Array-based LRU is a nightmare.
class ArrayLru<K, V> {
    private final int capacity;
    private int size = 0;
    private final Entry<K, V>[] entries;
    private final Map<K, Integer> indexMap = new HashMap<>();

    static class Entry<K, V> { K key; V value; long lastAccess; }

    @SuppressWarnings("unchecked")
    ArrayLru(int capacity) {
        this.capacity = capacity;
        this.entries = new Entry[capacity];
    }

    public V get(K key) {
        Integer idx = indexMap.get(key);
        if (idx == null) return null;
        entries[idx].lastAccess = System.nanoTime();
        return entries[idx].value;
    }

    public void put(K key, V value) {
        Integer existing = indexMap.get(key);
        if (existing != null) {
            entries[existing].value = value;
            entries[existing].lastAccess = System.nanoTime();
            return;
        }
        if (size == capacity) evictOldest();
        Entry<K, V> e = new Entry<>();
        e.key = key; e.value = value; e.lastAccess = System.nanoTime();
        entries[size] = e;
        indexMap.put(key, size);
        size++;
    }

    private void evictOldest() {
        int oldestIdx = 0;
        for (int i = 1; i < size; i++)
            if (entries[i].lastAccess < entries[oldestIdx].lastAccess)
                oldestIdx = i;
        indexMap.remove(entries[oldestIdx].key);
        entries[oldestIdx] = entries[size - 1]; // Compaction! O(n) per evict
        indexMap.put(entries[oldestIdx].key, oldestIdx);
        size--;
    }
}
Output
(Compiles. Don't ship it.)
Senior Shortcut:
An array-based LRU is a signal to your teammates that you're optimizing for the wrong thing. Memory locality? Doubly linked list nodes are scattered anyway — a contiguous array doesn't help. Just use LinkedHashMap.removeEldestEntry() for 90% of cases, or the linked list + map combo for the rest.
Key Takeaway
Array-based LRU requires O(n) eviction scans. Doubly linked list + hash map gives O(1) eviction and access. Pick the simpler, faster structure.
● Production incidentPOST-MORTEMseverity: high

The Session Cache That Leaked 4GB of Memory

Symptom
Heap usage grows linearly after deployment. OOM kills every 6 hours. Thread dump shows cache put operations succeeding but no evictions. Heap dump shows millions of SessionNode objects unreachable from the HashMap but still referenced by the DLL.
Assumption
The team initially blamed a memory leak in the session serialization layer, because the heap dump showed millions of SessionNode objects. The root cause was misattributed to a third-party library.
Root cause
A recent refactoring changed the eviction logic. The original code removed the LRU node from both the DLL and the HashMap during eviction. The refactored code only removed the node from the HashMap (del self.map[lru.key]) but forgot to call _remove(lru) on the DLL. The DLL grew unboundedly, keeping every evicted node alive through the DLL's next/prev pointers. The HashMap was correct, but the DLL leaked memory.
Fix
1. Restored the _remove(lru) call before del self.map[lru.key] in the eviction path. 2. Added an invariant check: assert len(self.map) == self._dll_size() in debug builds. 3. Added a metric: cache.dll.size vs cache.map.size — alert if they diverge. 4. Added integration tests that verify DLL and HashMap sizes match after 100K put operations with capacity=100.
Key lesson
  • Every eviction must update BOTH the HashMap and the DLL. Missing either causes a memory leak or stale references.
  • After any refactoring of cache eviction logic, verify that the DLL and HashMap remain in sync by comparing their sizes.
  • Add invariant assertions in debug builds: DLL Heap dumps showing objects unreachable from the HashMap but alive in the DLL are a strong signal of a broken eviction path.
Production debug guideSymptom-driven investigation paths for cache-related incidents.5 entries
Symptom · 01
Memory grows unboundedly despite fixed cache capacity.
Fix
Check if eviction removes nodes from BOTH the HashMap and the DLL. A missing DLL removal causes the DLL to grow while the HashMap stays correct.
Symptom · 02
Cache hit rate drops after a deployment.
Fix
Verify that get() moves the accessed node to the head (MRU position). If the move-to-head logic is broken, recently accessed items are evicted prematurely.
Symptom · 03
NullPointerException during get or put.
Fix
Check if sentinel head/tail nodes are properly initialized. Missing sentinel initialization causes null dereference on the first insert or remove.
Symptom · 04
Duplicate keys in the DLL but not in the HashMap.
Fix
Check if put() for an existing key removes the old node from the DLL before inserting the new one. Missing the remove step creates duplicate DLL entries.
Symptom · 05
Cache returns stale values after put with existing key.
Fix
Verify that put() with an existing key updates the node's value AND moves it to the head. Updating the value without moving to head preserves stale ordering.
★ LRU Cache Triage Cheat SheetFast commands to diagnose common production issues with LRU cache implementations.
Memory grows unboundedly — eviction appears broken.
Immediate action
Heap dump to compare DLL node count vs HashMap entry count.
Commands
jcmd <pid> GC.heap_dump /tmp/heap.hprof
jmap -histo <pid> | grep Node (count Node instances)
Fix now
If DLL node count >> HashMap size, eviction is not removing from DLL. Check the eviction code path for a missing _remove() call.
NullPointerException in cache get/put — sporadic crashes.+
Immediate action
Thread dump to confirm stack trace location.
Commands
jcmd <pid> Thread.print
grep -r 'sentinel\|dummy' src/ (verify sentinel initialization)
Fix now
Ensure sentinel head and tail are initialized in the constructor and never set to null.
Cache hit rate drops to near zero after deployment.+
Immediate action
Add metrics: cache.hits, cache.misses, cache.evictions. Compare pre/post deployment.
Commands
Check monitoring dashboard for hit_rate metric
jcmd <pid> GC.class_histogram (check for unexpected object growth)
Fix now
Verify get() calls _remove() then _insert_front() in that order. Missing either breaks the promotion-to-MRU logic.
LRU vs Other Eviction Policies
PolicyTime Complexity (get/put)Memory per EntryScan ResistanceAdaptivityUse Case
LRUO(1) averageHigh (2 pointers + map)PoorNoneGeneral temporal locality
LFUO(log n) or O(1)*Very High (counter + heap)ExcellentNoneStable popularity (e.g., CDN)
FIFOO(1)Low (queue)PoorNoneKnown sequential patterns
ARCO(1) amortizedHigh (4 lists + ghosts)ExcellentHighVariable workloads

Common mistakes to avoid

5 patterns
×

Missing DLL removal during eviction

Symptom
Heap grows unboundedly even though map size stays at capacity. Heap dump shows many Node objects alive in DLL but not in HashMap.
Fix
Always remove the node from the DLL before removing from HashMap in the eviction path. Use isConsistent() check to verify sizes match.
×

Not promoting node to MRU on get

Symptom
Cache hit rate drops after deployment because recently accessed items are evicted prematurely.
Fix
Ensure get() calls remove() then insertFront() on the accessed node.
×

Failing to remove old node when same key is put again

Symptom
Duplicate keys in DLL, leading to inconsistent state and possible NPE when traversing.
Fix
In the put() method, when key exists, remove the old node from DLL before inserting the new one.
×

Not using sentinel nodes

Symptom
NullPointerException on first insert or last remove due to null head/tail.
Fix
Initialize sentinel head and tail nodes in the constructor and never set them to null.
×

Ignoring thread safety in concurrent access

Symptom
Random NPE, infinite loops, or corrupted DLL under concurrent load.
Fix
Use ReentrantReadWriteLock or synchronize all public methods. For high throughput, consider lock-free designs.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Implement an LRU cache with O(1) get and put. Describe the data structur...
Q02SENIOR
How would you make your LRU cache thread-safe? Explain the locking strat...
Q03SENIOR
Add TTL (time-to-live) expiration to your LRU cache. How would you imple...
Q04SENIOR
What happens when an LRU cache is accessed concurrently without synchron...
Q05SENIOR
Compare LRU with LFU and ARC eviction policies. When would you choose ea...
Q01 of 05SENIOR

Implement an LRU cache with O(1) get and put. Describe the data structures and write the code.

ANSWER
Use a HashMap for O(1) key lookup and a Doubly Linked List for O(1) order maintenance. Maintain sentinel head and tail nodes to avoid null checks. On get, if key exists, move the node to the front of the DLL. On put, if key exists, update value and move to front; if new, create node, insert at front, add to map. If cache exceeds capacity, remove the node before tail (LRU) from both DLL and map. See the implementation section for full Java code.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Why use a doubly linked list instead of a singly linked list for LRU cache?
02
Can I use an array instead of a linked list for LRU?
03
How does Redis implement LRU?
04
What is the difference between LRU and FIFO eviction?
05
How do I add TTL expiration to a Java LRU cache?
N
Naren Founder & Principal Engineer

20+ years shipping performance-critical code where algorithms decide the bill. Drawn from code that ran under real load.

Follow
Verified
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
🔥

That's Linked List. Mark it forged?

18 min read · try the examples if you haven't

Previous
Remove Nth Node from End
9 / 10 · Linked List
Next
Clone a Linked List with Random Pointers