LinkedHashMap LRU Cache - Missing removeEldestEntry OOM
Service OOM after hours: heap dump had millions of entries because removeEldestEntry wasn't overridden in LRU cache.
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
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.
Imagine a library where you put books on a shelf in the order you buy them. When you want to find a book, you remember where you placed it. That's insertion-order. Now imagine a cache where every time you read a book, it moves to the front of the shelf. If the shelf gets full, the book at the back (least recently used) gets thrown out. That's access-order. LinkedHashMap and LinkedHashSet are Java's shelf implementations.
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 and LinkedHashSet: The Predictable Iteration Order That Can Cost You Memory
LinkedHashMap and LinkedHashSet are Java's hash-based collections that maintain a doubly linked list running through their entries, giving them predictable iteration order — either insertion-order or access-order. Unlike HashMap or HashSet, which offer no ordering guarantees, these classes let you iterate in the order entries were added or last accessed. The core mechanic is a linked list that shadows the hash table, adding O(1) overhead for each put/get but preserving order without requiring a separate sort.
In practice, LinkedHashMap's access-order mode (via constructor accessOrder=true) combined with removeEldestEntry makes it a ready-made LRU cache. Each get or put moves the accessed entry to the tail; the eldest entry (head) is evicted when removeEldestEntry returns true. The default implementation always returns false — a common oversight that silently turns your cache into a memory leak. LinkedHashSet is simply a LinkedHashMap with a dummy value, inheriting the same ordering behavior.
Use LinkedHashMap when you need predictable iteration without the overhead of TreeMap's O(log n) operations, or when building an LRU cache with bounded memory. LinkedHashSet is ideal for deduplication where insertion order matters, like maintaining a unique event log. In production, the access-order variant is the go-to for caches up to tens of thousands of entries; beyond that, consider a dedicated cache library with more sophisticated eviction policies.
put() calls never evicting.size() > maxCapacity when using access-order mode; test with a small maxCapacity to confirm eviction fires.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.
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.
get(), entries never move. So a put-only pattern makes it behave like insertion-order with eviction of the first inserted.get() on every access.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.
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).
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.
snapshot() method shows the correct way to iterate safely: synchronize on the map and copy to a list.Collections.synchronizedMap() works but synchronizes every operation.Why the Underlying Doubly-Linked List Changes Your GC Behavior
Most devs treat LinkedHashMap like 'HashMap with order'. That's dangerous. The linked list that gives you predictable iteration also pins every entry in memory. Each Entry object carries two extra references (before, after) plus the next pointer from the HashMap bucket chain. That's three pointers per entry versus one in a plain HashMap. In high-throughput services with millions of cached objects, those extra references bloat the young generation and increase GC pause frequency. I once saw a Spring Boot 3.1 service crater under load because a LinkedHashMap used for a session cache kept every object tenured long before it was needed. The fix: use a HashMap with manual eviction or a Caffeine cache that uses weak references. If you absolutely need insertion order, set an explicit max size and enable removeEldestEntry to force eviction. Otherwise, you're paying for iteration order you rarely iterate.
When Iteration Order Lies: Access-Order LinkedHashMap Under Concurrency
Access-ordered LinkedHashMap is seductive for LRU caches. You call get(), the entry moves to the end of the list. Simple. Wrong. The access-order modification is not atomic with the get operation. Under concurrent access, two threads reading the same key can both trigger structural modifications to the linked list. Even with ConcurrentHashMap's segment locks, LinkedHashMap's internal list is unprotected. You'll see the classic ConcurrentModificationException or, worse, silent data corruption where iteration returns null or skips entries. In Spring Boot 3.x, I debugged a rate-limiter that used an access-ordered LinkedHashMap. Under load test with 500 concurrent users, the cache returned wrong ordering every third request. We replaced it with a ConcurrentLinkedDeque + ConcurrentHashMap pair. That gave us LRU semantics without the linked-list race. If you need thread-safe access order, avoid the OOTB LinkedHashMap. Roll your own with synchronization or use a library like Guava's CacheBuilder.
get() calls to the same key both modify the internal linked list. Use Collections.synchronizedMap() or a proper concurrent cache library.LRU Cache Used accessOrder = false — No Eviction Happened
size() > capacity. Also set accessOrder=true to get LRU semantics. Example: new LinkedHashMap<K,V>(capacity, 0.75f, true) and override removeEldestEntry.- 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.
get() is called on access – otherwise the entry order is not updated.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.size() > capacity, and set accessOrder=true. Restart the service.Key takeaways
Collections.synchronizedMap() does not protect iteration.Common mistakes to avoid
5 patternsNot overriding removeEldestEntry()
size() > capacity. Test with a small capacity to verify eviction works.Forgetting to set accessOrder=true
new LinkedHashMap<>(capacity, loadFactor, true) to enable access-order.Using LinkedHashSet without realizing it's unbounded
Modifying the map while iterating without using iterator's remove()
iterator.remove() to remove the current entry. Or use Java 8's removeIf() for bulk operations.Assuming thread safety through synchronized wrappers protects iteration
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
How does LinkedHashMap differ from HashMap?
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
That's Collections. Mark it forged?
5 min read · try the examples if you haven't