LRU Cache Eviction — Missing DLL Removal Causes 4GB Leak
Heap grew 4GB in 6 hours — eviction missed DLL removal.
20+ years shipping performance-critical code where algorithms decide the bill. Drawn from code that ran under real load.
- 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
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.
- 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.
How LRU Cache Works — Step by Step
- 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.
- 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.
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.
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.
- 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.
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.
- 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.
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.
- 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.
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.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.
- 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.
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.
- 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.
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.
- Python's OrderedDict provides
move_to_end()andpopitem()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.
splice() is safe. The manual implementations in all three languages are functionally identical — only syntax differs.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.
- 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.
- 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.
- 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.
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:
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
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.
| Operation | Time Complexity | Explanation |
|---|---|---|
| get(key) | O(1) average | HashMap lookup (O(1) average) + DLL remove + insert front (both O(1)) |
| put(key, value) without eviction | O(1) average | HashMap insert (O(1) average) + DLL insert front (O(1)) |
| put(key, value) with eviction | O(1) average | Same 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 space | O(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.
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).
| Policy | Algorithm | Time Complexity (get/put) | Memory per Entry | Scan Resistance | Adaptivity | Use Case |
|---|---|---|---|---|---|---|
| LRU | Evict least recently used | O(1) average | High (2 pointers + map) | Poor | None | General temporal locality |
| LFU | Evict least frequently used | O(log n) or O(1) with heavy structure | Very High (frequency counter + heap) | Excellent | None | Items with stable popularity (e.g., CDN) |
| FIFO | Evict oldest insertion | O(1) | Low (queue) | Poor | None | Known sequential patterns |
| ARC | Adapts between LRU and LFU | O(1) amortized | High (4 lists + ghost entries) | Excellent | High | Variable workloads, high performance |
- 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.
- 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.
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.
- 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.
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.
- 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.
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.
Optional.empty(). Your callers will thank you when they don't silently propagate -1 through a payment pipeline.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.
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.
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.
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.
LinkedHashMap.removeEldestEntry() for 90% of cases, or the linked list + map combo for the rest.The Session Cache That Leaked 4GB of Memory
_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.- 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.
get() moves the accessed node to the head (MRU position). If the move-to-head logic is broken, recently accessed items are evicted prematurely.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.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.jcmd <pid> GC.heap_dump /tmp/heap.hprofjmap -histo <pid> | grep Node (count Node instances)_remove() call.Common mistakes to avoid
5 patternsMissing DLL removal during eviction
Not promoting node to MRU on get
get() calls remove() then insertFront() on the accessed node.Failing to remove old node when same key is put again
put() method, when key exists, remove the old node from DLL before inserting the new one.Not using sentinel nodes
Ignoring thread safety in concurrent access
Practice These on LeetCode
Interview Questions on This Topic
Implement an LRU cache with O(1) get and put. Describe the data structures and write the code.
Frequently Asked Questions
20+ years shipping performance-critical code where algorithms decide the bill. Drawn from code that ran under real load.
That's Linked List. Mark it forged?
18 min read · try the examples if you haven't