Back to Blog
javamemoryspring-bootperformancejvmheap

Java Memory Leaks in Spring Boot: How to Find and Fix Them (2026)

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.

J

JOptimize Team

May 25, 2026· 8 min read

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.


The Most Common Memory Leak Patterns

1. Static Collections

// 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 }

2. ThreadLocal Leak in Thread Pools

// 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 } }

3. Unclosed Streams and Connections

// 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 }

4. Event Listener Not Deregistered

// 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 } }

5. Large Objects in HTTP Session

// 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.


Detecting Leaks: Heap Dump Analysis

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:

  • Leak Suspects Report — MAT automatically identifies the largest object groups
  • Dominator Tree — shows which objects retain the most memory
  • Retained Heap — the total memory freed if this object were garbage collected

A real leak shows as a single object type holding 60%+ of retained heap.


Live Monitoring with Spring Boot Actuator

# 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.


JVM Flags for Leak Investigation

# 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.


Common Mistakes to Avoid

  • Not closing JDBC connections — connection pool connections returned without .close() exhaust the pool; use try-with-resources or JdbcTemplate
  • Prototype beans injected into singletons — a singleton holding a reference to a prototype bean keeps it alive forever; use ObjectProvider<T> instead
  • Growing Hibernate L1 cache — in a long-running @Transactional method processing thousands of records, session.clear() prevents the first-level cache from holding all loaded entities
  • Parsing and caching byte[] in static fields — compiled regex patterns are fine in static fields; large parsed data structures are not

Summary

The 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.


Detect Memory Issues in Your Codebase

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.

Want to go deeper?

Master Spring Boot, security, and Java performance with hands-on courses.

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.