DEV Community

Styrow.dev
Styrow.dev

Posted on Originally published at styrow.dev

How to diagnose and resolve a memory leak caused by improper bean scope and static references in a Spring Boot microserv

🔥 Spring Boot Memory Leak: 99% of engineers miss this subtle trap!
Is your microservice slowly dying with OutOfMemoryError? Heap dumps show a singleton bean holding a static List<String>, retaining huge amounts of memory.

📌 Problem Statement
Your Spring Boot microservice suffers gradual heap memory growth, leading to OutOfMemoryError. Heap analysis reveals excessive String objects retained by a singleton bean using a static List<String>, populated continuously by request. Circular bean dependencies also contribute.

💡 Solution & Code Walkthrough

❌ Antipattern: Leaky Singleton Cache

@Component
public class LeakyCache {
    private static final List<String> cache = new ArrayList<>(); // Static field in a singleton!
    public void put(String key, String value) { cache.add(value); } // Grows infinitely
    // ...get() method omitted for brevity...
}
Enter fullscreen mode Exit fullscreen mode

• A static field in a singleton @Component creates a global List that accumulates data infinitely, causing unbounded memory leaks.

✅ Solution 1: Prototype Scoped Instance

@Component
@Scope("prototype") // Each injection gets a fresh instance
public class ScopedCache {
    private final Map<String, String> map = new HashMap<>(); // Instance field
    public void put(String key, String value) { map.put(key, value); }
    public String get(String key) { return map.get(key); }
}
Enter fullscreen mode Exit fullscreen mode

• @Scope("prototype") provides a fresh instance on injection. An instance Map is garbage-collectible with its owning bean, preventing global accumulation.

✅ Solution 2: Injecting Prototypes into Singletons via ObjectProvider

@Service
public class DataService {
    private final ObjectProvider<ScopedCache> cacheProvider; // Get new instances on demand

    public DataService(ObjectProvider<ScopedCache> cacheProvider) {
        this.cacheProvider = cacheProvider;
    }

    public void processData(String data) {
        ScopedCache requestCache = cacheProvider.getObject(); // Fresh instance per operation
        requestCache.put("key", data); // Use for transient, request-specific state
        // ... business logic ...
    }
}
Enter fullscreen mode Exit fullscreen mode

• Direct injection of @Prototype into @Singleton creates only one instance. ObjectProvider ensures fresh prototype instances are retrieved via getObject() call, upholding proper scope.

🔑 Key Takeaways
• Static Fields in Beans: Avoid mutable static fields in Spring components; they lead to memory leaks or concurrency issues.
• Bean Scopes: Understand singleton vs. prototype. Use prototype for transient, stateful beans.
• ObjectProvider: Correctly injects prototype beans into singletons, ensuring new instances on demand.
• Memory Profiling: Use tools like VisualVM or JProfiler with heap dumps to detect and diagnose memory issues.

❓ Quick Summary Q&A
Q: What causes memory leaks with static lists in Spring singletons?
A: A static List in a singleton bean acts as a global, unmanaged store, accumulating data indefinitely and preventing GC.

TAGS: spring boot, memory leak, java, performance, debugging, sdet, objectprovider, bean scope, oom, heap dump

────────────────────────────────────────
Get more coding challenges and solutions!
Download on the App Store: ────────────────────────────────────────

📲 𝐅𝐑𝐄𝐄 𝐌𝐎𝐁𝐈𝐋𝐄 𝐀𝐏𝐏 — 𝟔𝟎𝟎+ 𝐒𝐃𝐄𝐓 𝐐&𝐀𝐬
Practice real-world interview scenarios offline on the free QA Automation & SDET Prep app:

🤖 𝐆𝐨𝐨𝐠𝐥𝐞 𝐏𝐥𝐚𝐲 (𝐀𝐧𝐝𝐫𝐨𝐢𝐝):
https://play.google.com/store/apps/details?id=com.app.seleniuminterviewquestions&referrer=utm_source%3Ddevto%26utm_medium%3Darticle%26utm_campaign%3Dselenium_20260927

🍎 𝐀𝐩𝐩 𝐒𝐭𝐨𝐫𝐞 (𝐢𝐎𝐒):
https://apps.apple.com/app/id6786760948?pt=128640464&ct=devto_selenium_20260927&mt=8

────────────────────────────────────────
Get it on Google Play: ────────────────────────────────────────

📲 𝐅𝐑𝐄𝐄 𝐌𝐎𝐁𝐈𝐋𝐄 𝐀𝐏𝐏 — 𝟔𝟎𝟎+ 𝐒𝐃𝐄𝐓 𝐐&𝐀𝐬
Practice real-world interview scenarios offline on the free QA Automation & SDET Prep app:

🤖 𝐆𝐨𝐨𝐠𝐥𝐞 𝐏𝐥𝐚𝐲 (𝐀𝐧𝐝𝐫𝐨𝐢𝐝):
https://play.google.com/store/apps/details?id=com.app.seleniuminterviewquestions&referrer=utm_source%3Ddevto%26utm_medium%3Darticle%26utm_campaign%3Dselenium_20260927

🍎 𝐀𝐩𝐩 𝐒𝐭𝐨𝐫𝐞 (𝐢𝐎𝐒):
https://apps.apple.com/app/id6786760948?pt=128640464&ct=devto_selenium_20260927&mt=8

────────────────────────────────────────
────────────────────────────────────────

Top comments (1)

Collapse
 
contentclips_st profile image
ContentClips •

The prototype-scoped fix has its own trap worth flagging: a prototype bean injected into a singleton is resolved once at injection time, so ScopedCache silently degrades back to shared state unless you inject ObjectProvider or use a lookup method. Also, with the List variant, ArrayList is not thread-safe and every request thread appends into it — beyond the leak you get corrupted internal state, so it needs synchronization or a concurrent structure if it stays static. Two things make this class of leak visible before OOM: (1) two heap dumps ~30 min apart, diff the retained sets — a growing static List shows up immediately as a dominator; (2) -XX:+HeapDumpOnOutOfMemoryError plus a JFR ObjectAllocationSample recording points at the allocation site (the put() call) without the multi-GB dump overhead. And for the legitimate-cache case, Caffeine with maximumSize and expireAfterWrite turns an unbounded list into an explicit eviction policy instead of hoping bean scope fixes it.