Real World Example

Debugging an OutOfMemoryError in a Payments Service

A typical production story: a Spring Boot payments service in Kubernetes is restarted every few hours with OOMKilled. The team follows these steps.

  1. Add -XX:+HeapDumpOnOutOfMemoryError -XX:MaxRAMPercentage=75 -Xlog:gc*:file=/logs/gc.log.
  2. GC log shows old generation growing after every full GC: a leak, not just a small heap.
  3. Open the heap dump in Eclipse MAT → "Dominator tree": a static HashMap holds 2 million PaymentRequest objects.
  4. Root cause: a home-made cache with no eviction. Fix: replace it with a bounded cache (Caffeine with maximumSize and TTL).

The Leak and the Fix

Example

A bounded LRU cache using LinkedHashMap.removeEldestEntry keeps memory flat no matter how many requests arrive.

import java.util.*;

public class Main {
    // BAD: grows forever, every entry stays reachable from a static field (a GC root)
    static final Map<String, String> leakyCache = new HashMap<>();

    // GOOD: bounded, least-recently-used entries are dropped
    static final Map<String, String> lruCache = new LinkedHashMap<>(16, 0.75f, true) {
        @Override
        protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
            return size() > 1000;
        }
    };

    public static void main(String[] args) {
        for (int i = 0; i < 100_000; i++) {
            String txnId = "TXN" + i;
            leakyCache.put(txnId, "SUCCESS");
            lruCache.put(txnId, "SUCCESS");
        }
        System.out.println("Leaky cache size: " + leakyCache.size());
        System.out.println("LRU cache size:   " + lruCache.size());
        System.out.println("Oldest kept: " + lruCache.keySet().iterator().next());
    }
}

Output

Leaky cache size: 100000
LRU cache size:   1000
Oldest kept: TXN99000