CubeAPM
CubeAPM CubeAPM

Spring Boot Memory Leak Detection: Setup, Heap Dumps and Fixes

Spring Boot Memory Leak Detection: Setup, Heap Dumps and Fixes

Table of Contents

Your Spring Boot app was running fine at 512MB heap last week. Today it is at 2GB. No errors in the logs. APIs still respond. Just slower. You ignore it. A week later production crashes with OutOfMemoryError. You restart the app. Everything looks normal again. You move on. This is not a random failure. This is a memory leak. And restarting your app is not a fix; it is a temporary escape.

Memory leaks in production systems do not crash your app on day one. They grow silently. They wait. And they crash your system when your biggest client is using it.

This guide walks you through detecting memory leaks in Spring Boot applications, capturing heap dumps at the right moment, analyzing them with Eclipse Memory Analyzer Tool, and fixing the most common leak patterns; ThreadLocal misuse, unclosed JPA streams, unbounded caches, and static collections that only grow.

Prerequisites

Before you start detecting memory leaks in Spring Boot, ensure you have the following:

  • Spring Boot app running in production or a staging environment that mirrors production load
  • JDK 11 or higher installed on the server where your app runs
  • Spring Boot Actuator dependency enabled in your pom.xml or build.gradle
  • Eclipse Memory Analyzer Tool (MAT) installed on your local machine — download from eclipse.org/mat
  • Access to the server or pod where your Spring Boot app is running to execute jmap or trigger Actuator endpoints
  • At least 8GB of free disk space on the server to store heap dump files

Step 1: Enable Spring Boot Actuator and Metrics

Spring Boot Actuator exposes runtime metrics and health endpoints that let you monitor JVM memory usage without restarting your app or adding external agents.

Add the Actuator dependency to your project:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

For Gradle:

implementation 'org.springframework.boot:spring-boot-starter-actuator'

Enable the metrics and heap dump endpoints in application.properties:

management.endpoints.web.exposure.include=health,metrics,heapdump
management.endpoint.health.show-details=always
management.metrics.enable.jvm=true

Restart your Spring Boot app. Once running, verify the metrics endpoint is accessible:

curl http://localhost:8080/actuator/metrics/jvm.memory.used

You should see JSON output showing heap and non heap memory usage. The jvm.memory.used metric is the key signal — if this value grows continuously and never drops after garbage collection, you have a memory leak.

Why this matters: Actuator gives you runtime visibility into memory without adding external monitoring tools. It is built into Spring Boot and requires zero code changes beyond configuration. The /actuator/heapdump endpoint lets you capture a heap dump on demand without SSH access or jmap, which is critical in containerized environments like Kubernetes where you may not have shell access to the pod.

Step 2: Monitor Memory Usage to Detect the Leak Pattern

Before capturing a heap dump, confirm the leak pattern by monitoring jvm.memory.used over time. A memory leak shows up as steady upward growth in heap usage that does not drop after garbage collection.

Use Spring Boot Actuator metrics to track heap usage every few minutes:

watch -n 60 'curl -s http://localhost:8080/actuator/metrics/jvm.memory.used | jq'

This polls the heap usage metric every 60 seconds. Watch for these patterns:

  • Normal behavior: heap usage increases, garbage collection runs, heap usage drops back down
  • Memory leak: heap usage increases, garbage collection runs, heap usage stays high or only drops slightly

If you see heap usage climbing from 500MB to 1GB to 1.5GB over hours or days without dropping back to baseline after GC, you have a leak.

For more precise tracking, enable GC logging in your JVM startup arguments:

-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags

This writes every GC event to /var/log/gc.log. Look for GC events where old generation memory does not get reclaimed. If you see GC running frequently but memory never dropping, the leak is in the old generation — long-lived objects that should have been garbage collected but are still referenced.

Why this matters: You need to know when the leak is happening before you capture a heap dump. A heap dump is a snapshot of memory at one moment. If you capture it before the leak has grown large enough, you will not see the problem clearly. Wait until heap usage has grown significantly — ideally to 70–80% of max heap — before capturing the dump. This makes the leak easier to spot in the analysis phase.

Step 3: Capture a Heap Dump at the Right Moment

A heap dump is a snapshot of all objects in memory at a specific moment. To diagnose a memory leak, you need to capture the heap dump when the leak is large enough to see clearly but before the app crashes with OutOfMemoryError.

The best time to capture a heap dump is when heap usage has reached 70–80% of max heap and is not dropping after garbage collection. You can capture the heap dump in three ways depending on your environment.

Method 1: Using Spring Boot Actuator Endpoint

If you enabled the heapdump endpoint in Step 1, you can capture a heap dump via HTTP:

curl http://localhost:8080/actuator/heapdump -O heapdump.hprof

This downloads the heap dump as heapdump.hprof to your current directory. This method works in containerized environments where you may not have shell access to the pod.

Method 2: Using jmap Command

If you have SSH or exec access to the server or container, use jmap:

jps

This lists all running Java processes with their process IDs. Find your Spring Boot app’s PID, then capture the heap dump:

jmap -dump:format=b,file=heap.hprof <PID>

Replace <PID> with the actual process ID. This creates heap.hprof in the current directory.

Method 3: Automatically on OutOfMemoryError

To capture a heap dump automatically when the app crashes with OutOfMemoryError, add this JVM argument:

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/heapdump.hprof

This writes a heap dump to /var/log/heapdump.hprof the moment the app runs out of memory. This is useful if you cannot predict when the crash will happen or if the leak happens during off hours.

Why this matters: The heap dump is your primary diagnostic artifact. Without it, you are guessing. With it, you can see exactly which objects are consuming memory and which references are preventing garbage collection. Heap dumps are large — often 2–4GB for a 2GB heap — so ensure you have enough disk space before triggering the dump.

Step 4: Analyze the Heap Dump with Eclipse MAT

Eclipse Memory Analyzer Tool is the standard tool for analyzing heap dumps from Java applications. It runs locally on your machine and can handle multi-gigabyte heap dumps efficiently.

Download and install MAT from eclipse.org/mat/downloads. Launch MAT and open your heap dump file via File > Open Heap Dump. MAT will parse the file — this can take several minutes for large dumps.

Once parsed, MAT automatically runs a Leak Suspects Report. This report identifies objects that are consuming the most memory and shows you which references are keeping them alive.

Look for these key sections in the Leak Suspects Report:

  • Problem Suspect 1: The object or collection consuming the most memory
  • Shortest paths to the accumulation point: The chain of references keeping these objects alive

Common patterns you will see in Spring Boot leaks:

  • A ConcurrentHashMap or ArrayList growing without bounds
  • A ThreadLocal holding user session data that never gets cleared
  • A JPA EntityManager holding thousands of entity instances because a Stream<Entity> was never closed
  • A static Map or List that only grows and never evicts entries

Once you identify the object consuming memory, use MAT’s Dominator Tree view to see which objects are retaining the most memory. Right-click on the suspect object and select List objects > with outgoing references to see what it is holding on to.

Why this matters: MAT does the hard work of analyzing object graphs and finding the root cause. Without it, you would need to manually trace object references through millions of instances. The Leak Suspects Report gives you the exact class, field, and reference chain causing the leak — often in under 5 minutes.

Step 5: Fix Common Spring Boot Memory Leak Patterns

Most memory leaks in Spring Boot fall into four categories: unclosed JPA streams, ThreadLocal misuse, unbounded caches, and static collections. Each has a clear fix.

Pattern 1: Unclosed JPA Stream\<Entity\>

JPA streams are lazy and hold a reference to the underlying EntityManager. If you do not close the stream, the EntityManager holds every entity loaded during the stream in memory until the session closes.

Bad code:

public List<User> getActiveUsers() {
    return userRepository.findAll()
        .stream()
        .filter(User::isActive)
        .collect(Collectors.toList());
}

This loads all users into memory even if you only need 10. The stream is never closed, so the EntityManager keeps all entities in the persistence context.

Fix:

public List<User> getActiveUsers() {
    try (Stream<User> stream = userRepository.streamAllBy()) {
        return stream
            .filter(User::isActive)
            .limit(100)
            .collect(Collectors.toList());
    }
}

The try-with-resources block ensures the stream is closed after use. Use streamAllBy() or streamBy() methods instead of findAll().stream() to avoid loading everything into memory.

Pattern 2: ThreadLocal Not Cleared After Request

Servlet containers like Tomcat reuse threads from a thread pool. If a filter stores user context in a ThreadLocal and does not clear it after the request ends, the data stays in memory and piles up with every request.

Bad code:

public class UserContextFilter implements Filter {
    private static final ThreadLocal<UserContext> context = new ThreadLocal<>();
    
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
        context.set(extractUserContext(request));
        chain.doFilter(request, response);
    }
}

This sets the ThreadLocal but never clears it. The thread gets returned to the pool with the UserContext still attached.

Fix:

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
    try {
        context.set(extractUserContext(request));
        chain.doFilter(request, response);
    } finally {
        context.remove();
    }
}

The finally block ensures ThreadLocal.remove() is called even if the request throws an exception.

Pattern 3: ConcurrentHashMap as Cache Without Eviction

Using a ConcurrentHashMap or HashMap as an in-memory cache without size limits or TTL causes unbounded growth.

Bad code:

private final Map<String, User> userCache = new ConcurrentHashMap<>();
public User getUser(String id) {
    return userCache.computeIfAbsent(id, this::loadUserFromDb);
}

This cache only grows. Every unique user ID adds an entry that never expires.

Fix — use Spring’s @Cacheable with a proper cache manager:

@Cacheable(value = "users", key = "#id")
public User getUser(String id) {
    return loadUserFromDb(id);
}

Configure Caffeine or EhCache with max size and TTL in application.properties:

spring.cache.type=caffeine
spring.cache.caffeine.spec=maximumSize=10000,expireAfterWrite=10m

This limits the cache to 10,000 entries and evicts entries after 10 minutes.

Pattern 4: Static List or Map That Only Grows

Static collections live for the entire lifetime of the JVM. If you add to them on every request without clearing or limiting size, they will eventually consume all heap.

Bad code:

private static final List<AuditEvent> auditLog = new ArrayList<>();
public void logEvent(AuditEvent event) {
    auditLog.add(event);
}

This list grows indefinitely. After a million events, it holds a million objects in memory.

Fix — use a bounded queue or write to a log file instead:

private static final Queue<AuditEvent> auditLog = new ArrayBlockingQueue<>(1000);
public void logEvent(AuditEvent event) {
    if (!auditLog.offer(event)) {
        auditLog.poll();
        auditLog.offer(event);
    }
}

This keeps only the most recent 1,000 events in memory. Older events are dropped. For production systems, write audit logs to a file or external logging system instead of holding them in memory.

Step 6: Monitor Memory After the Fix with CubeAPM

After applying the fix, deploy the new version and monitor memory usage to confirm the leak is resolved. If heap usage now drops after garbage collection and stays within normal bounds, the fix worked.

For production systems, continuous monitoring prevents regressions. CubeAPM tracks JVM memory metrics, garbage collection behavior, and heap usage trends in real time. It runs inside your own cloud or on-prem infrastructure, so telemetry data never leaves your environment — critical for teams with data residency or compliance requirements.

CubeAPM captures memory metrics at the service and JVM level, correlates them with distributed traces, and triggers alerts when heap usage crosses thresholds you define. Unlike cloud SaaS tools that charge per host or per user, CubeAPM uses predictable $0.15/GB ingestion pricing with unlimited retention — no surprise bills when traffic spikes.

To set up JVM memory monitoring in CubeAPM:

  1. Deploy the CubeAPM agent alongside your Spring Boot app — it supports OpenTelemetry, Datadog, and New Relic agents for incremental migration
  2. Configure JVM metrics collection in the agent config
  3. Create a dashboard tracking jvm.memory.used, jvm.gc.pause, and heap usage by pool (Eden, Survivor, Old Gen)
  4. Set an alert to trigger when old generation heap usage exceeds 75% for more than 5 minutes

This gives you continuous visibility into memory behavior without adding per-host costs or sending telemetry outside your infrastructure.

Troubleshooting Common Issues

Heap Dump File Is Too Large to Transfer

Heap dumps for large apps can be 4–8GB. If you cannot transfer the file over the network, compress it before download:

gzip heap.hprof

This reduces file size by 60–80%. Transfer the .gz file and decompress locally before opening in MAT.

MAT Runs Out of Memory When Opening the Heap Dump

MAT itself is a Java app and needs enough heap to parse large heap dumps. If MAT crashes with OutOfMemoryError, increase its heap size by editing MemoryAnalyzer.ini in the MAT installation directory:

-Xmx8g

This gives MAT 8GB of heap. For very large dumps (10GB+), increase to 12–16GB.

Heap Dump Shows No Clear Leak Suspect

If MAT does not identify a clear leak suspect, the problem may be many small leaks rather than one large one. Use the Histogram view in MAT to see object counts by class. Sort by number of instances. Look for classes with hundreds of thousands or millions of instances — these are often the culprit.

For example, if you see 500,000 instances of UserSession or RequestContext, you likely have a collection holding onto these objects when it should not.

Memory Leak Only Happens Under Load

Some leaks only appear under sustained load. To reproduce locally, use a load testing tool like Apache JMeter or Gatling to simulate production traffic patterns. Run the load test for several hours while monitoring heap usage. Capture the heap dump when heap has grown significantly.

Conclusion

Memory leaks in Spring Boot do not crash your app immediately — they grow silently and surface as production outages during peak traffic. The detection workflow is consistent: enable Actuator metrics, monitor heap usage over time, capture a heap dump when the leak is large enough to see, analyze with Eclipse MAT to find the root cause, and fix the underlying pattern — unclosed streams, ThreadLocal misuse, unbounded caches, or static collections.

After fixing the leak, continuous monitoring prevents regressions. Tools like CubeAPM track JVM memory metrics and heap behavior in real time, running inside your own infrastructure with predictable pricing and unlimited retention.

Disclaimer: The information in this article reflects the latest details available at the time of publication and may change as technologies and products evolve. Features, pricing, and plan limits can change over time. Always verify the latest information directly with the vendor before making purchasing or deployment decisions.

Frequently Asked Questions

How do I know if my Spring Boot app has a memory leak?

Monitor `jvm.memory.used` via Spring Boot Actuator. If heap usage grows continuously and does not drop after garbage collection, you have a leak. Capture a heap dump and analyze with Eclipse MAT to find the root cause.

What is the best tool to analyze Java heap dumps?

Eclipse Memory Analyzer Tool (MAT) is the standard. It runs locally, handles multi-gigabyte heap dumps, and automatically identifies leak suspects with the Leak Suspects Report.

When should I capture a heap dump?

Capture when heap usage has reached 70–80% of max heap and is not dropping after GC. This makes the leak large enough to see clearly in the analysis without waiting for an OutOfMemoryError crash.

What causes most memory leaks in Spring Boot?

Unclosed JPA streams, ThreadLocal not cleared after requests, ConcurrentHashMap used as cache without eviction, and static collections that only grow. All four have clear fixes documented in this guide.

Can I capture a heap dump without restarting my app?

Yes. Use the Spring Boot Actuator `/actuator/heapdump` endpoint or `jmap -dump` command. Both capture a snapshot of memory without stopping the app.

How do I prevent memory leaks in production Spring Boot apps?

Use `try-with-resources` for JPA streams, always clear ThreadLocal in `finally` blocks, use Spring Cache with eviction policies instead of raw maps, and avoid static collections. Monitor heap usage continuously with tools like CubeAPM to catch regressions early.

Why does MAT say no leak suspects found even though my app is leaking memory?

The leak may be many small leaks rather than one large object. Use the Histogram view in MAT to see object counts by class. Look for classes with hundreds of thousands of instances — these are often the culprit.

×
×