Your app passes load tests. It runs fine in staging. Then, two days after go-live, the heap grows steadily until the pod restarts. You are not leaking objects in an obvious place โ it is subtler than that.
Here are the four most common memory leaks in Spring Boot applications and how to diagnose each one.
1. ThreadLocal Not Cleaned Up
ThreadLocal is the most dangerous abstraction in Java when used carelessly. Values stored in a ThreadLocal live as long as the thread does โ and in a thread pool (which Spring uses everywhere), that means forever.
// This looks harmless public class RequestContext { private static final ThreadLocal<User> currentUser = new ThreadLocal<>(); public static void set(User user) { currentUser.set(user); } public static User get() { return currentUser.get(); } }
If you never call currentUser.remove(), the User object stays in memory on that thread permanently. Multiply by thread pool size, multiply by request volume, and you have a leak.
Fix: Always clean up in a finally block or a Spring HandlerInterceptor.
@Override public void afterCompletion(HttpServletRequest req, HttpServletResponse res, Object handler, Exception ex) { RequestContext.clear(); // calls ThreadLocal.remove() }
2. Static Collections That Grow Unbounded
A static Map or List used as an in-memory cache with no eviction policy is a memory leak waiting for production traffic to trigger it.
// Classic trap public class TokenBlacklist { private static final Set<String> blacklisted = new HashSet<>(); public static void revoke(String token) { blacklisted.add(token); // grows forever } }
Every revoked token stays in memory indefinitely. On a busy auth service this fills the heap in days.
Fix: Use a bounded cache with eviction. Caffeine is the standard for Spring Boot:
@Bean public Cache<String, Boolean> tokenBlacklist() { return Caffeine.newBuilder() .maximumSize(100_000) .expireAfterWrite(24, TimeUnit.HOURS) .build(); }
3. Hibernate Session Holding Large Object Graphs
Hibernate's first-level cache is session-scoped. In a long-running batch job or a mistakenly transaction-scoped service method, the session accumulates every entity it has ever touched.
@Transactional public void processAllOrders() { List<Long> ids = orderRepo.findAllIds(); // could be 100,000 ids for (Long id : ids) { Order order = orderRepo.findById(id).orElseThrow(); process(order); // order stays in session cache } // session holds 100,000 Order objects in memory at commit }
Fix: Clear the session periodically in batch operations, or use stateless sessions.
@Transactional public void processAllOrders() { List<Long> ids = orderRepo.findAllIds(); int count = 0; for (Long id : ids) { Order order = orderRepo.findById(id).orElseThrow(); process(order); if (++count % 500 == 0) { entityManager.flush(); entityManager.clear(); // releases session cache } } }
4. Event Listeners Not Unregistered
Spring's ApplicationEventPublisher holds strong references to listeners. If you register a listener from a request-scoped or prototype-scoped bean, that bean stays alive as long as the application context does.
@Component public class OrderService { @Autowired private ApplicationEventPublisher publisher; // Fine โ singleton listener registered once public void placeOrder(Order order) { publisher.publishEvent(new OrderPlacedEvent(order)); } }
The danger is programmatic listeners registered in short-lived objects:
// Prototype bean registers itself โ never cleaned up @Component @Scope("prototype") public class ReportGenerator implements ApplicationListener<ReportEvent> { // This instance is now held by the application context forever }
Fix: Use @EventListener on singleton beans, or implement SmartApplicationListener and unregister explicitly.
How to Diagnose
Do not guess. Measure.
1. Enable JVM heap dumps on OOM:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof
2. Open the dump in Eclipse Memory Analyzer (MAT) โ free tool that shows you the largest retained heaps and their GC roots.
3. Use Spring Boot Actuator to watch heap growth over time:
/actuator/metrics/jvm.memory.used
4. JFR (Java Flight Recorder) for continuous low-overhead profiling in production:
-XX:StartFlightRecording=duration=60s,filename=recording.jfr
If the heap grows linearly with requests and never returns to baseline after GC, you have a leak. The dump will tell you exactly what is holding the references.
The Pattern
All four leaks share the same root cause: a long-lived container holds a reference to something that should have been freed. Thread pools, static fields, the Hibernate session, and the Spring context are all long-lived. Anything you put in them without an eviction or cleanup strategy will accumulate.
The fix is always the same: bound the lifetime. Use expiry, use explicit cleanup, use the right scope.