TestForge Blog
← All Posts

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
  • OutOfMemoryError appears 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.

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 static collections have an eviction policy?
  • Are ThreadLocal values remove()d in a finally block?
  • Is pagination applied on JPA queries?
  • Is try-with-resources used for all closeable resources?
  • Is JVM memory monitored via Actuator /actuator/metrics?