Hook: A Spring Boot memory leak is like a guest who keeps leaving their coat in your house every hour; the garbage collector can only clean up objects that are truly unreachable.
Question: How do you investigate a memory leak in a Spring Boot application?
Answer: A memory leak means objects are still being held in memory even though the app no longer needs them, so the JVM cannot reclaim them. In Spring Boot, the usual suspects are static collections, caches without eviction, ThreadLocal misuse, listeners, and unclosed resources. The investigation starts by confirming the symptom, then capturing a heap dump, finding the biggest retained objects, and tracing who is still referencing them.
Interview-Ready Answer: I would first confirm it is a real heap leak by watching heap usage and GC pauses under steady load. Then I would capture a heap dump with jcmd, inspect retained size in a tool like Eclipse MAT, and trace the GC roots back to the code that is keeping objects alive. In Spring Boot, I would especially check static maps, unbounded caches, ThreadLocal values, and long-lived singleton beans. Once I find the root reference, I would fix the ownership or eviction policy and verify the heap stays stable under the same traffic.
In Java, the garbage collector only frees objects that are unreachable from any GC root. A GC root is a starting point such as a live thread, a static field, or a loaded class. If your app keeps a reference to an object by mistake, that object stays reachable and grows the heap over time. That is why a leak is usually not about bytes disappearing from RAM; it is about the program accidentally keeping references alive.
OutOfMemoryError or pod restarts. If traffic stops and memory does not fall, that is a strong clue.jcmd <pid> GC.heap_dump /tmp/app.hprof. A heap dump is a snapshot of live objects. It is large, often close to the live heap size, so on a 2 GB heap dump you may need several GB of workstation RAM to inspect it comfortably.| Tool | Best for | Main trade-off |
|---|---|---|
| GC logs | Fast first signal | Shows symptoms, not roots |
| JFR | Timeline and allocation bursts | More setup, less detail than a dump |
| Heap dump | Root-cause analysis | Heavy to capture and inspect |
static Map or static List lives for the entire classloader lifetime.ThreadLocal stores data per thread; if a pool thread is reused, the value can stick around forever unless removed.Performance note: Finding the biggest objects in a dump is roughly linear in the number of live objects, but retained-size analysis can still take minutes on multi-GB heaps. A good rule is to fix the root reference rather than just increasing Xmx; raising heap may delay the crash but does not solve the leak.
Memory hook: Think of memory as a hotel. GC cleans empty rooms, but a leak is a guest who keeps a fake reservation alive so the room can never be reused.
Imagine an e-commerce checkout service built with Spring Boot. During a weekend sale, traffic doubles and the team notices the pod memory climbing from 400 MB to 1.8 GB over a few hours. Latency jumps from 80 ms to 4 seconds, the JVM starts doing full GCs every few seconds, and Kubernetes eventually kills the pod with OOMKilled.
The root cause turns out to be a singleton bean that stores per-order response objects in a static ConcurrentHashMap for debugging. Because order IDs are unique, the map never reuses keys, so every request leaves another large object behind. The logs show growing GC pauses and the heap dump reveals that the map is retaining most of the live heap. The fix is to remove the debug store, or replace it with a bounded cache and proper expiry.
What goes wrong when people misunderstand it: teams often add more memory first. That can hide the leak for a few more hours, but the curve still grows, the app still gets slower, and the next outage is bigger and harder to diagnose.
package com.example.memoryleak;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.management.MemoryUsage;
import java.time.Instant;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@SpringBootApplication
public class MemoryLeakInvestigationApplication {
public static void main(String[] args) {
SpringApplication.run(MemoryLeakInvestigationApplication.class, args);
}
}
@RestController
@RequestMapping(path = "/demo", produces = MediaType.APPLICATION_JSON_VALUE)
class LeakDemoController {
// This bean is a singleton, so its fields live for the life of the application.
// We keep the registry objects as normal fields to show that the leak is caused
// by the data structure inside them, not by creating a new controller per request.
private final LeakyRegistry leakyRegistry = new LeakyRegistry();
private final BoundedRegistry boundedRegistry = new BoundedRegistry(100);
private final MemoryMXBean memoryMxBean = ManagementFactory.getMemoryMXBean();
@PostMapping("/leak/{key}")
public Map<String, Object> leak(@PathVariable String key,
@RequestParam(defaultValue = "256") int kilobytes) {
validateInput(key, kilobytes);
// Each call allocates a fresh payload. If we store it in a static map, the object
// remains reachable and GC cannot reclaim it.
byte[] payload = new byte[kilobytes * 1024];
leakyRegistry.store(key, payload);
return stats("stored in leaky registry");
}
@PostMapping("/bounded/{key}")
public Map<String, Object> bounded(@PathVariable String key,
@RequestParam(defaultValue = "256") int kilobytes) {
validateInput(key, kilobytes);
// This path shows the safer pattern: a bounded cache with eviction.
byte[] payload = new byte[kilobytes * 1024];
boundedRegistry.store(key, payload);
return stats("stored in bounded registry");
}
@GetMapping("/stats")
public Map<String, Object> statsEndpoint() {
return stats("current snapshot");
}
@DeleteMapping("/clear")
public Map<String, Object> clear() {
leakyRegistry.clear();
boundedRegistry.clear();
// Do not rely on System.gc() as a fix in real systems; it is not deterministic.
return stats("registries cleared");
}
private void validateInput(String key, int kilobytes) {
if (key == null || key.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "key must not be blank");
}
if (kilobytes <= 0 || kilobytes > 2048) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "kilobytes must be between 1 and 2048");
}
}
private Map<String, Object> stats(String note) {
MemoryUsage heap = memoryMxBean.getHeapMemoryUsage();
Map<String, Object> response = new LinkedHashMap<>();
response.put("note", note);
response.put("time", Instant.now().toString());
response.put("heapUsedMb", toMb(heap.getUsed()));
response.put("heapCommittedMb", toMb(heap.getCommitted()));
response.put("heapMaxMb", toMb(heap.getMax()));
response.put("leakyEntries", leakyRegistry.size());
response.put("boundedEntries", boundedRegistry.size());
return response;
}
private long toMb(long bytes) {
if (bytes < 0) {
return -1;
}
return bytes / (1024 * 1024);
}
}
class LeakyRegistry {
// Static state is a classic leak source: it is rooted by the classloader.
// As long as the class stays loaded, this map stays alive too.
private static final Map<String, byte[]> CACHE = new ConcurrentHashMap<>();
void store(String key, byte[] payload) {
CACHE.put(key, payload);
}
int size() {
return CACHE.size();
}
void clear() {
CACHE.clear();
}
}
class BoundedRegistry {
private final int maxEntries;
private final Map<String, byte[]> cache;
BoundedRegistry(int maxEntries) {
this.maxEntries = maxEntries;
this.cache = Collections.synchronizedMap(new LinkedHashMap<String, byte[]>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, byte[]> eldest) {
// Evict the oldest entry so the cache cannot grow forever.
return size() > BoundedRegistry.this.maxEntries;
}
});
}
void store(String key, byte[] payload) {
cache.put(key, payload);
}
int size() {
return cache.size();
}
void clear() {
cache.clear();
}
}
Follow-up & Tricky Questions:
remove() in a finally block when the value is request-scoped. This matters especially with thread pools, because pooled threads are reused and can carry old data into the next request.System.gc() fix a memory leak? No. It can only reclaim objects that are already unreachable, so a true leak still stays alive and the heap will grow again.Common Mistakes:
Xmx first. Correction: do that only as a temporary safety net; the real fix is to stop retaining objects forever.finally when using thread pools.Memory Hook: ‘If you can still point to it, GC cannot free it.’ Imagine a hotel where every room has a rope tied to the lobby desk; as long as the rope stays attached, housekeeping is not allowed to clean that room.
Cheat Sheet:
jcmd or JFR first, then a heap dump for root cause.Practice Tasks:
/demo/leak/{key} endpoint 500 times with unique keys and watch the stats grow./demo/bounded/{key} and confirm the bounded cache stops growing past 100 entries.