How Your Code Structure Is Causing Java Memory Leaks
Real Java Memory Leaks From Design Mistakes
Java has garbage collection. But memory leaks still happen and often, they’re your fault.
Not because you forgot to close()
a resource or kept an unused object around…
But because the way you structured your classes, reused patterns, or wired your services accidentally created object graphs that GC can’t break.
In this post, we’ll cover some real-world, design-level Java memory leaks you won’t find in textbooks, and how to fix them with actual code.
1. Singleton Holding Mutable State
Situation: You use a singleton for a service class, and accidentally store per-user or per-request state in it.
Singleton lives forever. Any object referenced inside it also lives forever. Even if you “null out” local references elsewhere, GC can’t collect the state held inside the singleton.
The SessionManager
is a singleton, it lives as long as the JVM does. So does everything it references. If you store user sessions here and never clean them, the GC can't collect them, even if users log out.
When it bites:
Long-running services with lots of users
Hot-reloading tools (like Spring Dev Tools)
Stateful services that don’t expire cached state
How to fix:
Don’t store per-user/request state in singletons
Use a proper cache like Caffeine with TTL/size limits
For manual maps, use
WeakHashMap
or add cleanup logic
Don’t store mutable app state in singletons. Use scoped storage (e.g., per-request, per-session beans in DI).
Singleton ≠ Safe. It’s one of the most common silent killers in Java backends.
2. Fluent Builder Pattern Capturing Heavy Context
Situation: Fluent builder pattern where each chained method returns a new modified object.
Captured context (especially in inner classes, lambdas) keeps earlier builders alive. You think you’re being immutable, but you’re actually holding references to the entire chain.
Many builder implementations hold onto all previously set fields, even after calling build()
. If you store or reuse builders (or worse, chain them in reactive pipelines), they retain everything passed in. GC sees a live reference, it can’t help you.
When it bites:
Builders stored for template reuse
Async pipelines (
CompletableFuture
,Flux
, etc.)Complex UI logic that builds and reuses views/data
How to fix:
Null out heavy fields after
build()
Don’t reuse builders that hold references
Prefer stateless builders or immutables
Or Even better, don’t reuse the builder at all. Let it be a disposable throwaway object. The object you return from build()
should be fully immutable, and the builder should just... die.
Fluent APIs can be memory pigs if you’re building a fluent object graph, not just state.
3. Decorator/Composite Chains Without Disposal
Situation: You layer multiple decorators over an object for logging, tracing, auth, etc.
Each layer keeps a ref to the inner layer. If the top-level object is cached or retained somewhere, all inner layers stay alive ,even if unused.
Each decorator holds a strong reference to the inner one. If the outer decorator is stored in a registry or injected into a singleton bean, every layer underneath stays in memory. You’re accidentally keeping the whole service stack alive.
When it bites:
Microservices with dynamically created services
Caching layers that hold entire service graphs
Plugin systems using deep composition
How to fix:
Introduce a
dispose()
method in your decorator chain to release referencesAvoid storing decorated services, decorate at call time
Use dynamic proxies or AOP instead of manual decorators
Implement and call dispose()
/close()
in your decorators to break chains when you're done.
Decorator chains are beautiful in design, deadly in lifecycle management.
4. Dependency Injection Elevating Lifetimes
Situation: Spring or Guice injects a bean into a component that was never meant to be long-lived but the container keeps it alive due to dependency resolution.
A “heavy” bean ends up in a long-lived singleton because of a small helper dependency.
You think reportGenerator
is a lightweight helper, but Spring injects it into a singleton bean now everything inside that generator (caches, buffers, native libs) stays forever. This is accidental object promotion via DI.
When it bites:
Heavy beans injected into lightweight components
Memory-heavy models like Lucene, TensorFlow, etc.
Spring Boot apps running scheduled jobs
How to fix:
Mark heavy beans as
@RequestScope
,@Prototype
, or load them lazilyDon’t inject heavyweight beans into long-lived components
Explicitly destroy or release buffers in
@PreDestroy
Fix 1: Use @Lazy
or provider
Fix 2: Mark it as prototype
Now it gets created per-use, not shared forever.
Don’t inject heavy services into singleton beans unless necessary. Use lazy loading or provider-based injection.
Just because Spring injects it doesn’t mean it’s safe. Be picky with what you inject where.
5. Observer/Event Bus Without Unsubscribe
Situation: Your domain objects subscribe to events (e.g., via an event bus or observer pattern) and forget to unsubscribe.
The event bus holds strong references to observers, which holds domain objects, which keep entire sub-graphs alive.
eventBus.register(order -> cache.update(order)); // never removed
EventBus holds strong references to all registered listeners. If your listener captures something big (like a service or database context), it stays alive even if you think it’s “done”.
When it bites:
Background workers that never unsubscribe
GUI elements or mobile activities bound to global events
Any app using Guava/EventBus/reactive libraries without cleanup
How to fix:
Always unregister listeners when done (
eventBus.unregister(this)
)Use weak references or custom buses with auto-cleanup
If using reactive APIs, ensure completion/error handlers unsubscribe
Fix 1: Unregister explicitly
public class OrderListener {
@Subscribe
public void onOrder(Order o) {
cache.update(o);
}
}
// Register
eventBus.register(listener);
// On shutdown or cleanup
eventBus.unregister(listener);
Fix 2: Use weak references for listeners
If using your own event bus or supporting weak refs:
WeakReference<OrderListener> weakListener = new WeakReference<>(new OrderListener());
Or use RxJava/Project Reactor with dispose()
on subscriptions:
Disposable sub = orderFlux.subscribe(order -> cache.update(order));
sub.dispose(); // Cleanup
Event-driven is great… until you’re broadcasting into a blackhole of memory leaks.
Memory leaks in Java don’t always come from bad code.
They often come from “good code used in the wrong place”.
The garbage collector only cleans what’s unreachable. Your job is to design for reachability.
Know your lifecycles. Watch your references. Fix your architecture.
One of the things I've learned about Java after having used it for the past few months is that you still need to think about memory management. There are common programming idioms (say, a mutual dependency between a parent and its child) that result in data structurs that don't get garbage collected.
I learned this first-hand when using Junit to write unit tests. As the test runs, the process grows to over 20GB. On low-memory machines the tests end up crashing with a memory error.
I always find myself pining for C++ destructors, which give me control over memory management that I don't have in Java.
Solving the problem of dangling references just created a different problem.