Spring Boot Memory Leak — Root Causes and Diagnosis
Five common memory leak patterns in Spring Boot applications and how to quickly diagnose them with Heap Dump analysis.
TestForge Team ·
Symptoms of a Memory Leak
- GC frequency steadily increases over time
- Heap usage doesn’t decrease after GC
OutOfMemoryErrorappears days into running in production
java.lang.OutOfMemoryError: Java heap space
Cause 1: Unbounded Accumulation in a Static Collection
// Dangerous pattern
public class CacheService {
private static final List<String> cache = new ArrayList<>();
public void add(String data) {
cache.add(data); // Nothing ever removes items → grows forever
}
}
Fix: Use a cache with an eviction policy like Caffeine or Guava Cache.
@Bean
public Cache<String, String> localCache() {
return Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build();
}
Cause 2: ThreadLocal Not Cleared
Servlet containers reuse threads. If ThreadLocal is not remove()d, the previous request’s data lingers.
// Dangerous
public class UserContext {
private static final ThreadLocal<User> currentUser = new ThreadLocal<>();
public static void set(User user) { currentUser.set(user); }
// No remove()!
}
// Safe
try {
UserContext.set(user);
// ... business logic
} finally {
UserContext.remove(); // Always clear
}
Cause 3: JPA N+1 + Loading Large Data into Memory
// Loads all Orders into memory at once
List<Order> orders = orderRepository.findAll();
orders.forEach(o -> process(o.getItems())); // N+1 queries
Fix: Pagination + Fetch Join
@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.status = :status")
Page<Order> findByStatus(@Param("status") String status, Pageable pageable);
Cause 4: Unreleased Event Listeners
@Component
public class MyListener implements ApplicationListener<MyEvent> {
// Spring-managed beans are fine,
// but manually registered listeners must always be deregistered
}
Cause 5: Unreturned Resources from External Libraries
// Dangerous: missing close()
InputStream is = url.openStream();
// ... use stream, then no is.close()
// Safe
try (InputStream is = url.openStream()) {
// auto-closed
}
Finding the Root Cause with a Heap Dump
# 1. Find the running process PID
jps -l
# 2. Generate Heap Dump
jmap -dump:format=b,file=heap.hprof <PID>
# Or auto-generate on OOM with JVM option
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/app/dumps/
Open the generated .hprof file with Eclipse MAT or IntelliJ Profiler to visually identify which objects are holding memory.
Recommended Production JVM Options
java -jar app.jar \
-Xms512m -Xmx1g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/heap/ \
-Xlog:gc*:file=/var/log/gc.log:time,uptime:filecount=5,filesize=20m
Quick Inspection Checklist
- Do
staticcollections have an eviction policy? - Are
ThreadLocalvaluesremove()d in afinallyblock? - Is pagination applied on JPA queries?
- Is
try-with-resourcesused for all closeable resources? - Is JVM memory monitored via Actuator
/actuator/metrics?