Skip to content
Home Java LinkedHashMap LRU Cache - Missing removeEldestEntry OOM

LinkedHashMap LRU Cache - Missing removeEldestEntry OOM

Where developers are forged. · Structured learning · Free forever.
📍 Part of: Collections → Topic 9 of 21
Service OOM after hours: heap dump had millions of entries because removeEldestEntry wasn't overridden in LRU cache.
⚙️ Intermediate — basic Java knowledge assumed
In this tutorial, you'll learn
Service OOM after hours: heap dump had millions of entries because removeEldestEntry wasn't overridden in LRU cache.
  • LinkedHashMap maintains insertion order; HashMap does not.
  • Pass accessOrder=true to LinkedHashMap constructor for LRU semantics.
  • Override removeEldestEntry() to implement a bounded LRU cache in 10 lines.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer

LinkedHashMap and LinkedHashSet maintain insertion order — iteration yields elements in the order they were added. LinkedHashMap also supports access-order mode, where recently accessed entries move to the tail — the foundation of an LRU cache. They have the same O(1) average complexity as HashMap and HashSet with a small memory overhead for the doubly-linked list.

🚨 START HERE

Quick Debug Steps for LinkedHashMap Issues

Commands and checks for the most common problems.
🟡

LRU cache not evicting

Immediate ActionCheck constructor call: ensure third argument is true (accessOrder=true).
Commands
Add a debug log in removeEldestEntry: System.out.println("Eldest: " + eldest + " size=" + size());
Use jmap -histo:live <pid> | head -20 to see number of entries in the map.
Fix NowAdd a hard limit by overriding removeEldestEntry to return size() > capacity, and set accessOrder=true. Restart the service.
🟡

LinkedHashSet iteration order unexpected

Immediate ActionVerify you are not constructing the set from another collection that has a different order (e.g., TreeSet or HashSet).
Commands
Print the set after each insertion to trace order changes.
Check hashCode() of elements — if mutable, hash can change and affect order.
Fix NowEnsure elements are immutable or use a List if duplicates are acceptable and order is critical.
Production Incident

LRU Cache Used accessOrder = false — No Eviction Happened

A microservice cached user session data with a LinkedHashMap, expecting LRU eviction, but the cache grew unboundedly until OOM.
SymptomThe service ran out of memory after a few hours of moderate load. Heap dumps revealed millions of map entries.
AssumptionThe developer assumed that by default, LinkedHashMap evicts the oldest entry when the size exceeds a threshold. They used a simple size check in removeEldestEntry but forgot to set accessOrder to true.
Root causeThe developer did not override removeEldestEntry(). The default implementation always returns false, so the map grows without bound. They assumed that passing a capacity parameter would automatically limit size, but LinkedHashMap's capacity is only a hint for the hash table's initial size, not a hard limit.
FixOverride removeEldestEntry() to return size() > capacity. Also set accessOrder=true to get LRU semantics. Example: new LinkedHashMap<K,V>(capacity, 0.75f, true) and override removeEldestEntry.
Key Lesson
LinkedHashMap does not auto-evict. You must override removeEldestEntry and set accessOrder=true for LRU behavior.Always test cache eviction under load. A heap dump or jstat can reveal unbounded growth.Use jvisualvm or jmap to inspect the map's size if you suspect a memory leak.
Production Debug Guide

Common symptoms and their root causes

Iteration order is not insertion order (random)You may have used HashMap instead of LinkedHashMap. Check the variable type. Also verify that you did not create a new LinkedHashMap from another Map, which may reorder entries during copy.
LRU cache evicts wrong entries or not evicting at allCheck that accessOrder=true in the constructor. If false, the map uses insertion-order. Verify removeEldestEntry returns true when capacity exceeded. Also check that get() is called on access – otherwise the entry order is not updated.
LinkedHashSet allows duplicates? (Not possible, but order seems wrong)LinkedHashSet never allows duplicates. If you see duplicates, you might be comparing objects with inconsistent equals/hashCode. Check the equals method of the elements.
Memory grows unexpectedly when using LinkedHashMapCheck for missing removeEldestEntry override. Also consider that the map holds strong references to values. If you use it as a cache, ensure values are not preventing GC elsewhere. Use jmap -histo:live to find the map's size.

HashMap and HashSet are fast but their iteration order is undefined — you get a different order on different JVM runs. LinkedHashMap and LinkedHashSet solve this by maintaining a doubly-linked list through their entries in addition to the hash table.

The most practical consequence: if you need a Map that remembers insertion order (for example, building JSON, preserving request parameter order, or implementing an LRU cache), LinkedHashMap is the right tool.

LinkedHashMap — Insertion Order

LinkedHashMap extends HashMap and adds a doubly-linked list that runs through all entries. The list defines the iteration ordering, which by default is insertion-order: the order in which keys were inserted into the map. This is guaranteed as long as no rehash operations disrupt the list. Rehashing does not change the order because the list is maintained separately from the bucket array.

When you put a key that already exists, the mapping is updated but the entry stays in its original position in the list. This is the key behavior that differs from access-order mode.

LinkedHashMapDemo.java · JAVA
1234567891011121314151617181920212223
package io.thecodeforge.java.collections;

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

public class LinkedHashMapDemo {
    public static void main(String[] args) {
        // Insertion-order map
        Map<String, Integer> scores = new LinkedHashMap<>();
        scores.put("Alice", 95);
        scores.put("Charlie", 88);
        scores.put("Bob", 92);

        // Iteration order = insertion order — always
        scores.forEach((name, score) ->
            System.out.println(name + ": " + score));
        // Alice: 95
        // Charlie: 88
        // Bob: 92

        // HashMap would print in some arbitrary order — not predictable
    }
}
▶ Output
Alice: 95
Charlie: 88
Bob: 92
📊 Production Insight
Insertion-order is not the same as creation-order. If you put a key twice, the position remains from the first insertion.
When you iterate over a LinkedHashMap that is being concurrently modified, you get ConcurrentModificationException — just like HashMap.
The linked list adds ~24 bytes per entry on HotSpot (two references: before and after).
🎯 Key Takeaway
Insertion-order is stable and predictable.
Rehashing doesn’t change the order of existing entries.
Use insertion-order when you need to preserve the order of keys as they were first added.

LRU Cache Using Access-Order LinkedHashMap

Pass true as the third constructor argument (accessOrder=true) and override removeEldestEntry to cap the size. This is a textbook LRU cache.

In access-order mode, every get or put moves the accessed entry to the tail of the list. The head of the list is the least recently used entry. When removeEldestEntry returns true (typically when size() exceeds a threshold), that eldest (head) entry is removed. This gives you O(1) get, put, and eviction — but the eviction is lazy, occurring only on new inserts after the threshold is passed.

This implementation is not thread-safe. For a concurrent LRU cache, use Caffeine or Guava's CacheBuilder.

LRUCache.java · JAVA
123456789101112131415161718192021222324252627282930313233
package io.thecodeforge.java.collections;

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

public class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private final int capacity;

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

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

    public static void main(String[] args) {
        LRUCache<Integer, String> cache = new LRUCache<>(3);
        cache.put(1, "one");
        cache.put(2, "two");
        cache.put(3, "three");
        System.out.println(cache.keySet()); // [1, 2, 3]

        cache.get(1);  // access 1 — moves to tail (most recently used)
        System.out.println(cache.keySet()); // [2, 3, 1]

        cache.put(4, "four");  // capacity exceeded — removes 2 (least recently used)
        System.out.println(cache.keySet()); // [3, 1, 4]
        System.out.println(cache.containsKey(2)); // false — evicted
    }
}
▶ Output
[1, 2, 3]
[2, 3, 1]
[3, 1, 4]
false
📊 Production Insight
The default capacity parameter in the super call is only the initial table size, not the max size. The eviction is triggered only when put is called after the map is full.
If you forget to override removeEldestEntry, the map grows unboundedly — a common production memory leak.
Access-order mode is not a true LRU because it uses the tail for most recent and head for least recent; but if you don't call get(), entries never move. So a put-only pattern makes it behave like insertion-order with eviction of the first inserted.
🎯 Key Takeaway
Three things for a correct LRU: accessOrder=true, override removeEldestEntry, and call get() on every access.
Without override, no eviction happens.
The capacity in the constructor is not the max size — it's the initial hash table size.

LinkedHashSet — Insertion-Order Set

LinkedHashSet is backed by a LinkedHashMap (with dummy values). It inherits all properties of HashSet plus a guaranteed iteration order — the order in which elements were inserted.

This is incredibly useful when you need to deduplicate a stream of data while preserving the order of first occurrence. Common use cases include building a list of unique recent actions, maintaining a priority order for a job queue, or generating a unique list of visited URLs in a web crawler.

Like LinkedHashMap, the order is unaffected by rehashing and is consistent over time if no modifications are made.

LinkedHashSetDemo.java · JAVA
12345678910111213141516171819202122
package io.thecodeforge.java.collections;

import java.util.LinkedHashSet;
import java.util.Set;

public class LinkedHashSetDemo {
    public static void main(String[] args) {
        Set<String> visited = new LinkedHashSet<>();
        visited.add("/home");
        visited.add("/about");
        visited.add("/home");   // duplicate — ignored
        visited.add("/contact");

        System.out.println(visited);  // [/home, /about, /contact]
        // Order preserved, duplicates removed

        // Deduplication while preserving order
        String[] input = {"banana", "apple", "banana", "cherry", "apple"};
        Set<String> deduped = new LinkedHashSet<>(java.util.Arrays.asList(input));
        System.out.println(deduped);  // [banana, apple, cherry]
    }
}
▶ Output
[/home, /about, /contact]
[banana, apple, cherry]
📊 Production Insight
A common mistake is to use LinkedHashSet for a cache but forget that it only provides order, not eviction. It will keep all unique elements forever.
If you need a bounded set with removal of oldest entries, you must use a LinkedHashMap with dummy values or a dedicated library.
The memory overhead is the same as LinkedHashMap: one extra reference per entry compared to HashSet.
🎯 Key Takeaway
LinkedHashSet = HashSet + a doubly-linked list for insertion order.
Use it when you need deduplication with order preservation.
It is not a cache; it does not auto-evict.

Performance and Memory Overhead

LinkedHashMap and LinkedHashSet have the same O(1) average complexity for add, remove, contains, and get as their parent classes. The only difference is the linked list, which adds two references (prev, next) per entry. On a 64-bit JVM with compressed OOPs, that's about 16 bytes per entry extra.

Iteration over a LinkedHashMap is actually slightly faster than over a HashMap because it traverses the linked list rather than the entire bucket array (which can be sparse). But for very large maps, the extra memory can be significant.

Access-order mode does not add computational overhead on get/put beyond the O(1) operation to move the entry to the tail (which is a constant-time pointer update).

📊 Production Insight
If you have millions of entries, the extra 16 bytes per entry can cause GC pressure. Consider using a custom LRU cache with primitive types or off-heap storage.
LinkedHashMap's linked list is not concurrent-safe; even a single iterator coupled with any modification throws ConcurrentModificationException.
The memory overhead is fixed per entry, regardless of the load factor.
🎯 Key Takeaway
Extra memory per entry: ~16 bytes on 64-bit with compressed oops.
Iteration is faster than HashMap for sparse maps.
Do not use for very high entry counts without measuring GC impact.

Thread Safety and Synchronization

Like HashMap and HashSet, LinkedHashMap and LinkedHashSet are not thread-safe. If multiple threads access them concurrently and at least one modifies the structure, external synchronization is required.

You can wrap them with Collections.synchronizedMap(new LinkedHashMap(...)), but that only protects individual method calls. Iteration still requires manual synchronization. For an LRU cache that needs concurrency, use java.util.concurrent.ConcurrentHashMap with additional logic, or better, use a dedicated library like Caffeine.

One common pattern is to use a synchronized block around the entire put-get-evict sequence in an LRU cache, but that quickly becomes a bottleneck under high concurrency.

SynchronizedLRU.java · JAVA
123456789101112131415161718192021222324252627
package io.thecodeforge.java.collections;

import java.util.*;

public class SynchronizedLRU<K, V> {
    private final Map<K, V> cache;
    private final int capacity;

    public SynchronizedLRU(int capacity) {
        this.cache = Collections.synchronizedMap(
            new LinkedHashMap<K, V>(capacity, 0.75f, true) {
                @Override
                protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
                    return size() > capacity;
                }
            }
        );
        this.capacity = capacity;
    }

    // But iteration is not thread-safe; you must synchronize on cache
    public List<Map.Entry<K, V>> snapshot() {
        synchronized (cache) {
            return new ArrayList<>(cache.entrySet());
        }
    }
}
📊 Production Insight
Using synchronizedMap on a LinkedHashMap means every put, get, and remove is synchronized. Under high throughput, this can become a major contention point.
The snapshot() method shows the correct way to iterate safely: synchronize on the map and copy to a list.
For highly concurrent LRU, use Caffeine (built on ConcurrentHashMap). The LRU in this article is fine for single-threaded or low-contention scenarios.
🎯 Key Takeaway
LinkedHashMap is not thread-safe.
Collections.synchronizedMap() works but synchronizes every operation.
For production LRU caches with high concurrency, use Caffeine or Guava.
🗂 Comparison: HashMap vs LinkedHashMap vs TreeMap
Key differences that affect your choice
PropertyHashMapLinkedHashMapTreeMap
Iteration orderUndefined (depends on bucket structure)Insertion-order or access-orderSorted according to natural ordering or Comparator
Time complexity (get/put)O(1) averageO(1) averageO(log n)
Memory overheadBase+ ~16 bytes per entry (prev/next pointers)+ ~32 bytes per entry (color, parent, left, right, key, value)
Access-order LRU supportNoYes (accessOrder=true + override removeEldestEntry)No
Thread-safeNoNoNo
Often used forGeneral-purpose mappingCaches, ordered JSON, preserving param orderRange queries, sorted data

🎯 Key Takeaways

  • LinkedHashMap maintains insertion order; HashMap does not.
  • Pass accessOrder=true to LinkedHashMap constructor for LRU semantics.
  • Override removeEldestEntry() to implement a bounded LRU cache in 10 lines.
  • LinkedHashSet deduplicates while preserving insertion order.
  • Memory overhead vs HashMap/HashSet: one extra prev/next pointer per entry.
  • Neither is thread-safe; Collections.synchronizedMap() does not protect iteration.

⚠ Common Mistakes to Avoid

    Not overriding removeEldestEntry()
    Symptom

    The LRU cache grows without bound, eventually causing OutOfMemoryError. The map size equals the number of entries ever inserted.

    Fix

    Always override removeEldestEntry to return size() > capacity. Test with a small capacity to verify eviction works.

    Forgetting to set accessOrder=true
    Symptom

    The cache evicts based on insertion order, so frequently accessed but later-inserted entries may be evicted while old unused entries remain. Cache hit rate drops dramatically.

    Fix

    Use the constructor new LinkedHashMap<>(capacity, loadFactor, true) to enable access-order.

    Using LinkedHashSet without realizing it's unbounded
    Symptom

    Memory grows as elements are added. The developer thought it would automatically evict old entries, but it never does.

    Fix

    Understand that LinkedHashSet is a Set with insertion order only. For bounded sets, use a custom implementation with LinkedHashMap and dummy values, or use a dedicated cache library.

    Modifying the map while iterating without using iterator's remove()
    Symptom

    ConcurrentModificationException thrown even in single-threaded code.

    Fix

    Use the iterator directly and call iterator.remove() to remove the current entry. Or use Java 8's removeIf() for bulk operations.

    Assuming thread safety through synchronized wrappers protects iteration
    Symptom

    Random ConcurrentModificationException or inconsistent iteration results under concurrent access.

    Fix

    When using Collections.synchronizedMap(), you must manually synchronize on the map during iteration. E.g., synchronized(map) { for(...) } or copy to a separate list inside a synchronized block.

Interview Questions on This Topic

  • QHow does LinkedHashMap differ from HashMap?JuniorReveal
    LinkedHashMap maintains a doubly-linked list through its entries, providing a predictable iteration order (insertion-order or access-order). HashMap offers no ordering guarantees. LinkedHashMap also supports LRU eviction via accessOrder=true and removeEldestEntry(). Performance is O(1) for both, but LinkedHashMap has slightly higher memory overhead due to the linked list pointers.
  • QHow would you implement an LRU cache using Java's built-in classes?Mid-levelReveal
    Extend LinkedHashMap, set accessOrder=true in the constructor, and override removeEldestEntry to evict when size exceeds capacity. Example: public class LRUCache<K,V> extends LinkedHashMap<K,V> { private final int capacity; public LRUCache(int capacity) { super(capacity, 0.75f, true); this.capacity = capacity; } @Override protected boolean removeEldestEntry(Map.Entry<K,V> eldest) { return size() > capacity; } } This gives O(1) get, put, and eviction. Note: not thread-safe.
  • QWhen is LinkedHashSet preferable to HashSet?JuniorReveal
    Use LinkedHashSet when you need deduplication while preserving the order of first occurrence. Examples: maintaining a unique list of recently visited URLs, preserving insertion order in a UI filter, or ensuring a deterministic iteration order in tests. If order does not matter, HashSet uses less memory.
  • QWhat is the difference between insertion-order and access-order in LinkedHashMap?Mid-levelReveal
    Insertion-order maintains the order in which keys were first inserted. Reinserting an existing key does not change its position. Access-order moves the entry to the tail of the linked list every time the entry is accessed via get or put. This makes the head the least recently used entry, enabling LRU eviction logic.
  • QHow does removeEldestEntry work? What would happen if you forget to override it?Mid-levelReveal
    removeEldestEntry is called automatically after each put. The default implementation returns false, so no eviction occurs. If you intend to use LinkedHashMap as a bounded cache and forget to override it, the map will grow indefinitely, potentially causing an OutOfMemoryError. The override should return size() > capacity to evict the eldest entry.

Frequently Asked Questions

When should I use LinkedHashMap instead of HashMap?

When iteration order matters — building an ordered JSON response, preserving URL parameter order, displaying results in insertion order, or testing where you need predictable output. If order does not matter, HashMap is slightly faster.

Is LinkedHashMap thread-safe?

No, like HashMap. Wrap it with Collections.synchronizedMap() for basic thread safety, but this does not protect iteration. For a thread-safe LRU cache in production, use Caffeine or Guava's Cache instead.

Can I use LinkedHashMap as a FIFO queue?

Yes, with insertion-order and a fixed capacity via removeEldestEntry, you get a simple FIFO eviction policy (oldest inserted is evicted first). For a true FIFO queue, however, a LinkedList or ArrayDeque is more appropriate.

What is the initial capacity parameter in LinkedHashMap constructor?

It sets the initial size of the hash table (the number of buckets). It does NOT set a maximum size. The capacity you pass is only a tuning parameter for resizing; the map can grow beyond it if you do not override removeEldestEntry.

Why does my LinkedHashMap iteration order change after rehashing?

It doesn't. The linked list ensures ordering is preserved across rehashes. If you see order changes, you might have removed and reinserted entries, or used a constructor that copies from another map (which may have a different order).

🔥
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousIterator and ListIterator in JavaNext →PriorityQueue in Java
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged