Memory leaks in Spring Boot apps cause OOM errors, growing heap usage, and unpredictable GC pauses. Learn the most common leak patterns and how to detect them with heap dumps and profilers.
JOptimize Team
A memory leak in Java is subtle. The JVM doesn't crash immediately — heap usage climbs gradually, GC runs longer, response times increase, and eventually you get an OutOfMemoryError: Java heap space at 3 AM. The hard part isn't fixing the leak once found — it's finding it.
// LEAK — static map grows without bound public class MetricsService { private static final Map<String, List<Long>> metrics = new HashMap<>(); public void record(String key, long value) { metrics.computeIfAbsent(key, k -> new ArrayList<>()).add(value); // Never trimmed — memory grows with every recorded metric } }
Fix: use a bounded cache (Caffeine, LinkedHashMap with max size) or aggregate instead of accumulating:
private static final Map<String, LongSummaryStatistics> metrics = new ConcurrentHashMap<>(); public void record(String key, long value) { metrics.merge(key, new LongSummaryStatistics(), (existing, newStats) -> { existing.accept(value); return existing; }); // Stores statistics, not raw values — bounded memory }
// LEAK — ThreadLocal is never removed in a pooled thread public class UserContextHolder { private static final ThreadLocal<UserContext> context = new ThreadLocal<>(); public static void set(UserContext ctx) { context.set(ctx); } public static UserContext get() { return context.get(); } // No remove() — thread pool reuses threads, context accumulates }
Fix: always remove in a finally block or use a request interceptor:
@Component public class UserContextInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) { UserContextHolder.set(extractContext(req)); return true; } @Override public void afterCompletion(HttpServletRequest req, HttpServletResponse res, Object handler, Exception ex) { UserContextHolder.remove(); // Critical — cleans up before thread returns to pool } }
// LEAK — InputStream never closed on exception path public byte[] readFile(String path) throws IOException { InputStream is = new FileInputStream(path); byte[] data = is.readAllBytes(); is.close(); // Never reached if readAllBytes() throws return data; }
Fix: always use try-with-resources:
public byte[] readFile(String path) throws IOException { try (InputStream is = new FileInputStream(path)) { return is.readAllBytes(); } // is.close() called automatically, even on exception }
// LEAK — listener registered but never removed @Component public class ReportGenerator { @Autowired public ReportGenerator(ApplicationEventPublisher publisher) { publisher.registerListener(this::onDataChange); // Strong reference held // If ReportGenerator is prototype-scoped or garbage collected, listener stays } }
Fix: use Spring's @EventListener annotation — Spring manages the lifecycle:
@Component public class ReportGenerator { @EventListener(DataChangeEvent.class) public void onDataChange(DataChangeEvent event) { // Spring registers and unregisters automatically } }
// LEAK — storing large objects in session accumulates across users @GetMapping("/report") public String generateReport(HttpSession session) { List<ReportRow> rows = reportService.getAll(); // Could be 50MB session.setAttribute("report", rows); // Stays until session expires return "report"; }
Fix: never store large objects in session. Store IDs and reload, or use a cache with explicit eviction.
Step 1: Take a heap dump on OOM
# JVM flags — dump heap on OOM -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof
Step 2: Force a heap dump from a running JVM
# Get PID jps -l # Take heap dump jmap -dump:format=b,file=/tmp/heapdump.hprof <PID>
Step 3: Analyze with Eclipse MAT (Memory Analyzer Tool)
Open the .hprof file in Eclipse MAT and look at:
A real leak shows as a single object type holding 60%+ of retained heap.
# application.properties management.endpoints.web.exposure.include=metrics,health management.metrics.enable.jvm=true
Key metrics to watch:
GET /actuator/metrics/jvm.memory.used GET /actuator/metrics/jvm.gc.pause GET /actuator/metrics/jvm.memory.committed
A healthy app shows jvm.memory.used oscillating (GC collecting). A leaking app shows it climbing steadily with GC unable to reclaim.
# GC logging — see if GC is running but memory still grows -Xlog:gc*:file=/tmp/gc.log:time,uptime:filecount=5,filesize=20m # Explicit GC to test if objects are collectible -XX:+ExplicitGCInvokesConcurrent # Native memory tracking (for off-heap leaks) -XX:NativeMemoryTracking=summary
If jcmd <PID> VM.native_memory summary shows growing committed memory that doesn't appear in heap, you have an off-heap or metaspace leak.
.close() exhaust the pool; use try-with-resources or JdbcTemplateObjectProvider<T> instead@Transactional method processing thousands of records, session.clear() prevents the first-level cache from holding all loaded entitiesThe most common Java memory leaks are: static collections without eviction, ThreadLocal not cleaned in thread pools, unclosed streams/connections, deregistered event listeners, and large objects stored in HTTP sessions. Detect them with heap dump analysis (Eclipse MAT) and Actuator memory metrics. The fix is usually one of: bounded collection, try-with-resources, or explicit cleanup in finally/afterCompletion.
JOptimize flags unbounded static collections, ThreadLocal without remove(), and unclosed resource patterns in Spring Boot projects.
Find memory leaks before your prod server runs out of heap.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.